From b70857cc0e774e299d23d49d0862c1b077772348 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 12 Jul 2026 23:58:30 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20=E9=82=80=E8=AF=B7=E5=A5=96?= =?UTF-8?q?=E5=8A=B1=20+=20=E5=A5=96=E5=8A=B1=E4=BB=BB=E5=8A=A1=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E8=AE=A1=E5=88=92(14=20=E4=BB=BB=E5=8A=A1=20TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 docs/invite-task-rewards-design.html 拆成逐任务 TDD 计划: 后端 迁移000024 → GrantRewardTx → reward.Store → reward.Service(注册段防刷发奖) → auth.Register 接 inviteCode → pay webhook 首充钩子 → GET /v1/invite → TG token → TG webhook getChatMember 真校验 → main 装配; 客户端 invite api/provider → invite 页真实化+六语 l10n → 注册页邀请码输入。 含真实签名/建表 SQL/防刷用例;deep-link 预填列为可选后置。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-12-invite-task-rewards.md | 1831 +++++++++++++++++ 1 file changed, 1831 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-invite-task-rewards.md diff --git a/docs/superpowers/plans/2026-07-12-invite-task-rewards.md b/docs/superpowers/plans/2026-07-12-invite-task-rewards.md new file mode 100644 index 0000000..5d84a6d --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-invite-task-rewards.md @@ -0,0 +1,1831 @@ +# 邀请奖励 + 奖励任务(加入 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 ③)。