# 邀请奖励 + 奖励任务(加入 TG 频道)Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 用「送 Pro 会员天数」驱动增长——邀请两段式(注册双方各 +3、被邀请人首充双方各 +7)+ 奖励任务(加入 TG 频道 +3,Bot 真校验),全部复用现有 subscriptions 发天数链路。 **Architecture:** 新增 `server/internal/reward` 包承载发奖 + 防刷;发天数复用 `codes.Service`(新增 `GrantRewardTx`)。注册段在 `auth.Service.Register` 后best-effort触发(自有 tx);首充段挂 `pay/webhook.settle` 同事务内;TG 走 `POST /tg/webhook`(getChatMember 真校验)。客户端 invite_page 由占位转真实(邀请区 + 任务区),注册页加邀请码输入。 **Tech Stack:** Go(chi + 裸 SQL + golang-migrate 双 DB)、Redis(token)、Flutter/Riverpod、Telegram Bot API。 ## Global Constraints - 发天数一律复用 `codes.Service`(Pro plan);**迁移须把 `subscriptions.source` CHECK/ENUM 从 `('trial','code','pay')` 扩到 `('trial','code','pay','invite','task')`**(sqlite 重建表 / mysql `MODIFY`)。 - 奖励天数:注册段各 **3**、首充段各 **7**、TG 任务 **3**;注册段邀请人**月度封顶默认 10**(env `INVITE_REG_MONTHLY_CAP`,`getenvDefault` 缺省 "10")。 - 审计一律走 `audit_log`(`store.WriteAuditLog(ctx, tx, actor, action, target, meta)`),**无 sub_events 表**。 - 多 DB:`server/migrations/{mysql,sqlite}/` 两套文件**一一对应**;裸 SQL + `internal/db` dialect 层;时间一律 Go 端 `time.Now().UTC()` 传 `?`,**禁** `NOW()`/`UTC_TIMESTAMP()`。 - 邀请码 = **8 位 base32 大写、去 `0/O/1/I`**;`users.invite_code` UNIQUE、惰性生成。 - 绑定**仅注册时**、first-touch 永久不可改。防刷:自邀请拦截(`inviter_id≠invitee_id`)+ `referrals.invitee_id` UNIQUE + 设备去重(`devices.uuid == DeviceMeta.DeviceID` 已属别的用户则注册奖励不发、记 `rejected`)+ 注册段月度封顶 + `reward_claims` 双唯一 `(user_id,task_key)`/`(task_key,external_ref)`。 - TG:`getChatMember` 返回 `member`/`administrator`/`creator` 视为已加入;bot 未配(`TG_REWARD_BOT_TOKEN` 空)则 `/tg/webhook` 返回 404 且 App 任务卡隐藏。webhook 校验 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`。 - 客户端 l10n **无 codegen**:新 string 须 `app_text.dart` 加抽象 getter + 6 个 `strings_{zh,en,es,ja,ko,ru}.dart` 各加实现。 - **deep-link 自动预填 = 可选后置任务(Task 13)**;MVP 走注册页手填邀请码,邀请链接指向网页落地页(`url_launcher` 已在)。 - TG 新基建(reward bot / 设为频道管理员 / `setWebhook`)由用户在实现前于 Telegram 侧配好。 - 命令:后端 `cd server && go test ./...`;客户端 `cd client && flutter analyze && flutter test`。 --- ## File Structure - `server/migrations/{mysql,sqlite}/000024_invite_rewards.{up,down}.sql` — 新表 + source 扩容(**新建**)。 - `server/internal/codes/paygrant.go` — 加 `GrantRewardTx`(**改**)。 - `server/internal/reward/store.go` — referrals / reward_claims / invite_code / first_paid_at 的 SQL(**新建**)。 - `server/internal/reward/service.go` — 发奖 + 防刷 + 邀请码生成/解析 + summary(**新建**)。 - `server/internal/reward/telegram.go` — token 签发 + getChatMember 客户端 + TG 领取(**新建**)。 - `server/internal/reward/handler.go` — `GET /v1/invite`、`GET /v1/tasks/telegram/start`、`POST /tg/webhook`(**新建**)。 - `server/internal/auth/service.go` / `handler.go` — Register 加 inviteCode + setter(**改**)。 - `server/internal/pay/webhook.go` — settle 首充钩子(**改**)。 - `server/cmd/server/main.go` — 装配 reward svc + 注入 + 路由 + env(**改**)。 - `client/lib/services/invite_api.dart` / `client/lib/state/invite_provider.dart` — api + provider(**新建**)。 - `client/lib/screens/invite_page.dart` — 占位转真实(**改**)。 - `client/lib/widgets/auth_screen.dart` + `client/lib/services/auth_api.dart` — 邀请码输入(**改**)。 - `client/lib/l10n/app_text.dart` + `strings_*.dart` ×6 — 新文案(**改**)。 --- ### Task 1: 迁移 000024 — 新表 + source 扩容 **Files:** - Create: `server/migrations/sqlite/000024_invite_rewards.up.sql` / `.down.sql` - Create: `server/migrations/mysql/000024_invite_rewards.up.sql` / `.down.sql` - Test: `server/internal/store/migrate_sqlite_test.go`(现有 up/down 测试;新增断言) **Interfaces:** - Produces: 表 `referrals`、`reward_claims`;`users.invite_code`、`users.first_paid_at`;`subscriptions.source` 允许 `'invite'`/`'task'`。 - [ ] **Step 1: 写 sqlite up 迁移** `server/migrations/sqlite/000024_invite_rewards.up.sql` ```sql -- users 加两列 ALTER TABLE users ADD COLUMN invite_code TEXT; ALTER TABLE users ADD COLUMN first_paid_at DATETIME; CREATE UNIQUE INDEX ux_users_invite_code ON users(invite_code); -- 邀请关系(一对一绑定) CREATE TABLE referrals ( id INTEGER PRIMARY KEY AUTOINCREMENT, inviter_id INTEGER NOT NULL, invitee_id INTEGER NOT NULL UNIQUE, device_uuid TEXT, status TEXT NOT NULL DEFAULT 'bound' CHECK (status IN ('bound','reg_rewarded','paid_rewarded','rejected')), reg_rewarded_at DATETIME, paid_rewarded_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (inviter_id) REFERENCES users(id), FOREIGN KEY (invitee_id) REFERENCES users(id) ); CREATE INDEX ix_referrals_inviter ON referrals(inviter_id, created_at); -- 通用一次性任务领取 CREATE TABLE reward_claims ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, task_key TEXT NOT NULL, external_ref TEXT NOT NULL DEFAULT '', granted_days INTEGER NOT NULL, granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ); CREATE UNIQUE INDEX ux_claim_user_task ON reward_claims(user_id, task_key); CREATE UNIQUE INDEX ux_claim_task_ref ON reward_claims(task_key, external_ref); -- subscriptions.source 扩容(SQLite 需重建表) CREATE TABLE subscriptions_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, plan_id INTEGER NOT NULL, expires_at DATETIME NOT NULL, source TEXT NOT NULL CHECK (source IN ('trial','code','pay','invite','task')), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (plan_id) REFERENCES plans(id) ); INSERT INTO subscriptions_new (id,user_id,plan_id,expires_at,source,created_at) SELECT id,user_id,plan_id,expires_at,source,created_at FROM subscriptions; DROP TABLE subscriptions; ALTER TABLE subscriptions_new RENAME TO subscriptions; ``` - [ ] **Step 2: 写 sqlite down 迁移** `server/migrations/sqlite/000024_invite_rewards.down.sql` ```sql -- 还原 subscriptions.source CHECK CREATE TABLE subscriptions_old ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, plan_id INTEGER NOT NULL, expires_at DATETIME NOT NULL, source TEXT NOT NULL CHECK (source IN ('trial','code','pay')), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (plan_id) REFERENCES plans(id) ); INSERT INTO subscriptions_old SELECT id,user_id,plan_id,expires_at,source,created_at FROM subscriptions WHERE source IN ('trial','code','pay'); DROP TABLE subscriptions; ALTER TABLE subscriptions_old RENAME TO subscriptions; DROP TABLE reward_claims; DROP TABLE referrals; DROP INDEX ux_users_invite_code; ALTER TABLE users DROP COLUMN first_paid_at; ALTER TABLE users DROP COLUMN invite_code; ``` - [ ] **Step 3: 写 mysql up 迁移** `server/migrations/mysql/000024_invite_rewards.up.sql` ```sql ALTER TABLE users ADD COLUMN invite_code VARCHAR(16) NULL, ADD COLUMN first_paid_at DATETIME(6) NULL, ADD UNIQUE KEY ux_users_invite_code (invite_code); CREATE TABLE referrals ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, inviter_id BIGINT UNSIGNED NOT NULL, invitee_id BIGINT UNSIGNED NOT NULL UNIQUE, device_uuid VARCHAR(64) NULL, status ENUM('bound','reg_rewarded','paid_rewarded','rejected') NOT NULL DEFAULT 'bound', reg_rewarded_at DATETIME(6) NULL, paid_rewarded_at DATETIME(6) NULL, created_at DATETIME(6) NOT NULL, INDEX ix_referrals_inviter (inviter_id, created_at), FOREIGN KEY (inviter_id) REFERENCES users(id), FOREIGN KEY (invitee_id) REFERENCES users(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE reward_claims ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, task_key VARCHAR(32) NOT NULL, external_ref VARCHAR(64) NOT NULL DEFAULT '', granted_days INT NOT NULL, granted_at DATETIME(6) NOT NULL, UNIQUE KEY ux_claim_user_task (user_id, task_key), UNIQUE KEY ux_claim_task_ref (task_key, external_ref), FOREIGN KEY (user_id) REFERENCES users(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay','invite','task') NOT NULL; ``` - [ ] **Step 4: 写 mysql down 迁移** `server/migrations/mysql/000024_invite_rewards.down.sql` ```sql ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay') NOT NULL; DROP TABLE reward_claims; DROP TABLE referrals; ALTER TABLE users DROP INDEX ux_users_invite_code, DROP COLUMN first_paid_at, DROP COLUMN invite_code; ``` - [ ] **Step 5: 跑迁移 up/down 测试** Run: `cd server && bash run_sqlite_test.sh` Expected: PASS(现有 `TestSQLiteMigrateUpDown` 全量 up→down→up 无错;若该测试对表数量有断言,更新计数)。 - [ ] **Step 6: Commit** ```bash git add server/migrations/sqlite/000024_invite_rewards.* server/migrations/mysql/000024_invite_rewards.* git commit -m "feat(server/migrate): 000024 邀请奖励表 + subscriptions.source 扩 invite/task" ``` --- ### Task 2: `codes.Service.GrantRewardTx` — 发奖天数原语 **Files:** - Modify: `server/internal/codes/paygrant.go` - Test: `server/internal/codes/reward_grant_sqlite_test.go`(新建) **Interfaces:** - Produces: `func (svc *Service) GrantRewardTx(ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (subID int64, expiresAt time.Time, err error)` — 发 Pro `days` 天,source 落新订阅行(已有活跃 pro 则顺延),写 audit_log。 - [ ] **Step 1: 写失败测试** `server/internal/codes/reward_grant_sqlite_test.go` ```go package codes import ( "context" "database/sql" "testing" "time" ) func TestGrantRewardTx_FreshUserCreatesInviteSub(t *testing.T) { db := openMigratedSQLiteCodes(t) // 复用本包已有 sqlite helper(见 service_sqlite_test.go) seedCodesUser(t, db, 7, "uuid-7") // 建 user id=7,不给活跃 sub(过期/无) svc := NewService(NewStore(db), nil, 5, time.Hour) tx, _ := db.BeginTx(context.Background(), &sql.TxOptions{}) subID, exp, err := svc.GrantRewardTx(context.Background(), tx, 7, 3, "invite", "invite_reward", "ref-1") if err != nil { t.Fatalf("grant: %v", err) } if err := tx.Commit(); err != nil { t.Fatal(err) } if subID == 0 || exp.Before(time.Now()) { t.Fatalf("bad sub %d exp %v", subID, exp) } var src string db.QueryRow(`SELECT source FROM subscriptions WHERE id=?`, subID).Scan(&src) if src != "invite" { t.Fatalf("source = %q, want invite", src) } var n int db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action='invite_reward' AND target='ref-1'`).Scan(&n) if n != 1 { t.Fatalf("audit rows = %d, want 1", n) } } ``` > 注:若本包已有 `openMigratedSQLite`/`seedUser` 同名 helper,直接复用并删掉上面重名声明;否则照 `server/internal/pay/testutil_test.go` 的 `openMigratedSQLite` 抄一份(`store.Open` → `MigrateUp` → `ApplyCodesLibMigrations`),user 用 `INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at) VALUES (...,'active',?)`。 - [ ] **Step 2: 跑测试确认失败** Run: `cd server && go test ./internal/codes/ -run TestGrantRewardTx -v` Expected: FAIL(`svc.GrantRewardTx undefined`)。 - [ ] **Step 3: 实现** —— 在 `server/internal/codes/paygrant.go` 末尾加: ```go // GrantRewardTx 发放奖励会员天数(Pro,source∈{invite,task}),与付费/兑换码同一条 // applySubscription 延时逻辑(max(到期,now)+days;已有活跃 pro 则原地顺延)。审计走 audit_log。 func (svc *Service) GrantRewardTx( ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string, ) (int64, time.Time, error) { planID, err := svc.store.GetPlanIDTx(ctx, tx, PlanPro) if err != nil { return 0, time.Time{}, err } subID, expiresAt, err := svc.applySubscription(ctx, tx, userID, planID, days, source) if err != nil { return 0, time.Time{}, err } meta, _ := json.Marshal(map[string]any{"days": days, "source": source, "sub_id": subID}) _ = svc.store.WriteAuditLog(ctx, tx, fmt.Sprintf("user:%d", userID), auditAction, ref, string(meta)) return subID, expiresAt, nil } ``` (`json`/`fmt`/`time`/`sql`/`context` 已在 paygrant.go import。) - [ ] **Step 4: 跑测试确认通过** Run: `cd server && go test ./internal/codes/ -run TestGrantRewardTx -v` Expected: PASS。 - [ ] **Step 5: Commit** ```bash git add server/internal/codes/paygrant.go server/internal/codes/reward_grant_sqlite_test.go git commit -m "feat(server/codes): GrantRewardTx 复用 applySubscription 发奖励会员天数" ``` --- ### Task 3: `reward.Store` — 数据访问层 **Files:** - Create: `server/internal/reward/store.go` - Test: `server/internal/reward/store_sqlite_test.go` **Interfaces:** - Produces: - `type Store struct{ db *sql.DB }` + `func NewStore(db *sql.DB) *Store` - `EnsureInviteCode(ctx, userID int64, gen func() string) (string, error)` — 惰性生成并写 users.invite_code(已有则返回旧值)。 - `ResolveInviteCode(ctx, code string) (inviterID int64, ok bool, err error)` - `DeviceUsedByOther(ctx, deviceUUID string, exceptUserID int64) (bool, error)` — devices.uuid 已属别人。 - `RegRewardCountThisMonth(ctx, inviterID int64, since time.Time) (int, error)` — referrals.status='reg_rewarded' 本月计数。 - `InsertReferralTx(ctx, tx, inviterID, inviteeID int64, deviceUUID, status string, regRewardedAt *time.Time, now time.Time) error` - `MarkFirstPaidTx(ctx, tx, userID int64, at time.Time) (isFirst bool, err error)` — `UPDATE users SET first_paid_at=? WHERE id=? AND first_paid_at IS NULL`,RowsAffected==1 → true。 - `ReferralByInvitee(ctx, tx, inviteeID int64) (inviterID int64, status string, ok bool, err error)` - `MarkPaidRewardedTx(ctx, tx, inviteeID int64, at time.Time) error` - `InsertClaimTx(ctx, tx, userID int64, taskKey, externalRef string, days int, now time.Time) error` — 唯一冲突返回 `ErrClaimExists`。 - `Summary(ctx, userID int64) (invited, converted, earnedDays int, err error)` + `TelegramClaimed(ctx, userID int64) (bool, error)` - [ ] **Step 1: 写失败测试** `server/internal/reward/store_sqlite_test.go` ```go package reward import ( "context" "testing" "time" "github.com/wangjia/pangolin/server/internal/config" "github.com/wangjia/pangolin/server/internal/store" ) func openDB(t *testing.T) *sqlDB { // 见下 helper db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) if err := store.MigrateUp(db, "sqlite"); err != nil { t.Fatal(err) } store.ApplyCodesLibMigrations(context.Background(), db, "sqlite") return db } func seedU(t *testing.T, db *sqlDB, id int64, uuid string) { _, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at) VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC()) if err != nil { t.Fatal(err) } } func TestEnsureAndResolveInviteCode(t *testing.T) { db := openDB(t); seedU(t, db, 1, "u1") st := NewStore(db) code, err := st.EnsureInviteCode(context.Background(), 1, func() string { return "ABCD2345" }) if err != nil || code != "ABCD2345" { t.Fatalf("ensure: %q %v", code, err) } again, _ := st.EnsureInviteCode(context.Background(), 1, func() string { return "ZZZZ9999" }) if again != "ABCD2345" { t.Fatalf("second ensure changed code: %q", again) } inviter, ok, _ := st.ResolveInviteCode(context.Background(), "ABCD2345") if !ok || inviter != 1 { t.Fatalf("resolve: %d %v", inviter, ok) } } func TestInsertClaimUniqueGuards(t *testing.T) { db := openDB(t); seedU(t, db, 1, "u1"); seedU(t, db, 2, "u2") st := NewStore(db) tx, _ := db.Begin() if err := st.InsertClaimTx(context.Background(), tx, 1, "telegram_join", "tg-100", 3, time.Now().UTC()); err != nil { t.Fatal(err) } tx.Commit() // 同 user 再领 → ErrClaimExists tx2, _ := db.Begin() if err := st.InsertClaimTx(context.Background(), tx2, 1, "telegram_join", "tg-999", 3, time.Now().UTC()); err != ErrClaimExists { t.Fatalf("same user reclaim err = %v, want ErrClaimExists", err) } tx2.Rollback() // 同 telegram_id 换 user → ErrClaimExists tx3, _ := db.Begin() if err := st.InsertClaimTx(context.Background(), tx3, 2, "telegram_join", "tg-100", 3, time.Now().UTC()); err != ErrClaimExists { t.Fatalf("same tgid reclaim err = %v, want ErrClaimExists", err) } tx3.Rollback() } ``` > `sqlDB` 是 `*sql.DB` 的别名以省 import;实际直接用 `*sql.DB` 即可(把测试里 `*sqlDB` 换成 `*sql.DB`,并 `import "database/sql"`)。 - [ ] **Step 2: 跑确认失败** Run: `cd server && go test ./internal/reward/ -v` Expected: FAIL(包不存在 / 符号未定义)。 - [ ] **Step 3: 实现** `server/internal/reward/store.go` ```go // Package reward 承载邀请奖励与奖励任务的数据访问 + 发奖 + 防刷。 package reward import ( "context" "database/sql" "errors" "strings" "time" ) var ErrClaimExists = errors.New("reward: claim already exists") type Store struct{ db *sql.DB } func NewStore(db *sql.DB) *Store { return &Store{db: db} } // EnsureInviteCode 惰性生成邀请码;已有则返回旧值。gen 生成候选码(冲突时重试到成功)。 func (s *Store) EnsureInviteCode(ctx context.Context, userID int64, gen func() string) (string, error) { var existing sql.NullString if err := s.db.QueryRowContext(ctx, `SELECT invite_code FROM users WHERE id=?`, userID).Scan(&existing); err != nil { return "", err } if existing.Valid && existing.String != "" { return existing.String, nil } for i := 0; i < 5; i++ { code := gen() _, err := s.db.ExecContext(ctx, `UPDATE users SET invite_code=? WHERE id=? AND invite_code IS NULL`, code, userID) if err != nil { if isDup(err) { continue } return "", err } // 读回(并发下可能是别的并发写入的值) var got sql.NullString if err := s.db.QueryRowContext(ctx, `SELECT invite_code FROM users WHERE id=?`, userID).Scan(&got); err != nil { return "", err } if got.Valid && got.String != "" { return got.String, nil } } return "", errors.New("reward: invite code generation exhausted") } func (s *Store) ResolveInviteCode(ctx context.Context, code string) (int64, bool, error) { var id int64 err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE invite_code=? AND status='active'`, code).Scan(&id) if err == sql.ErrNoRows { return 0, false, nil } return id, err == nil, err } func (s *Store) DeviceUsedByOther(ctx context.Context, deviceUUID string, exceptUserID int64) (bool, error) { if deviceUUID == "" { return false, nil } var n int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM devices WHERE uuid=? AND user_id<>?`, deviceUUID, exceptUserID).Scan(&n) return n > 0, err } func (s *Store) RegRewardCountThisMonth(ctx context.Context, inviterID int64, since time.Time) (int, error) { var n int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='reg_rewarded' AND reg_rewarded_at>=?`, inviterID, since).Scan(&n) return n, err } func (s *Store) InsertReferralTx(ctx context.Context, tx *sql.Tx, inviterID, inviteeID int64, deviceUUID, status string, regRewardedAt *time.Time, now time.Time) error { _, err := tx.ExecContext(ctx, `INSERT INTO referrals (inviter_id,invitee_id,device_uuid,status,reg_rewarded_at,created_at) VALUES (?,?,?,?,?,?)`, inviterID, inviteeID, deviceUUID, status, regRewardedAt, now) if isDup(err) { return ErrClaimExists } return err } func (s *Store) ReferralByInvitee(ctx context.Context, tx *sql.Tx, inviteeID int64) (int64, string, bool, error) { var inviter int64 var status string err := tx.QueryRowContext(ctx, `SELECT inviter_id,status FROM referrals WHERE invitee_id=?`, inviteeID). Scan(&inviter, &status) if err == sql.ErrNoRows { return 0, "", false, nil } return inviter, status, err == nil, err } func (s *Store) MarkFirstPaidTx(ctx context.Context, tx *sql.Tx, userID int64, at time.Time) (bool, error) { res, err := tx.ExecContext(ctx, `UPDATE users SET first_paid_at=? WHERE id=? AND first_paid_at IS NULL`, at, userID) if err != nil { return false, err } n, _ := res.RowsAffected() return n == 1, nil } func (s *Store) MarkPaidRewardedTx(ctx context.Context, tx *sql.Tx, inviteeID int64, at time.Time) error { _, err := tx.ExecContext(ctx, `UPDATE referrals SET status='paid_rewarded', paid_rewarded_at=? WHERE invitee_id=?`, at, inviteeID) return err } func (s *Store) InsertClaimTx(ctx context.Context, tx *sql.Tx, userID int64, taskKey, externalRef string, days int, now time.Time) error { _, err := tx.ExecContext(ctx, `INSERT INTO reward_claims (user_id,task_key,external_ref,granted_days,granted_at) VALUES (?,?,?,?,?)`, userID, taskKey, externalRef, days, now) if isDup(err) { return ErrClaimExists } return err } func (s *Store) TelegramClaimed(ctx context.Context, userID int64) (bool, error) { var n int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM reward_claims WHERE user_id=? AND task_key='telegram_join'`, userID).Scan(&n) return n > 0, err } // Summary: invited=已绑定人数;converted=已首充奖励人数;earnedDays=本人从奖励得到的总天数(audit_log 累加)。 func (s *Store) Summary(ctx context.Context, userID int64) (invited, converted, earnedDays int, err error) { if err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=?`, userID).Scan(&invited); err != nil { return } if err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='paid_rewarded'`, userID).Scan(&converted); err != nil { return } // earnedDays: audit_log 里 actor=user: 且 action∈奖励动作,meta.days 累加(简化:reward_claims + referrals 估算) var tg, reg, paid int s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(granted_days),0) FROM reward_claims WHERE user_id=?`, userID).Scan(&tg) s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status IN ('reg_rewarded','paid_rewarded')`, userID).Scan(®) s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='paid_rewarded'`, userID).Scan(&paid) // 邀请人:每 reg_rewarded +3、每 paid_rewarded 再 +7;被邀请人自身得的天数不计入其「邀请战绩」。 earnedDays = tg + reg*3 + paid*7 return } func isDup(err error) bool { if err == nil { return false } m := strings.ToLower(err.Error()) return strings.Contains(m, "unique") || strings.Contains(m, "duplicate") || strings.Contains(m, "constraint") } ``` - [ ] **Step 4: 跑确认通过** Run: `cd server && go test ./internal/reward/ -v` Expected: PASS。 - [ ] **Step 5: Commit** ```bash git add server/internal/reward/store.go server/internal/reward/store_sqlite_test.go git commit -m "feat(server/reward): Store — referrals/reward_claims/invite_code 数据访问 + 唯一守卫" ``` --- ### Task 4: `reward.Service` — 邀请码生成、注册段发奖 + 防刷 **Files:** - Create: `server/internal/reward/service.go` - Test: `server/internal/reward/service_sqlite_test.go` **Interfaces:** - Consumes: `Store`(Task 3);`Granter`(下面定义,由 `*codes.Service` 满足)。 - Produces: - `type Granter interface { GrantRewardTx(ctx, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (int64, time.Time, error) }` - `type Config struct{ RegDays, PaidDays, TgDays, RegMonthlyCap int }` - `func NewService(db *sql.DB, st *Store, g Granter, cfg Config, now func() time.Time) *Service` - `func GenInviteCode() string` — 8 位 base32(去 0/O/1/I),crypto/rand。 - `func (s *Service) EnsureCode(ctx, userID) (string, error)` - `func (s *Service) OnRegister(ctx, inviteeID int64, inviteCode, deviceUUID string)` — best-effort:解析码→防刷→建 referrals→注册段发双方 3 天。无返回(自吞错、内部 log)。 - [ ] **Step 1: 写失败测试** `server/internal/reward/service_sqlite_test.go` ```go package reward import ( "context" "database/sql" "testing" "time" "github.com/wangjia/pangolin/server/internal/codes" ) func newSvc(t *testing.T, db *sql.DB) *Service { g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) return NewService(db, NewStore(db), g, Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now) } func proDays(t *testing.T, db *sql.DB, userID int64) int { // 该用户 pro 剩余天数(粗算) var exp sql.NullTime db.QueryRow(`SELECT MAX(expires_at) FROM subscriptions s JOIN plans p ON p.id=s.plan_id WHERE s.user_id=? AND p.code='pro'`, userID).Scan(&exp) if !exp.Valid { return 0 } return int(time.Until(exp.Time).Hours()/24) + 1 } func TestOnRegister_BothGetThreeDays(t *testing.T) { db := openDB(t); seedU(t, db, 1, "inviter"); seedU(t, db, 2, "invitee") s := newSvc(t, db) code, _ := s.EnsureCode(context.Background(), 1) s.OnRegister(context.Background(), 2, code, "dev-2") var status string db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=2`).Scan(&status) if status != "reg_rewarded" { t.Fatalf("status=%q", status) } if d := proDays(t, db, 1); d < 3 { t.Fatalf("inviter pro days=%d, want>=3", d) } if d := proDays(t, db, 2); d < 3 { t.Fatalf("invitee pro days=%d, want>=3", d) } } func TestOnRegister_SelfInviteRejected(t *testing.T) { db := openDB(t); seedU(t, db, 1, "u1") s := newSvc(t, db) code, _ := s.EnsureCode(context.Background(), 1) s.OnRegister(context.Background(), 1, code, "dev-1") // 自邀请 var n int db.QueryRow(`SELECT COUNT(*) FROM referrals`).Scan(&n) if n != 0 { t.Fatalf("self-invite created %d referrals, want 0", n) } } func TestOnRegister_DeviceReusedRejectsReward(t *testing.T) { db := openDB(t); seedU(t, db, 1, "inv"); seedU(t, db, 2, "old"); seedU(t, db, 3, "new") // old(2) 的设备 dev-X 已在 devices db.Exec(`INSERT INTO devices (uuid,user_id,name,platform,last_seen,created_at) VALUES ('dev-X',2,'n','android',?,?)`, time.Now().UTC(), time.Now().UTC()) s := newSvc(t, db) code, _ := s.EnsureCode(context.Background(), 1) s.OnRegister(context.Background(), 3, code, "dev-X") // 新用户复用老设备 var status string db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=3`).Scan(&status) if status != "rejected" { t.Fatalf("status=%q, want rejected", status) } if d := proDays(t, db, 1); d >= 3 { t.Fatalf("inviter got reward despite device reuse: %d", d) } } ``` - [ ] **Step 2: 跑确认失败** Run: `cd server && go test ./internal/reward/ -run TestOnRegister -v` Expected: FAIL(`NewService`/`EnsureCode`/`OnRegister` 未定义)。 - [ ] **Step 3: 实现** `server/internal/reward/service.go` ```go package reward import ( "context" "crypto/rand" "database/sql" "log/slog" "time" ) type Granter interface { GrantRewardTx(ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (int64, time.Time, error) } type Config struct{ RegDays, PaidDays, TgDays, RegMonthlyCap int } type Service struct { db *sql.DB st *Store g Granter cfg Config now func() time.Time } func NewService(db *sql.DB, st *Store, g Granter, cfg Config, now func() time.Time) *Service { if now == nil { now = func() time.Time { return time.Now().UTC() } } return &Service{db: db, st: st, g: g, cfg: cfg, now: now} } const inviteAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // 去 0/O/1/I,32 字符 // GenInviteCode 返回 8 位随机码。crypto/rand 失败时用 now 派生兜底(避免 panic)。 func GenInviteCode() string { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { t := time.Now().UnixNano() for i := range b { b[i] = byte(t >> (8 * i)) } } out := make([]byte, 8) for i, x := range b { out[i] = inviteAlphabet[int(x)%len(inviteAlphabet)] } return string(out) } func (s *Service) EnsureCode(ctx context.Context, userID int64) (string, error) { return s.st.EnsureInviteCode(ctx, userID, GenInviteCode) } // OnRegister best-effort:与建号解耦(自有事务,失败只 log,不回滚注册)。 func (s *Service) OnRegister(ctx context.Context, inviteeID int64, inviteCode, deviceUUID string) { if inviteCode == "" { return } inviterID, ok, err := s.st.ResolveInviteCode(ctx, inviteCode) if err != nil || !ok { if err != nil { slog.Warn("reward: resolve invite code failed", "err", err) } return // 无效码:静默,不报错(注册已成功) } if inviterID == inviteeID { return // 自邀请:不建关系 } now := s.now() // 防刷判定:设备复用 or 月度封顶 → 记关系但 status=rejected、不发注册奖。 rejected := false if used, e := s.st.DeviceUsedByOther(ctx, deviceUUID, inviteeID); e == nil && used { rejected = true } if !rejected { monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) if cnt, e := s.st.RegRewardCountThisMonth(ctx, inviterID, monthStart); e == nil && cnt >= s.cfg.RegMonthlyCap { rejected = true } } tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { slog.Warn("reward: begin tx failed", "err", err) return } defer func() { _ = tx.Rollback() }() status := "reg_rewarded" var regAt *time.Time if rejected { status = "rejected" } else { regAt = &now } if err := s.st.InsertReferralTx(ctx, tx, inviterID, inviteeID, deviceUUID, status, regAt, now); err != nil { if err != ErrClaimExists { slog.Warn("reward: insert referral failed", "err", err) } return // invitee 已绑定过(唯一约束)→ 放弃 } if !rejected { if _, _, err := s.g.GrantRewardTx(ctx, tx, inviterID, s.cfg.RegDays, "invite", "invite_reg_inviter", "ref-inviter"); err != nil { slog.Warn("reward: grant inviter reg failed", "err", err) return } if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.RegDays, "invite", "invite_reg_invitee", "ref-invitee"); err != nil { slog.Warn("reward: grant invitee reg failed", "err", err) return } } if err := tx.Commit(); err != nil { slog.Warn("reward: commit failed", "err", err) } } ``` - [ ] **Step 4: 跑确认通过** Run: `cd server && go test ./internal/reward/ -run TestOnRegister -v` Expected: PASS(3 个用例)。 - [ ] **Step 5: Commit** ```bash git add server/internal/reward/service.go server/internal/reward/service_sqlite_test.go git commit -m "feat(server/reward): OnRegister 绑定 + 注册段双方发奖 + 自邀请/设备/月封顶防刷" ``` --- ### Task 5: 接入注册 — auth.Register 加 inviteCode **Files:** - Modify: `server/internal/auth/service.go`(Register 签名 + setter) - Modify: `server/internal/auth/handler.go`(RegisterRequest 加字段 + 传参) - Test: `server/internal/auth/register_invite_test.go` **Interfaces:** - Consumes: `reward.Service.OnRegister`(Task 4)—— 经接口 `ReferralHook interface{ OnRegister(ctx, inviteeID int64, inviteCode, deviceUUID string) }`。 - Produces: `Register(ctx, rawEmail, code, password, ip string, device DeviceMeta, inviteCode string)`;`Service.SetReferralHook(h ReferralHook)`。 - [ ] **Step 1: 写失败测试** `server/internal/auth/register_invite_test.go` ```go package auth import ( "context" "testing" ) type fakeHook struct{ gotInvitee int64; gotCode, gotDev string } func (f *fakeHook) OnRegister(_ context.Context, inviteeID int64, code, dev string) { f.gotInvitee, f.gotCode, f.gotDev = inviteeID, code, dev } func TestRegister_CallsReferralHookWithInviteCode(t *testing.T) { s := newTestService(t) // 复用本包已有的 service 测试装配(见 service_test.go) h := &fakeHook{} s.SetReferralHook(h) // 走通注册(需本包既有测试里的 seed 验证码/邮件 mock 流程);断言 hook 收到 code。 _, apiErr := s.Register(context.Background(), "new@x.com", validCode(t, s, "new@x.com"), "password123", "1.2.3.4", DeviceMeta{DeviceID: "dev-1"}, "INVCODE9") if apiErr != nil { t.Fatalf("register: %v", apiErr) } if h.gotCode != "INVCODE9" || h.gotDev != "dev-1" || h.gotInvitee == 0 { t.Fatalf("hook got invitee=%d code=%q dev=%q", h.gotInvitee, h.gotCode, h.gotDev) } } ``` > `newTestService`/`validCode` 若本包无同名 helper,照 `server/internal/auth/service_test.go` 现有注册测试的装配复制(含 redis mock / verifyCode 放行)。若现有注册测试用别的 seed 手法,沿用之。 - [ ] **Step 2: 跑确认失败** Run: `cd server && go test ./internal/auth/ -run TestRegister_CallsReferralHook -v` Expected: FAIL(`SetReferralHook` 未定义 / Register 参数不匹配)。 - [ ] **Step 3: 实现** —— `server/internal/auth/service.go`: 在 Service struct 加字段与 setter(紧挨已有 `SetDeviceRegistrar`): ```go // ReferralHook 在注册成功后被调用(best-effort;实现见 reward.Service)。 type ReferralHook interface { OnRegister(ctx context.Context, inviteeID int64, inviteCode, deviceUUID string) } func (s *Service) SetReferralHook(h ReferralHook) { s.referral = h } ``` Service struct 增 `referral ReferralHook`。改 `Register` 签名与尾部: ```go func (s *Service) Register(ctx context.Context, rawEmail, code, password, ip string, device DeviceMeta, inviteCode string) (*TokenPair, *apierr.Error) { // ... 原有校验 / verifyCode / CreateUserWithTrial / IssueWithJTI / recordLogin 不变 ... s.recordLogin(ctx, user.ID, jti, ip, device) if s.referral != nil { s.referral.OnRegister(ctx, user.ID, inviteCode, device.DeviceID) // best-effort } return pair, nil } ``` `server/internal/auth/handler.go` 的 register 请求体加字段并传参: ```go type registerRequest struct { Email string `json:"email"` Code string `json:"code"` Password string `json:"password"` Device DeviceMeta `json:"device"` InviteCode string `json:"invite_code"` // 选填 } // Register handler 内: pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password, clientIP(r), req.Device, req.InviteCode) ``` > 若 handler 里 DeviceMeta 是从别的字段拼的,保持原样,仅追加 `req.InviteCode` 末参。**全库搜 `.Register(` 更新所有调用点**(测试也算):`grep -rn '\.Register(' server/`。 - [ ] **Step 4: 跑确认通过 + 全包回归** Run: `cd server && go test ./internal/auth/ -v` Expected: PASS(新用例 + 原有注册测试因签名更新后仍绿)。 - [ ] **Step 5: Commit** ```bash git add server/internal/auth/service.go server/internal/auth/handler.go server/internal/auth/register_invite_test.go git commit -m "feat(server/auth): Register 加 inviteCode + ReferralHook(注册后 best-effort 归因)" ``` --- ### Task 6: 首充钩子 — pay webhook 接首充段发奖 **Files:** - Modify: `server/internal/pay/webhook.go`(settle 内插首充奖励 + Rewarder 接口/setter) - Test: `server/internal/pay/webhook_referral_sqlite_test.go` **Interfaces:** - Consumes: `reward.Service`——经接口 `Rewarder interface{ OnFirstPaidTx(ctx, tx *sql.Tx, inviteeID int64, now time.Time) error }`。 - Produces: `WebhookHandler.SetRewarder(r Rewarder)`;settle 在 grant 之后、commit 之前调 `OnFirstPaidTx`。 - [ ] **Step 1: 在 reward 包加 `OnFirstPaidTx`(同事务)** —— `server/internal/reward/service.go` 追加: ```go // OnFirstPaidTx 在 pay webhook 的同一事务内调用:标记 first_paid_at,若确为首充且该用户 // 是被邀请人(referral 命中且未发过首充奖)→ 发首充段双方各 PaidDays 天。幂等。 func (s *Service) OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64, now time.Time) error { first, err := s.st.MarkFirstPaidTx(ctx, tx, inviteeID, now) if err != nil || !first { return err // 非首充 → 不发 } inviterID, status, ok, err := s.st.ReferralByInvitee(ctx, tx, inviteeID) if err != nil || !ok || status == "paid_rewarded" || status == "rejected" { return err // 无邀请关系 / 已发过 / 被拒 → 不发 } if _, _, err := s.g.GrantRewardTx(ctx, tx, inviterID, s.cfg.PaidDays, "invite", "invite_paid_inviter", "ref-paid-inviter"); err != nil { return err } if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.PaidDays, "invite", "invite_paid_invitee", "ref-paid-invitee"); err != nil { return err } return s.st.MarkPaidRewardedTx(ctx, tx, inviteeID, now) } ``` - [ ] **Step 2: 写失败测试** `server/internal/pay/webhook_referral_sqlite_test.go` ```go package pay import ( "context" "testing" "time" "github.com/wangjia/pangolin/server/internal/codes" "github.com/wangjia/pangolin/server/internal/reward" ) func TestSettle_FirstPaidGrantsReferralBonus(t *testing.T) { h, db, _ := newWebhookRig(t) // 复用本包 rig;rig 里 seedUser(1) seedUser(t, db, 2, "invitee-uuid") // 被邀请人 = 付费者 // 建 referral: inviter=1, invitee=2, 已 reg_rewarded now := time.Now().UTC() db.Exec(`INSERT INTO referrals (inviter_id,invitee_id,status,reg_rewarded_at,created_at) VALUES (1,2,'reg_rewarded',?,?)`, now, now) // 装 rewarder g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) rw := reward.NewService(db, reward.NewStore(db), g, reward.Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now) h.SetRewarder(rw) // 触发 settle(照本包已有 webhook 测试构造 ev:invitee=2 的一笔 paid;biz_ref=其 uuid) err := h.settleForTest(context.Background(), &webhookEvent{ ProductBizCode: "pro_month", OutTradeNo: "ot-1", BizRef: "invitee-uuid", Channel: "nezha", AmountMinor: 600, Currency: "CNY", PaidAt: now.Format(time.RFC3339), }) if err != nil { t.Fatalf("settle: %v", err) } var status string db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=2`).Scan(&status) if status != "paid_rewarded" { t.Fatalf("status=%q, want paid_rewarded", status) } var fp int db.QueryRow(`SELECT COUNT(*) FROM users WHERE id=2 AND first_paid_at IS NOT NULL`).Scan(&fp) if fp != 1 { t.Fatalf("first_paid_at not set") } } ``` > `settleForTest` 若无,加一个包内导出小助手 `func (h *WebhookHandler) settleForTest(ctx, ev *webhookEvent) error { return h.settle(ctx, ev) }`(仅测试文件用 `//go:build` 不必;直接同包调用私有 `settle` 即可,删掉 wrapper,直接 `h.settle(...)`)。`webhookEvent` 字段名以本包实际定义为准(见 webhook.go)。 - [ ] **Step 3: 跑确认失败** Run: `cd server && go test ./internal/pay/ -run TestSettle_FirstPaid -v` Expected: FAIL(`SetRewarder` 未定义)。 - [ ] **Step 4: 实现** —— `server/internal/pay/webhook.go`: ```go // Rewarder(可选)在首充同事务内发放邀请首充奖励;nil 则跳过。 type Rewarder interface { OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64, now time.Time) error } func (h *WebhookHandler) SetRewarder(r Rewarder) { h.rewarder = r } ``` WebhookHandler struct 增 `rewarder Rewarder`。在 settle 里 `GrantPaidSubscriptionTx` 成功之后、`MarkPaidTx` 之后、`tx.Commit()` 之前插入: ```go if h.rewarder != nil { if err := h.rewarder.OnFirstPaidTx(ctx, tx, userID, h.now().UTC()); err != nil { return err // 同事务:发奖失败则整笔回滚,webhook 重试 } } return tx.Commit() ``` - [ ] **Step 5: 跑确认通过** Run: `cd server && go test ./internal/pay/ -v` Expected: PASS。 - [ ] **Step 6: Commit** ```bash git add server/internal/reward/service.go server/internal/pay/webhook.go server/internal/pay/webhook_referral_sqlite_test.go git commit -m "feat(server/pay): 首充钩子 — settle 内接邀请首充段发奖(first_paid_at 幂等)" ``` --- ### Task 7: `GET /v1/invite` 端点 **Files:** - Create: `server/internal/reward/handler.go` - Test: `server/internal/reward/handler_invite_test.go` **Interfaces:** - Consumes: `Service`(EnsureCode + st.Summary + st.TelegramClaimed)。 - Produces: `type Handler struct{...}` + `func NewHandler(s *Service, st *Store, tgEnabled bool, channel string) *Handler`;`func (h *Handler) GetInvite(w, r)` 返回 `{invite_code, invite_link, invited, converted, earned_days, telegram:{enabled, joined, channel}}`。 - [ ] **Step 1: 写失败测试** `server/internal/reward/handler_invite_test.go` ```go package reward import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/wangjia/pangolin/server/internal/codes" ) func TestGetInvite_ReturnsCodeAndSummary(t *testing.T) { db := openDB(t); seedU(t, db, 1, "u1") st := NewStore(db) g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) svc := NewService(db, st, g, Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now) h := NewHandler(svc, st, true, "@pangolin_app") r := httptest.NewRequest(http.MethodGet, "/v1/invite", nil) r = r.WithContext(context.WithValue(r.Context(), codes.CtxKeyUserID, int64(1))) w := httptest.NewRecorder() h.GetInvite(w, r) // 直接调 handler,不经 router if w.Code != 200 { t.Fatalf("code=%d body=%s", w.Code, w.Body) } var got map[string]any json.Unmarshal(w.Body.Bytes(), &got) if got["invite_code"] == "" || got["invite_code"] == nil { t.Fatalf("no invite_code: %v", got) } tg, _ := got["telegram"].(map[string]any) if tg == nil || tg["enabled"] != true { t.Fatalf("telegram block wrong: %v", got["telegram"]) } } ``` > 取 uid 用 `codes.CtxKeyUserID`(与 `auth.UserIDFromContext` 同键,见锚点 D11)。若 import auth 造成环,直接用 `auth.UserIDFromContext(ctx)`——它内部读同键。测试里注入用 `codes.CtxKeyUserID`。 - [ ] **Step 2: 跑确认失败** Run: `cd server && go test ./internal/reward/ -run TestGetInvite -v` Expected: FAIL(`NewHandler`/`GetInvite` 未定义)。 - [ ] **Step 3: 实现** `server/internal/reward/handler.go`(GetInvite 部分) ```go package reward import ( "encoding/json" "net/http" "github.com/wangjia/pangolin/server/internal/apierr" "github.com/wangjia/pangolin/server/internal/auth" ) const inviteLinkBase = "https://pangolin.yanmeiai.com/i/" type Handler struct { svc *Service st *Store tgEnabled bool channel string } func NewHandler(s *Service, st *Store, tgEnabled bool, channel string) *Handler { return &Handler{svc: s, st: st, tgEnabled: tgEnabled, channel: channel} } func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) { ctx := r.Context() uid, ok := auth.UserIDFromContext(ctx) if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) return } code, err := h.svc.EnsureCode(ctx, uid) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } invited, converted, earned, err := h.st.Summary(ctx, uid) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } joined, _ := h.st.TelegramClaimed(ctx, uid) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "invite_code": code, "invite_link": inviteLinkBase + code, "invited": invited, "converted": converted, "earned_days": earned, "telegram": map[string]any{ "enabled": h.tgEnabled, "joined": joined, "channel": h.channel, }, }) } ``` - [ ] **Step 4: 跑确认通过** Run: `cd server && go test ./internal/reward/ -run TestGetInvite -v` Expected: PASS。 - [ ] **Step 5: Commit** ```bash git add server/internal/reward/handler.go server/internal/reward/handler_invite_test.go git commit -m "feat(server/reward): GET /v1/invite — 邀请码/链接/战绩/TG 任务态" ``` --- ### Task 8: TG 绑定 token — `GET /v1/tasks/telegram/start` **Files:** - Create: `server/internal/reward/telegram.go`(token 部分) - Modify: `server/internal/reward/handler.go`(加 TelegramStart handler) - Test: `server/internal/reward/telegram_token_test.go` **Interfaces:** - Produces: - `func (s *Service) IssueTelegramToken(ctx, userID int64) (string, error)` — 生成随机 token,Redis `SETEX tg_bind: 600 `;Redis 为 nil 时用内存 map 兜底(测试)。 - `func (s *Service) ConsumeTelegramToken(ctx, token string) (userID int64, ok bool, err error)` — GETDEL。 - `Handler.TelegramStart(w, r)` → `{deep_link: "https://t.me/?start="}`。Service 需知道 bot username(`SetTelegram(botUser, channel, botToken, webhookSecret)`)。 - [ ] **Step 1: 写失败测试** `server/internal/reward/telegram_token_test.go` ```go package reward import ( "context" "testing" "time" "github.com/wangjia/pangolin/server/internal/codes" ) func TestTelegramToken_IssueThenConsumeOnce(t *testing.T) { db := openDB(t); seedU(t, db, 5, "u5") g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) s := NewService(db, NewStore(db), g, Config{TgDays: 3}, time.Now) s.SetTelegram("pangolin_reward_bot", "@pangolin_app", "bot-token", "wh-secret") // 内存兜底(rdb nil) tok, err := s.IssueTelegramToken(context.Background(), 5) if err != nil || tok == "" { t.Fatalf("issue: %q %v", tok, err) } uid, ok, _ := s.ConsumeTelegramToken(context.Background(), tok) if !ok || uid != 5 { t.Fatalf("consume: %d %v", uid, ok) } _, ok2, _ := s.ConsumeTelegramToken(context.Background(), tok) if ok2 { t.Fatalf("token consumable twice") } } ``` - [ ] **Step 2: 跑确认失败** Run: `cd server && go test ./internal/reward/ -run TestTelegramToken -v` Expected: FAIL。 - [ ] **Step 3: 实现** `server/internal/reward/telegram.go` ```go package reward import ( "context" "crypto/rand" "encoding/hex" "strconv" "sync" "time" "github.com/redis/go-redis/v9" ) type tgConfig struct { botUser, channel, botToken, webhookSecret string } // SetTelegram 注入 TG 配置 + redis(经 Service.rdb)。rdb 为 nil 时用内存 token 兜底(测试/未配 redis)。 func (s *Service) SetTelegram(botUser, channel, botToken, webhookSecret string) { s.tg = tgConfig{botUser: botUser, channel: channel, botToken: botToken, webhookSecret: webhookSecret} } func (s *Service) SetRedis(rdb *redis.Client) { s.rdb = rdb } func randToken() string { b := make([]byte, 16); _, _ = rand.Read(b); return hex.EncodeToString(b) } func (s *Service) IssueTelegramToken(ctx context.Context, userID int64) (string, error) { tok := randToken() if s.rdb != nil { return tok, s.rdb.Set(ctx, "tg_bind:"+tok, userID, 10*time.Minute).Err() } s.memMu.Lock() if s.memTok == nil { s.memTok = map[string]int64{} } s.memTok[tok] = userID s.memMu.Unlock() return tok, nil } func (s *Service) ConsumeTelegramToken(ctx context.Context, token string) (int64, bool, error) { if s.rdb != nil { v, err := s.rdb.GetDel(ctx, "tg_bind:"+token).Result() if err == redis.Nil { return 0, false, nil } if err != nil { return 0, false, err } id, _ := strconv.ParseInt(v, 10, 64) return id, true, nil } s.memMu.Lock() defer s.memMu.Unlock() id, ok := s.memTok[token] if ok { delete(s.memTok, token) } return id, ok, nil } var _ = sync.Mutex{} // 保 sync import(memMu 在 Service struct) ``` 在 `service.go` 的 Service struct 增字段:`rdb *redis.Client`、`tg tgConfig`、`memMu sync.Mutex`、`memTok map[string]int64`(补 `import "sync"` 与 redis)。 `handler.go` 加: ```go func (h *Handler) TelegramStart(w http.ResponseWriter, r *http.Request) { ctx := r.Context() uid, ok := auth.UserIDFromContext(ctx) if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized); return } if !h.tgEnabled { apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound); return } tok, err := h.svc.IssueTelegramToken(ctx, uid) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal); return } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"deep_link": h.svc.TelegramDeepLink(tok)}) } ``` `service.go` 加 `func (s *Service) TelegramDeepLink(tok string) string { return "https://t.me/" + s.tg.botUser + "?start=" + tok }`。 - [ ] **Step 4: 跑确认通过** Run: `cd server && go test ./internal/reward/ -run TestTelegramToken -v` Expected: PASS。 - [ ] **Step 5: Commit** ```bash git add server/internal/reward/telegram.go server/internal/reward/handler.go server/internal/reward/service.go server/internal/reward/telegram_token_test.go git commit -m "feat(server/reward): TG 绑定 token 签发/消费 + GET /v1/tasks/telegram/start" ``` --- ### Task 9: TG webhook — getChatMember 真校验 + 发奖 **Files:** - Modify: `server/internal/reward/telegram.go`(getChatMember 客户端 + ClaimTelegram) - Modify: `server/internal/reward/handler.go`(TelegramWebhook handler) - Test: `server/internal/reward/telegram_webhook_test.go` **Interfaces:** - Produces: - `type ChatMemberChecker interface{ IsMember(ctx, channel, telegramID string) (bool, error) }`(默认实现打 Telegram Bot API;测试注入 fake)。`Service.SetMemberChecker(c ChatMemberChecker)`。 - `func (s *Service) ClaimTelegram(ctx, userID int64, telegramID string) (granted bool, err error)` — getChatMember 真是成员 → InsertClaimTx(唯一守卫)+ GrantRewardTx(source='task')。 - `Handler.TelegramWebhook(w, r)` — 校验 secret 头 → 解析 `/start ` → ConsumeToken → ClaimTelegram → sendMessage。 - [ ] **Step 1: 写失败测试** `server/internal/reward/telegram_webhook_test.go` ```go package reward import ( "context" "testing" "time" "github.com/wangjia/pangolin/server/internal/codes" ) type fakeChecker struct{ member bool } func (f fakeChecker) IsMember(_ context.Context, _, _ string) (bool, error) { return f.member, nil } func newTgSvc(t *testing.T, db *sql.DB, member bool) *Service { g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) s := NewService(db, NewStore(db), g, Config{TgDays: 3}, time.Now) s.SetTelegram("bot", "@ch", "tok", "sec") s.SetMemberChecker(fakeChecker{member: member}) return s } func TestClaimTelegram_MemberGrantsOnce(t *testing.T) { db := openDB(t); seedU(t, db, 1, "u1") s := newTgSvc(t, db, true) ok, err := s.ClaimTelegram(context.Background(), 1, "tg-777") if err != nil || !ok { t.Fatalf("claim1: %v %v", ok, err) } if d := proDays(t, db, 1); d < 3 { t.Fatalf("pro days=%d", d) } // 再领 → 不再发(唯一守卫) ok2, _ := s.ClaimTelegram(context.Background(), 1, "tg-777") if ok2 { t.Fatalf("claimed twice") } } func TestClaimTelegram_NonMemberNoGrant(t *testing.T) { db := openDB(t); seedU(t, db, 1, "u1") s := newTgSvc(t, db, false) ok, _ := s.ClaimTelegram(context.Background(), 1, "tg-1") if ok { t.Fatalf("non-member granted") } if d := proDays(t, db, 1); d >= 3 { t.Fatalf("granted days to non-member: %d", d) } } ``` (文件顶部 `import "database/sql"`。) - [ ] **Step 2: 跑确认失败** Run: `cd server && go test ./internal/reward/ -run TestClaimTelegram -v` Expected: FAIL。 - [ ] **Step 3: 实现** —— `telegram.go` 追加: ```go type ChatMemberChecker interface { IsMember(ctx context.Context, channel, telegramID string) (bool, error) } func (s *Service) SetMemberChecker(c ChatMemberChecker) { s.checker = c } // ClaimTelegram: 真成员则一次性发 TgDays 天(source='task')。 func (s *Service) ClaimTelegram(ctx context.Context, userID int64, telegramID string) (bool, error) { member, err := s.checker.IsMember(ctx, s.tg.channel, telegramID) if err != nil || !member { return false, err } tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { return false, err } defer func() { _ = tx.Rollback() }() now := s.now() if err := s.st.InsertClaimTx(ctx, tx, userID, "telegram_join", telegramID, s.cfg.TgDays, now); err != nil { if err == ErrClaimExists { return false, nil // 已领过(本账户 or 该 tgid) } return false, err } if _, _, err := s.g.GrantRewardTx(ctx, tx, userID, s.cfg.TgDays, "task", "task_telegram_join", "tg:"+telegramID); err != nil { return false, err } if err := tx.Commit(); err != nil { return false, err } return true, nil } ``` (`sql` 已 import;Service struct 增 `checker ChatMemberChecker`。) 默认 `IsMember` 打 Telegram API(生产用),同文件加: ```go type apiChecker struct{ botToken string; hc *http.Client } func (c apiChecker) IsMember(ctx context.Context, channel, telegramID string) (bool, error) { u := "https://api.telegram.org/bot" + c.botToken + "/getChatMember" req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) q := req.URL.Query(); q.Set("chat_id", channel); q.Set("user_id", telegramID); req.URL.RawQuery = q.Encode() resp, err := c.hc.Do(req) if err != nil { return false, err } defer resp.Body.Close() var out struct{ OK bool `json:"ok"`; Result struct{ Status string `json:"status"` } `json:"result"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return false, err } s := out.Result.Status return out.OK && (s == "member" || s == "administrator" || s == "creator"), nil } ``` (`telegram.go` 顶部补 `import ("net/http"; "encoding/json"; "time")`;`SetTelegram` 里把默认 checker 设成 `apiChecker{botToken, &http.Client{Timeout: 8*time.Second}}`,但**若已 SetMemberChecker 注入则不覆盖**——用 `if s.checker == nil { s.checker = apiChecker{...} }`。) `handler.go` 加 webhook: ```go func (h *Handler) TelegramWebhook(w http.ResponseWriter, r *http.Request) { if !h.tgEnabled || r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != h.svc.WebhookSecret() { w.WriteHeader(http.StatusNotFound) return } var upd struct { Message struct { Text string `json:"text"` From struct{ ID int64 `json:"id"` } `json:"from"` } `json:"message"` } _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&upd) w.WriteHeader(http.StatusOK) // Telegram 只要 200;业务异步在下方 text, fromID := upd.Message.Text, upd.Message.From.ID if !strings.HasPrefix(text, "/start ") || fromID == 0 { return } token := strings.TrimSpace(strings.TrimPrefix(text, "/start ")) ctx := r.Context() uid, ok, _ := h.svc.ConsumeTelegramToken(ctx, token) if !ok { h.svc.SendTelegram(ctx, fromID, "链接已失效,请回 App 重新点「验证领取」。") return } granted, err := h.svc.ClaimTelegram(ctx, uid, strconv.FormatInt(fromID, 10)) switch { case err != nil: h.svc.SendTelegram(ctx, fromID, "验证出错,请稍后重试。") case granted: h.svc.SendTelegram(ctx, fromID, "✅ 已到账 +3 天 Pro,感谢加入!") default: h.svc.SendTelegram(ctx, fromID, "请先加入频道 "+h.svc.Channel()+" 再点验证;若已加入且领取过则无需重复。") } } ``` `service.go` / `telegram.go` 补 `WebhookSecret()`/`Channel()`/`SendTelegram(ctx, chatID int64, text string)`(SendTelegram 打 Bot API sendMessage,失败只 log)。`handler.go` 顶部补 `import ("strconv"; "strings")`。 - [ ] **Step 4: 跑确认通过** Run: `cd server && go test ./internal/reward/ -v` Expected: PASS(全包)。 - [ ] **Step 5: Commit** ```bash git add server/internal/reward/telegram.go server/internal/reward/handler.go server/internal/reward/service.go server/internal/reward/telegram_webhook_test.go git commit -m "feat(server/reward): TG getChatMember 真校验 + ClaimTelegram + POST /tg/webhook" ``` --- ### Task 10: 装配 main.go — 构造 reward svc + 注入 + 路由 + env **Files:** - Modify: `server/cmd/server/main.go` - Test: 手动 `go build` + `go vet`(装配无独立单测;逻辑单测已在各包)。 **Interfaces:** - Consumes: 前序全部。 - Produces: 挂载 `GET /v1/invite`、`GET /v1/tasks/telegram/start`(protected)、`POST /tg/webhook`(public);把 reward svc 注入 auth(SetReferralHook)+ pay webhook(SetRewarder)。 - [ ] **Step 1: 加 env + 构造(在 codesSvc 之后、authHandler 装配之前调整顺序)** 在 `codesSvc := codes.NewService(...)`(main.go:317)之后加: ```go // ── Reward(邀请奖励 + 奖励任务)────────────────────────────────────────── regCap := 10 if v := os.Getenv("INVITE_REG_MONTHLY_CAP"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { regCap = n } } rewardStore := reward.NewStore(sqlDB) rewardSvc := reward.NewService(sqlDB, rewardStore, codesSvc, reward.Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: regCap}, nil) rewardSvc.SetRedis(rdb) tgBotToken := os.Getenv("TG_REWARD_BOT_TOKEN") tgEnabled := tgBotToken != "" if tgEnabled { rewardSvc.SetTelegram( getenvDefault("TG_REWARD_BOT_USER", "pangolin_reward_bot"), getenvDefault("TG_REWARD_CHANNEL", "@pangolin_app"), tgBotToken, os.Getenv("TG_WEBHOOK_SECRET")) } rewardHandler := reward.NewHandler(rewardSvc, rewardStore, tgEnabled, getenvDefault("TG_REWARD_CHANNEL", "@pangolin_app")) ``` (`strconv` 需 import;`reward` 包 import `github.com/wangjia/pangolin/server/internal/reward`。) - [ ] **Step 2: 注入 auth + pay webhook** auth 装配处(main.go:297-300)后加(authSvc 在作用域内即可;若 authSvc 是 if 内局部,把注入放进同一 if): ```go authSvc.SetReferralHook(rewardSvc) ``` pay webhook 装配处(main.go:334)后加: ```go payWebhook.SetRewarder(rewardSvc) ``` > 注意作用域:`authSvc`/`payWebhook` 在各自 if 块内构造。把上面两行放进对应 if 块末尾(webhook 仅 PAY_BASE_URL 配置时存在)。 - [ ] **Step 3: 挂路由** protected group(main.go:427 内,与 pay 段并列)加: ```go protected.Get("/invite", rewardHandler.GetInvite) protected.Get("/tasks/telegram/start", rewardHandler.TelegramStart) ``` public v1(与 `/webhook/pay` 并列,main.go:420 附近)加: ```go v1.Post("/tg/webhook", rewardHandler.TelegramWebhook) ``` - [ ] **Step 4: 编译 + vet** Run: `cd server && go build ./... && go vet ./cmd/server/` Expected: 无错。 - [ ] **Step 5: 全量测试** Run: `cd server && go test ./...` Expected: PASS。 - [ ] **Step 6: Commit** ```bash git add server/cmd/server/main.go git commit -m "feat(server): 装配 reward 服务 — 注入 auth/webhook + 挂 /v1/invite、/tasks/telegram/start、/tg/webhook" ``` --- ### Task 11: 客户端 invite api + provider **Files:** - Create: `client/lib/services/invite_api.dart` - Create: `client/lib/state/invite_provider.dart` - Modify: `client/lib/services/auth_api.dart`(register 加 inviteCode) - Test: `client/test/unit/invite_api_test.dart` **Interfaces:** - Consumes: `apiClientProvider`(`client/lib/state/account_providers.dart`)。 - Produces: - `class InviteInfo { code, link, invited, converted, earnedDays; bool tgEnabled, tgJoined; String channel; fromJson }` - `class InviteApi { InviteApi(this._c); Future fetch(); Future telegramStartLink(); }` - `inviteApiProvider`、`inviteProvider`(AsyncNotifierProvider)。 - [ ] **Step 1: 写失败测试** `client/test/unit/invite_api_test.dart` ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:pangolin_vpn/services/api_client.dart'; import 'package:pangolin_vpn/services/invite_api.dart'; ApiClient _c(MockClient m) => ApiClient(baseUrl: 'http://x', getToken: () => 't', refresh: () async => false, client: m); void main() { test('fetch 解析邀请信息 + TG 任务态', () async { final api = InviteApi(_c(MockClient((req) async { expect(req.url.path, '/v1/invite'); return http.Response('{"invite_code":"ABCD2345","invite_link":"https://x/i/ABCD2345",' '"invited":3,"converted":1,"earned_days":16,' '"telegram":{"enabled":true,"joined":false,"channel":"@pangolin_app"}}', 200); }))); final info = await api.fetch(); expect(info.code, 'ABCD2345'); expect(info.invited, 3); expect(info.earnedDays, 16); expect(info.tgEnabled, true); expect(info.tgJoined, false); }); } ``` - [ ] **Step 2: 跑确认失败** Run: `cd client && flutter test test/unit/invite_api_test.dart` Expected: FAIL(`invite_api.dart` 不存在)。 - [ ] **Step 3: 实现** `client/lib/services/invite_api.dart` ```dart // invite_api.dart — 邀请/奖励任务代理端点封装(JWT 经 ApiClient 自动注入)。 import 'api_client.dart'; class InviteInfo { const InviteInfo({ required this.code, required this.link, required this.invited, required this.converted, required this.earnedDays, required this.tgEnabled, required this.tgJoined, required this.channel, }); final String code, link, channel; final int invited, converted, earnedDays; final bool tgEnabled, tgJoined; factory InviteInfo.fromJson(Map j) { final tg = (j['telegram'] as Map?) ?? const {}; return InviteInfo( code: j['invite_code'] as String? ?? '', link: j['invite_link'] as String? ?? '', invited: (j['invited'] as num?)?.toInt() ?? 0, converted: (j['converted'] as num?)?.toInt() ?? 0, earnedDays: (j['earned_days'] as num?)?.toInt() ?? 0, tgEnabled: tg['enabled'] as bool? ?? false, tgJoined: tg['joined'] as bool? ?? false, channel: tg['channel'] as String? ?? '', ); } } class InviteApi { InviteApi(this._c); final ApiClient _c; Future fetch() async => InviteInfo.fromJson(await _c.getJson('/v1/invite')); Future telegramStartLink() async => (await _c.getJson('/v1/tasks/telegram/start'))['deep_link'] as String? ?? ''; } ``` > 顶部那行 `import ... if (dart.library.io)` 是误写,删掉,只保留 `import 'api_client.dart';`。 `client/lib/state/invite_provider.dart`(照 account_providers.dart 装配): ```dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../services/invite_api.dart'; import 'account_providers.dart'; final inviteApiProvider = Provider((ref) => InviteApi(ref.watch(apiClientProvider))); class InviteNotifier extends AsyncNotifier { @override Future build() async { // 未登录返回 null(不打网络);已登录拉取。 final token = ref.watch(authProvider).accessToken; if (token == null || token.isEmpty) return null; return ref.read(inviteApiProvider).fetch(); } Future refresh() async { state = const AsyncLoading(); state = await AsyncValue.guard(() => ref.read(inviteApiProvider).fetch()); } } final inviteProvider = AsyncNotifierProvider(InviteNotifier.new); ``` > `authProvider` import 来自 account_providers.dart 已 re-export 或直接 `import '../state/auth_provider.dart'` 取 `authProvider`;以本项目实际路径为准(account_providers.dart 里 `ref.read(authProvider)` 已能用,照抄其 import)。 `client/lib/services/auth_api.dart` 的 register 加 `String? inviteCode` 并入 body: ```dart Future register({required String email, required String code, required String password, Map? device, String? inviteCode}) async { final body = { 'email': email, 'code': code, 'password': password, if (device != null) 'device': device, if (inviteCode != null && inviteCode.isNotEmpty) 'invite_code': inviteCode, }; // ... 其余 POST /v1/auth/register 逻辑不变 ... } ``` - [ ] **Step 4: 跑确认通过** Run: `cd client && flutter test test/unit/invite_api_test.dart` Expected: PASS。 - [ ] **Step 5: Commit** ```bash git add client/lib/services/invite_api.dart client/lib/state/invite_provider.dart client/lib/services/auth_api.dart client/test/unit/invite_api_test.dart git commit -m "feat(client): invite api + provider + auth_api.register 加 inviteCode" ``` --- ### Task 12: 客户端 invite_page 真实化 + l10n **Files:** - Modify: `client/lib/screens/invite_page.dart` - Modify: `client/lib/l10n/app_text.dart` + `client/lib/l10n/strings_{zh,en,es,ja,ko,ru}.dart` - Test: `client/test/widget/invite_page_test.dart` **Interfaces:** - Consumes: `inviteProvider`、`InviteInfo`(Task 11)。 - [ ] **Step 1: 加 l10n string(先加,供页面引用)** —— `app_text.dart` 加抽象 getter: ```dart String get inviteTasksTitle; // 更多得会员 / More ways to earn String get inviteJoinTgTitle; // 加入 Telegram 频道 +3 天 String get inviteJoinTgBtn; // 加入频道 String get inviteVerifyBtn; // 验证领取 String get inviteTaskDone; // 已领 +3 天 String get inviteStatInvited; // 已邀请 String get inviteStatConverted; // 已转化 String get inviteStatDays; // 累计获赠(天) String get inviteCodeFieldLabel; // 邀请码(选填) ``` 在 6 个 `strings_*.dart` 各加实现(zh 示例): ```dart @override String get inviteTasksTitle => '更多得会员'; @override String get inviteJoinTgTitle => '加入 Telegram 频道'; @override String get inviteJoinTgBtn => '加入频道'; @override String get inviteVerifyBtn => '验证领取'; @override String get inviteTaskDone => '已领 +3 天'; @override String get inviteStatInvited => '已邀请'; @override String get inviteStatConverted => '已转化'; @override String get inviteStatDays => '累计获赠(天)'; @override String get inviteCodeFieldLabel => '邀请码(选填)'; ``` en:`'More ways to earn' / 'Join Telegram channel' / 'Join channel' / 'Verify & claim' / 'Claimed +3 days' / 'Invited' / 'Converted' / 'Days earned' / 'Invite code (optional)'`。es/ja/ko/ru 同法译(照该文件既有条目语气)。 - [ ] **Step 2: 写失败 widget 测试** `client/test/widget/invite_page_test.dart` ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pangolin_vpn/l10n/strings_en.dart'; import 'package:pangolin_vpn/screens/invite_page.dart'; import 'package:pangolin_vpn/services/invite_api.dart'; import 'package:pangolin_vpn/state/invite_provider.dart'; void main() { testWidgets('邀请页显示真实码 + 战绩 + TG 任务卡', (tester) async { const info = InviteInfo(code: 'ABCD2345', link: 'https://x/i/ABCD2345', invited: 3, converted: 1, earnedDays: 16, tgEnabled: true, tgJoined: false, channel: '@pangolin_app'); await tester.pumpWidget(ProviderScope( overrides: [inviteProvider.overrideWith(() => _FakeNotifier(info))], child: MaterialApp(home: Scaffold(body: InviteScreen(t: StringsEn()))), )); await tester.pumpAndSettle(); expect(find.text('ABCD2345'), findsOneWidget); expect(find.text('Join channel'), findsOneWidget); // TG 任务卡 }); } class _FakeNotifier extends InviteNotifier { _FakeNotifier(this._v); final InviteInfo _v; @override Future build() async => _v; } ``` - [ ] **Step 3: 实现 invite_page.dart** —— 删占位常量,`build` 里 `ref.watch(inviteProvider)`,`.when(...)`:code/link 用真实值填 `_CopyCard`;下方战绩三格(invited/converted/earnedDays)用 `t.inviteStatInvited/Converted/Days`;`info.tgEnabled` 为真时渲染 TG 任务卡:未 join 显「加入频道」(url_launcher 打开 `https://t.me/`)+「验证领取」(调 `inviteApiProvider.telegramStartLink()` → url_launcher 打开 deep_link),已 join 显 `t.inviteTaskDone` 置灰。loading→CircularProgressIndicator,error/null→回退占位文案。保留现有 `_CopyCard`/`_copy` 组件。 (完整代码依 §7 UI 规格 + 现有 `_CopyCard` 组件填充;颜色一律 `context.pangolin` token,禁硬编码。) - [ ] **Step 4: 跑测试 + analyze** Run: `cd client && flutter test test/widget/invite_page_test.dart && flutter analyze lib/screens/invite_page.dart lib/l10n/` Expected: PASS + No issues。 - [ ] **Step 5: Commit** ```bash git add client/lib/screens/invite_page.dart client/lib/l10n/ client/test/widget/invite_page_test.dart git commit -m "feat(client/invite): 邀请页真实化(邀请区+战绩+TG 任务卡)+ 六语 l10n" ``` --- ### Task 13: 注册页加邀请码输入 + deep-link 预填(可选后置) **Files:** - Modify: `client/lib/widgets/auth_screen.dart` - (可选)Add dep: `app_links` + 4 端原生配置 - Test: `client/test/widget/auth_invite_field_test.dart` **Interfaces:** - Consumes: `auth_api.register(..., inviteCode)`(Task 11)。 - [ ] **Step 1: 写失败测试** `client/test/widget/auth_invite_field_test.dart` —— 断言注册表单 step1(设密码步)存在「邀请码(选填)」输入框(`find.widgetWithText(TextField, ...)` 或按 key `find.byKey(const Key('invite-code-field'))`)。 - [ ] **Step 2: 跑确认失败** Run: `cd client && flutter test test/widget/auth_invite_field_test.dart` Expected: FAIL。 - [ ] **Step 3: 实现** —— `auth_screen.dart`:加 `final _invite = TextEditingController();`(随 `_email/_pw` 一起 dispose);在注册 step1 表单(`_register`,:413)密码框下方加一个可选 `TextField`(key `Key('invite-code-field')`,label `t.inviteCodeFieldLabel`);`_doRegister`(:102)把 `inviteCode: _invite.text.trim()` 传入 `_api.register(...)`。登录路径不加。 - [ ] **Step 4: 跑测试 + analyze** Run: `cd client && flutter test test/widget/auth_invite_field_test.dart && flutter analyze lib/widgets/auth_screen.dart` Expected: PASS + No issues。 - [ ] **Step 5: Commit** ```bash git add client/lib/widgets/auth_screen.dart client/test/widget/auth_invite_field_test.dart git commit -m "feat(client/auth): 注册页加邀请码(选填)输入,提交时传 invite_code" ``` - [ ] **Step 6(可选,deep-link 预填):** 加 `app_links` 依赖 + Android intent-filter / iOS associated domains / macOS URL scheme,监听 `pangolin.yanmeiai.com/i/` 或自定义 scheme,启动时把 code 预填进注册页并锁定。**此步涉四端原生配置,单列为独立后续任务;MVP 不含**。 --- ## Task 14: 计划 HTML 阅读版 + 索引登记 **Files:** - Create: `docs/invite-task-rewards-plan.html`(本 md 的 HTML 阅读版,抄 design 家族样式) - Modify: `docs/index.html`(「实现计划 / Plans」区加一条) - [ ] **Step 1:** 按项目规范(设计/计划双产物)生成 HTML 阅读版,登记进 `docs/index.html` 的实现计划分类,与设计文档互链。 - [ ] **Step 2: Commit** ```bash git add docs/invite-task-rewards-plan.html docs/index.html git commit -m "docs: 邀请奖励实现计划 HTML 阅读版 + 索引登记" ``` --- ## 验收(端到端) - 后端:`cd server && go test ./...` 全绿(含 reward 包 + auth/pay 回归)。 - 客户端:`cd client && flutter analyze && flutter test` 全绿。 - 真机联调(需 TG 新基建就绪):A 注册拿邀请码 → B 用 A 的码注册 → A/B 各 +3;B 用人民币下一单付成 → A/B 各再 +7;B 在 App 点「加入频道」+「验证领取」→ bot 校验成员 → +3;重复领被拒。 - 防刷:自邀请无关系;同设备第二账号注册奖励被拒(status=rejected);同一 telegram_id 换账户领被拒。 ## 不在本轮(YAGNI) - 现金/提现、多级分销、退群回收、注册后补填邀请码。 - deep-link 自动预填(Task 13 Step 6 单列后续)。 - 通知集成(奖励事件接缝留给 Spec ③)。