diff --git a/.env.example b/.env.example index 19052ef..22b613a 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/core-backend/demo.go b/core-backend/demo.go new file mode 100644 index 0000000..0232fa8 --- /dev/null +++ b/core-backend/demo.go @@ -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 +} diff --git a/core-backend/demo_test.go b/core-backend/demo_test.go new file mode 100644 index 0000000..b0db64c --- /dev/null +++ b/core-backend/demo_test.go @@ -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) + } +} diff --git a/core-backend/main.go b/core-backend/main.go index fc86e7c..d39d85b 100644 --- a/core-backend/main.go +++ b/core-backend/main.go @@ -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,31 +163,52 @@ 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 - if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"}) - return - } - if ok, detail := inviteUsable(invite, time.Now()); !ok { - status := http.StatusBadRequest - if detail == "invite code already used" { - status = http.StatusConflict + 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 + } + if ok, detail := inviteUsable(invite, time.Now()); !ok { + status := http.StatusBadRequest + if detail == "invite code already used" { + status = http.StatusConflict + } + c.JSON(status, gin.H{"detail": detail}) + return } - 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}) - now := time.Now() - invite.UsedAt = &now - invite.UsedByUserID = &user.ID - db.Save(&invite) + 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) diff --git a/mobile/lib/config.dart b/mobile/lib/config.dart index 45c2aa5..7496b84 100644 --- a/mobile/lib/config.dart +++ b/mobile/lib/config.dart @@ -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'; diff --git a/mobile/lib/screens/signup_screen.dart b/mobile/lib/screens/signup_screen.dart index 37ed2d3..1a5745b 100644 --- a/mobile/lib/screens/signup_screen.dart +++ b/mobile/lib/screens/signup_screen.dart @@ -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 { super.dispose(); } + void _fillDemoCredentials() { + _invite.text = AppConfig.demoInviteCode; + _name.text = AppConfig.demoDisplayName; + setState(() {}); + } + @override Widget build(BuildContext context) { final session = context.watch(); @@ -72,9 +80,23 @@ class _SignupScreenState extends State { 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 { } } +/// 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});