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)
|
# core-backend privileged endpoints (/invites, /admin/metrics, /admin/dashboard, /admin/push-test)
|
||||||
ADMIN_API_TOKEN=
|
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).
|
# Optional: FCM legacy server key for real push delivery (escalation notify + /admin/push-test).
|
||||||
# Without this, notifyUser soft-skips and records push_skipped metrics.
|
# Without this, notifyUser soft-skips and records push_skipped metrics.
|
||||||
FCM_SERVER_KEY=
|
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)
|
registerA1A2Routes(r, db)
|
||||||
registerBRoutes(r, db)
|
registerBRoutes(r, db)
|
||||||
registerInviteOpsRoutes(r, db)
|
registerInviteOpsRoutes(r, db)
|
||||||
|
registerDemoRoutes(r)
|
||||||
|
|
||||||
r.GET("/admin/metrics", func(c *gin.Context) {
|
r.GET("/admin/metrics", func(c *gin.Context) {
|
||||||
if !requireAdmin(c) {
|
if !requireAdmin(c) {
|
||||||
|
|
@ -162,7 +163,25 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
// 초대 기반 베타(roadmap.md §2.6): 가입은 누군가 실제로 발급한 미사용
|
// 초대 기반 베타(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
|
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 {
|
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
||||||
return
|
return
|
||||||
|
|
@ -175,18 +194,21 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
c.JSON(status, gin.H{"detail": detail})
|
c.JSON(status, gin.H{"detail": detail})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
user := User{InviteCode: req.InviteCode, DisplayName: req.DisplayName}
|
user := User{InviteCode: storedInvite, DisplayName: req.DisplayName}
|
||||||
if err := db.Create(&user).Error; err != nil {
|
if err := db.Create(&user).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
|
db.Create(&TwinSettings{UserID: user.ID, AutonomyLevel: AutonomyL0})
|
||||||
|
|
||||||
|
if !demo {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
invite.UsedAt = &now
|
invite.UsedAt = &now
|
||||||
invite.UsedByUserID = &user.ID
|
invite.UsedByUserID = &user.ID
|
||||||
db.Save(&invite)
|
db.Save(&invite)
|
||||||
|
}
|
||||||
|
|
||||||
session, err := createSession(db, user.ID)
|
session, err := createSession(db, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -674,6 +696,7 @@ func main() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db := openDB()
|
db := openDB()
|
||||||
|
seedDemoInvite(db)
|
||||||
relay := newConnectionManager()
|
relay := newConnectionManager()
|
||||||
ai := newAIServiceClient()
|
ai := newAIServiceClient()
|
||||||
r := setupRouter(db, relay, ai)
|
r := setupRouter(db, relay, ai)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ class AppConfig {
|
||||||
defaultValue: 'http://10.0.2.2:8080', // Android emulator → host localhost
|
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() {
|
static String wsBase() {
|
||||||
final uri = Uri.parse(coreApiBase);
|
final uri = Uri.parse(coreApiBase);
|
||||||
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
|
final scheme = uri.scheme == 'https' ? 'wss' : 'ws';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../config.dart';
|
||||||
import '../state/session_state.dart';
|
import '../state/session_state.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/twin_hero_backdrop.dart';
|
import '../widgets/twin_hero_backdrop.dart';
|
||||||
|
|
@ -23,6 +25,12 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _fillDemoCredentials() {
|
||||||
|
_invite.text = AppConfig.demoInviteCode;
|
||||||
|
_name.text = AppConfig.demoDisplayName;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionState>();
|
final session = context.watch<SessionState>();
|
||||||
|
|
@ -72,9 +80,23 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
style: theme.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(flex: 3),
|
const Spacer(flex: 2),
|
||||||
TwinFadeUp(
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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 {
|
class _PressScale extends StatefulWidget {
|
||||||
const _PressScale({required this.child});
|
const _PressScale({required this.child});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue