依据 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) <noreply@anthropic.com>
70 KiB
邀请奖励 + 奖励任务(加入 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.sourceCHECK/ENUM 从('trial','code','pay')扩到('trial','code','pay','invite','task')(sqlite 重建表 / mysqlMODIFY)。 - 奖励天数:注册段各 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/dbdialect 层;时间一律 Go 端time.Now().UTC()传?,禁NOW()/UTC_TIMESTAMP()。 - 邀请码 = 8 位 base32 大写、去
0/O/1/I;users.invite_codeUNIQUE、惰性生成。 - 绑定仅注册时、first-touch 永久不可改。防刷:自邀请拦截(
inviter_id≠invitee_id)+referrals.invitee_idUNIQUE + 设备去重(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
-- 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
-- 还原 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
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
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
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)— 发 Prodays天,source 落新订阅行(已有活跃 pro 则顺延),写 audit_log。 -
Step 1: 写失败测试
server/internal/codes/reward_grant_sqlite_test.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末尾加:
// 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
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) *StoreEnsureInviteCode(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) errorMarkFirstPaidTx(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) errorInsertClaimTx(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
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
// 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:<id> 且 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
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) *Servicefunc 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
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
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
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
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):
// 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 签名与尾部:
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 请求体加字段并传参:
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
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追加:
// 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
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:
// 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() 之前插入:
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
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
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 部分)
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
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,RedisSETEX tg_bind:<token> 600 <userID>;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/<bot>?start=<token>"}。Service 需知道 bot username(SetTelegram(botUser, channel, botToken, webhookSecret))。
-
Step 1: 写失败测试
server/internal/reward/telegram_token_test.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
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 加:
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
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 <token>→ ConsumeToken → ClaimTelegram → sendMessage。
-
Step 1: 写失败测试
server/internal/reward/telegram_webhook_test.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追加:
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(生产用),同文件加:
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:
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
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)之后加:
// ── 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):
authSvc.SetReferralHook(rewardSvc)
pay webhook 装配处(main.go:334)后加:
payWebhook.SetRewarder(rewardSvc)
注意作用域:
authSvc/payWebhook在各自 if 块内构造。把上面两行放进对应 if 块末尾(webhook 仅 PAY_BASE_URL 配置时存在)。
- Step 3: 挂路由
protected group(main.go:427 内,与 pay 段并列)加:
protected.Get("/invite", rewardHandler.GetInvite)
protected.Get("/tasks/telegram/start", rewardHandler.TelegramStart)
public v1(与 /webhook/pay 并列,main.go:420 附近)加:
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
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<InviteInfo> fetch(); Future<String> telegramStartLink(); }inviteApiProvider、inviteProvider(AsyncNotifierProvider)。
-
Step 1: 写失败测试
client/test/unit/invite_api_test.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
// 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<String, dynamic> j) {
final tg = (j['telegram'] as Map<String, dynamic>?) ?? 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<InviteInfo> fetch() async => InviteInfo.fromJson(await _c.getJson('/v1/invite'));
Future<String> 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 装配):
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/invite_api.dart';
import 'account_providers.dart';
final inviteApiProvider = Provider<InviteApi>((ref) => InviteApi(ref.watch(apiClientProvider)));
class InviteNotifier extends AsyncNotifier<InviteInfo?> {
@override
Future<InviteInfo?> build() async {
// 未登录返回 null(不打网络);已登录拉取。
final token = ref.watch(authProvider).accessToken;
if (token == null || token.isEmpty) return null;
return ref.read(inviteApiProvider).fetch();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => ref.read(inviteApiProvider).fetch());
}
}
final inviteProvider = AsyncNotifierProvider<InviteNotifier, InviteInfo?>(InviteNotifier.new);
authProviderimport 来自 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:
Future<AuthTokens> register({required String email, required String code, required String password,
Map<String, dynamic>? 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
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:
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 示例):
@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
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<InviteInfo?> 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/<channel去@>)+「验证领取」(调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
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, ...)或按 keyfind.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(keyKey('invite-code-field'),labelt.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
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/<code>或自定义 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
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 ③)。