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