Files
pangolin/docs/superpowers/plans/2026-07-12-invite-task-rewards.md
T
wangjia b70857cc0e docs(plan): 邀请奖励 + 奖励任务实现计划(14 任务 TDD)
依据 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>
2026-07-12 23:58:30 +08:00

70 KiB
Raw Blame History

邀请奖励 + 奖励任务(加入 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.goGET /v1/inviteGET /v1/tasks/telegram/startPOST /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: 表 referralsreward_claims;users.invite_codeusers.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) — 发 Pro days 天,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.goopenMigratedSQLite 抄一份(store.OpenMigrateUpApplyCodesLibMigrations),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) *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

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(&reg)
	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) *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

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,Redis SETEX 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.Clienttg tgConfigmemMu sync.MutexmemTok 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.gofunc (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.goWebhookSecret()/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/inviteGET /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(); }
    • inviteApiProviderinviteProvider(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);

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:

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: inviteProviderInviteInfo(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 —— 删占位常量,buildref.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"

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
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 ③)。