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:
Cursor Agent 2026-07-30 08:21:22 +00:00
parent 9abc09c2a6
commit 17b10a0e8c
No known key found for this signature in database
6 changed files with 281 additions and 17 deletions

View File

@ -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=

94
core-backend/demo.go Normal file
View File

@ -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
}

54
core-backend/demo_test.go Normal file
View File

@ -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)
}
}

View File

@ -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)

View File

@ -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';

View File

@ -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});