feat: show shared DEMO-BUNSIN invite on signup for testers
Seed a reusable demo invite (ALLOW_DEMO_INVITE, default on) and surface it on the Flutter signup screen with one-tap fill so others can try without asking for a one-off admin mint. Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
parent
9abc09c2a6
commit
17b10a0e8c
|
|
@ -5,6 +5,9 @@ GEMINI_API_KEY=
|
|||
# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test)
|
||||
ADMIN_API_TOKEN=
|
||||
|
||||
# Shared demo invite DEMO-BUNSIN on signup UI (multiple testers). Set 0 in production.
|
||||
ALLOW_DEMO_INVITE=1
|
||||
|
||||
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).
|
||||
# Without this, notifyUser soft-skips and records push_skipped metrics.
|
||||
FCM_SERVER_KEY=
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Shared closed-beta demo invite shown on the Flutter signup screen.
|
||||
// Multiple testers can use the same code when ALLOW_DEMO_INVITE is enabled.
|
||||
const demoInviteCode = "DEMO-BUNSIN"
|
||||
|
||||
func demoInviteEnabled() bool {
|
||||
v := strings.TrimSpace(os.Getenv("ALLOW_DEMO_INVITE"))
|
||||
if v == "" {
|
||||
// Default on for local / Phase-1 shared testing. Set ALLOW_DEMO_INVITE=0 in prod.
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(v) {
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func isDemoInviteCode(code string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(code), demoInviteCode)
|
||||
}
|
||||
|
||||
func seedDemoInvite(db *gorm.DB) {
|
||||
if !demoInviteEnabled() {
|
||||
return
|
||||
}
|
||||
var existing InviteCode
|
||||
err := db.Where("code = ?", demoInviteCode).First(&existing).Error
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
log.Printf("demo invite lookup: %v", err)
|
||||
return
|
||||
}
|
||||
inv := InviteCode{
|
||||
Code: demoInviteCode,
|
||||
Note: "shared demo invite (ALLOW_DEMO_INVITE)",
|
||||
}
|
||||
if err := db.Create(&inv).Error; err != nil {
|
||||
log.Printf("demo invite seed failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("seeded demo invite code %s", demoInviteCode)
|
||||
}
|
||||
|
||||
func registerDemoRoutes(r *gin.Engine) {
|
||||
r.GET("/demo", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"demo_invite_enabled": demoInviteEnabled(),
|
||||
"demo_invite_code": demoInviteCode,
|
||||
"demo_display_name": "테스터",
|
||||
"hint": "회원가입 화면에 표시된 테스트 코드를 그대로 쓰면 됩니다.",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// demoSignupInviteCode returns the InviteCode row for demo signups, creating it
|
||||
// if needed. Usability checks for "already used" are skipped by the caller.
|
||||
func demoSignupInviteCode(db *gorm.DB) (InviteCode, error) {
|
||||
var invite InviteCode
|
||||
err := db.Where("code = ?", demoInviteCode).First(&invite).Error
|
||||
if err == nil {
|
||||
return invite, nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return InviteCode{}, err
|
||||
}
|
||||
seedDemoInvite(db)
|
||||
err = db.Where("code = ?", demoInviteCode).First(&invite).Error
|
||||
return invite, err
|
||||
}
|
||||
|
||||
// uniqueDemoUserInvite stores a per-user value on users.invite_code (unique)
|
||||
// while still accepting the shared DEMO-BUNSIN input.
|
||||
func uniqueDemoUserInvite() (string, error) {
|
||||
suffix, err := generateInviteCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return demoInviteCode + "-" + suffix, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDemoInviteReusableForMultipleSignups(t *testing.T) {
|
||||
t.Setenv("ALLOW_DEMO_INVITE", "1")
|
||||
server, db := setupTestServer(t)
|
||||
seedDemoInvite(db)
|
||||
|
||||
demoResp, err := http.Get(server.URL + "/demo")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /demo: %v", err)
|
||||
}
|
||||
defer demoResp.Body.Close()
|
||||
var demo map[string]any
|
||||
if err := json.NewDecoder(demoResp.Body).Decode(&demo); err != nil {
|
||||
t.Fatalf("decode demo: %v", err)
|
||||
}
|
||||
if demo["demo_invite_code"] != demoInviteCode {
|
||||
t.Fatalf("demo code: %v", demo["demo_invite_code"])
|
||||
}
|
||||
|
||||
a := postJSON(t, server.URL+"/auth/signup", signupRequest{
|
||||
InviteCode: demoInviteCode,
|
||||
DisplayName: "테스터A",
|
||||
})
|
||||
defer a.Body.Close()
|
||||
aBody, _ := io.ReadAll(a.Body)
|
||||
if a.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first demo signup: %d %s", a.StatusCode, aBody)
|
||||
}
|
||||
|
||||
b := postJSON(t, server.URL+"/auth/signup", signupRequest{
|
||||
InviteCode: demoInviteCode,
|
||||
DisplayName: "테스터B",
|
||||
})
|
||||
defer b.Body.Close()
|
||||
bBody, _ := io.ReadAll(b.Body)
|
||||
if b.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second demo signup should reuse code: %d %s", b.StatusCode, bBody)
|
||||
}
|
||||
|
||||
var outA, outB map[string]any
|
||||
_ = json.Unmarshal(aBody, &outA)
|
||||
_ = json.Unmarshal(bBody, &outB)
|
||||
if outA["id"] == outB["id"] {
|
||||
t.Fatalf("expected distinct users, got %#v %#v", outA, outB)
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
registerA1A2Routes(r, db)
|
||||
registerBRoutes(r, db)
|
||||
registerInviteOpsRoutes(r, db)
|
||||
registerDemoRoutes(r)
|
||||
|
||||
r.GET("/admin/metrics", func(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
|
|
@ -162,7 +163,25 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
// 초대 기반 베타(roadmap.md §2.6): 가입은 누군가 실제로 발급한 미사용
|
||||
// 코드가 있어야만 된다 -- 아무 문자열이나 처음 쓰면 통과되던 이전
|
||||
// 방식은 "초대 기반"이 아니었음.
|
||||
//
|
||||
// Shared demo: DEMO-BUNSIN (ALLOW_DEMO_INVITE, default on) can be reused
|
||||
// by multiple testers; User.InviteCode still gets a unique stored value.
|
||||
demo := demoInviteEnabled() && isDemoInviteCode(req.InviteCode)
|
||||
var invite InviteCode
|
||||
storedInvite := req.InviteCode
|
||||
if demo {
|
||||
var err error
|
||||
invite, err = demoSignupInviteCode(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
storedInvite, err = uniqueDemoUserInvite()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
||||
return
|
||||
|
|
@ -175,18 +194,21 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
|||
c.JSON(status, gin.H{"detail": detail})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user := User{InviteCode: req.InviteCode, DisplayName: req.DisplayName}
|
||||
user := User{InviteCode: storedInvite, DisplayName: req.DisplayName}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
|
||||
|
||||
if !demo {
|
||||
now := time.Now()
|
||||
invite.UsedAt = &now
|
||||
invite.UsedByUserID = &user.ID
|
||||
db.Save(&invite)
|
||||
}
|
||||
|
||||
session, err := createSession(db, user.ID)
|
||||
if err != nil {
|
||||
|
|
@ -674,6 +696,7 @@ func main() {
|
|||
return
|
||||
}
|
||||
db := openDB()
|
||||
seedDemoInvite(db)
|
||||
relay := newConnectionManager()
|
||||
ai := newAIServiceClient()
|
||||
r := setupRouter(db, relay, ai)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ class AppConfig {
|
|||
defaultValue: 'http://10.0.2.2:8080', // Android emulator → host localhost
|
||||
);
|
||||
|
||||
/// Shared demo invite (must match core-backend `demoInviteCode`).
|
||||
/// Shown on the signup screen so other testers can join without admin mint.
|
||||
static const demoInviteCode = 'DEMO-BUNSIN';
|
||||
static const demoDisplayName = '테스터';
|
||||
|
||||
|
||||
static String wsBase() {
|
||||
final uri = Uri.parse(coreApiBase);
|
||||
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../config.dart';
|
||||
import '../state/session_state.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/twin_hero_backdrop.dart';
|
||||
|
|
@ -23,6 +25,12 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
void _fillDemoCredentials() {
|
||||
_invite.text = AppConfig.demoInviteCode;
|
||||
_name.text = AppConfig.demoDisplayName;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionState>();
|
||||
|
|
@ -72,9 +80,23 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
const Spacer(flex: 3),
|
||||
const Spacer(flex: 2),
|
||||
TwinFadeUp(
|
||||
delay: const Duration(milliseconds: 220),
|
||||
delay: const Duration(milliseconds: 200),
|
||||
child: _DemoTestPanel(
|
||||
onFill: _fillDemoCredentials,
|
||||
onCopy: () async {
|
||||
await Clipboard.setData(const ClipboardData(text: AppConfig.demoInviteCode));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('테스트 초대 코드를 복사했습니다')),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TwinFadeUp(
|
||||
delay: const Duration(milliseconds: 240),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
|
@ -133,6 +155,68 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Visible test credentials so other people can try the closed beta without
|
||||
/// asking Master for a one-off invite mint.
|
||||
class _DemoTestPanel extends StatelessWidget {
|
||||
const _DemoTestPanel({required this.onFill, required this.onCopy});
|
||||
|
||||
final VoidCallback onFill;
|
||||
final VoidCallback onCopy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: TwinTokens.mist.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
onTap: onFill,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'테스트용 (누구나)',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: TwinTokens.forest,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'초대 코드 ${AppConfig.demoInviteCode}\n표시 이름 ${AppConfig.demoDisplayName}',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: TwinTokens.ink,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '코드 복사',
|
||||
onPressed: onCopy,
|
||||
icon: const Icon(Icons.copy_rounded, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'탭하면 입력란에 채워집니다 · 여러 명이 같은 코드로 가입 가능',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: TwinTokens.ink.withValues(alpha: 0.55)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PressScale extends StatefulWidget {
|
||||
const _PressScale({required this.child});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue