Files
zt-closet-backend/.codebuddy/plans/新用户注册赠送体验会员_9ba5542c.md
T
2026-09-11 01:41:52 +08:00

149 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: 新用户注册赠送体验会员
overview: 新增 vip_type=4 体验会员,并在微信登录首次注册时按可配置天数(默认 3 天,允许 0)幂等发放,同时下发系统通知;同步配置、常量、注释、迁移列注释与三份文档。
todos:
- id: add-config
content: 新增 UserDefaultVipDays 配置段(section.go/global/main.go/config.example.yaml)
status: completed
- id: add-vip-type
content: 新增 VIPTypeTrial=4 与 DefaultVipDays 常量并同步模型与 DTO 注释
status: completed
- id: impl-register-gift
content: 实现 ensureTrialVIP:幂等发放、回填缓存字段、系统通知
status: completed
dependencies:
- add-config
- add-vip-type
- id: add-migration
content: 新增 000017 迁移更新三处 vip_type 列注释
status: completed
dependencies:
- add-vip-type
- id: sync-docs
content: 同步接口文档、README 与 Stitch 的体验会员说明
status: completed
dependencies:
- impl-register-gift
- id: verify
content: 编译校验并用 [subagent:code-explorer] 复核枚举同步与幂等缓存
status: completed
dependencies:
- impl-register-gift
- add-migration
- sync-docs
---
## 产品概述
为「纸条AI衣橱」新增「新用户注册赠送体验会员」能力:微信首次登录自动注册后,同步赠送 3 天体验会员(新增 `vip_type=4` 体验会员类型),并给用户发一条系统通知;赠送天数可配置、可关闭,失败不影响登录。
## 核心功能
- **注册即送体验会员**:首次注册成功后自动开通 3 天体验会员(`vip_type=4`),到期时间按注册时间 + N×86400 秒累加
- **新增会员类型**:`vip_type` 枚举新增 `4=体验会员`,与既有的非会员(1)/月度(2)/年度(3) 并列,模型、DTO、迁移列注释、接口文档、README、Stitch 设计稿同步
- **天数可配置可关闭**:新增 `UserDefaultVipDays` 配置段,未配置回落内置默认 3 天;显式配 0 表示不赠送;负数视为非法回落默认(与 4 类免费额度的指针三态语义一致)
- **发放幂等**:复用 `vip_flow_logs` 的 `uk_vip_flow_idem(order_no, change_type)` 唯一索引,注册场景以 `register_gift_<userID>` 作为幂等键
- **欢迎通知**:赠送成功后写入一条系统通知(「欢迎礼包已到账」),并递增未读徽标计数
- **不影响登录**:注册赠送为 best-effort,任何失败仅告警;购买、退款回收、权益补偿等既有链路行为不变
## 技术栈
- 语言/框架:Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend`
- 配置:`pkg/setting`(viper 只读 `configs/config.yaml`,`ReadSection` → `UnmarshalKey`/mapstructure)
- 存储:MySQL(GORM + 已落地的 golang-migrate 版本化迁移)、Redis(会话/用户信息缓存/未读计数)
- 可观测:OpenTelemetry span(命名已按层区分:`mysqldao.*` / `redisdao.*` / `svc.*`)
## 实现方案
采用「复用既有会员发放链路 + 配置三态指针 + 幂等键带 userID」的方案:注册分支内调用与购买路径同一个 `VIPGrantIdempotent`(事务内原子累加 `vip_expire` 并写流水),只额外传入新的 `vipType=4` 与注册专用的幂等键,从而不新增表、不改发放语义、天然获得并发幂等能力。
关键决策与取舍:
1. **复用 `VIPGrantIdempotent` 而非新写发放函数**:该函数第 51 行已把 `vipType` 作为入参,传 4 即可,SQL 无需改动;其原生 SQL `vip_expire = (CASE WHEN vip_expire > now THEN vip_expire ELSE now END) + days*86400` 保证并发安全与「已过期则重置」语义,重复实现反而易错。
2. **幂等键用 `register_gift_<userID>`**:注册场景没有订单号,而唯一索引是 `(order_no, change_type)` 而非按 user 维度——order_no **必须带 userID**,若写死常量会导致全站只能发放一次(第二个用户注册即冲突回滚)。此做法与 `quota_flow_logs` 在非订单场景把流水 UUID 写入 `order_no` 的既有约定一致,`order_no VARCHAR(64)` 长度充足。
3. **配置用对象段 + `*int64`**:`UserDefaultVipDays: { Days: 3 }`,与 `UserDefaultQuota` 同构、共用 `ReadSection`,零解码风险;指针用于区分「未配置(nil)→回落 3 天」与「显式 0→不赠送」,沿用刚落地的额度配置语义,避免二义。
4. **不改已应用的迁移文件**:`000001`/`000010` 的列注释更新走新增的 `000017`(`ALTER TABLE ... MODIFY COLUMN`),保持迁移历史可重放、不漂移。
5. **同步内存 user 对象再写缓存(易漏点)**:注册流程第 97 行 `redisdao.UserInfoSet(ctx, user, 0)` 会把内存对象写入缓存,若赠送后不更新 `user.VIPType/VIPExpire`,缓存会被写成「非会员」导致登录响应与缓存不一致。故赠送成功后必须回读用户并回填这两个字段。
6. **响应层归一(可选,默认纳入)**:`buildUserInfoResponse` 在 `vip_expire <= now` 时把返回 `vip_type` 归一为 1,避免过期会员(尤其是 3 天体验会员)被前端误判为有效会员;只影响响应,不写库。
性能与可靠性:该路径仅在首次注册执行一次;复用既有事务与索引,无额外查询放大。风险点为「缓存被旧对象覆盖」与「幂等键冲突范围」,分别由第 5、2 点覆盖,并以 `go build`/`go vet` + 子代理复核兜底。
## 架构设计
```mermaid
flowchart TD
A["微信登录 /user/wxlogin<br/>首次自动注册"] --> B["ensureDefaultQuota<br/>4 类免费额度"]
B --> C["ensureTrialVIP<br/>读 UserDefaultVipDays 配置"]
C -->|"days <= 0"| Z["不赠送,直接返回"]
C -->|"days > 0"| D["mysqldao.VIPGrantIdempotent<br/>vipType=4, order_no=register_gift_uid"]
D --> E[("users.vip_type=4<br/>vip_expire += days*86400")]
D --> F[("vip_flow_logs<br/>uk_vip_flow_idem 幂等")]
D -->|成功| G["回读用户回填 VIPType/VIPExpire<br/>→ UserInfoSet 缓存正确"]
G --> H["sendNotification<br/>系统通知 + 未读 +1"]
D -->|唯一键冲突| I["视为已发放,跳过"]
```
组件关系:注册服务(`internal/service/user.go`)为唯一编排点,向下依赖 `mysqldao.VIPGrantIdempotent`、`redisdao.UserInfoSet/NotificationUnreadIncr`、`pkg/setting` 配置;与购买链路(`internal/service/order.go` 的 `grantVIP`)并行复用同一 DAO,互不侵入。
## 目录结构
```
pkg/setting/section.go # [NEW 段] 新增 UserDefaultVipDaysSettingS{ Days *int64 },注释 nil/0/负数三态
global/setting.go # [MODIFY] 新增 UserDefaultVipDaysSetting *setting.UserDefaultVipDaysSettingS
main.go # [MODIFY] 追加 ReadSection("UserDefaultVipDays", &global.UserDefaultVipDaysSetting)
configs/config.example.yaml # [MODIFY] 新增 UserDefaultVipDays 段(Days: 3 + 0/负数/删除语义注释)
internal/service/constants.go # [MODIFY] 新增 VIPTypeTrial int8 = 4;新增内置兜底常量 DefaultVipDays int64 = 3
internal/service/user.go # [MODIFY] 新增 defaultVipDays() 与 ensureTrialVIP();注册分支调用并回读回填 user;
# buildUserInfoResponse 过期归一为 1(可选项,默认纳入)
internal/mysqlmodel/user.go # [MODIFY] vip_type 字段注释补 4=体验会员
internal/mysqlmodel/vipflowlog.go # [MODIFY] old/new_vip_type 注释补 4=体验会员
internal/dto/user.go # [MODIFY] vip_type 注释补 4=体验会员
pkg/migration/000017_alter_vip_type_comment.up.sql # [NEW] ALTER 更新 users.vip_type、vip_flow_logs.old/new_vip_type 三处列注释
pkg/migration/000017_alter_vip_type_comment.down.sql # [NEW] 恢复三处列注释为旧文案
接口文档.md # [MODIFY] 360 行 vip_type 枚举补 4;488 行注册流程补「赠送体验会员」
README.md # [MODIFY] 558 行字段枚举补 4;业务流程/配置表补 UserDefaultVipDays 说明
Stitch.md # [MODIFY] 第 12 段会员卡、第 14 段会员标签补「体验会员」展示文案
```
## 关键代码结构
```
// internal/service/constants.go(新增)
const (
VIPTypeNone int8 = 1 // 非会员
VIPTypeMonthly int8 = 2 // 月度会员
VIPTypeYearly int8 = 3 // 年度会员
VIPTypeTrial int8 = 4 // 体验会员(注册赠送,天数由 UserDefaultVipDays 配置)
)
// 内置兜底:仅在 UserDefaultVipDays 未配置或为负数时回落
const DefaultVipDays int64 = 3
// internal/service/user.go(新增,仅签名)
// defaultVipDays 返回注册赠送的体验会员天数:配置未设置(nil)或负数回落 DefaultVipDays,显式 0 表示不赠送。
func defaultVipDays() int64
// ensureTrialVIP 注册赠送体验会员(best-effort):赠送成功返回新的到期时间,未赠送或已发放返回 0。
// 幂等键 register_gift_<userID> 依赖 vip_flow_logs 的 uk_vip_flow_idem(order_no, change_type)。
func ensureTrialVIP(ctx context.Context, userID int64) int64
```
## 实现要点(防回归)
- **幂等键必须含 userID**:`fmt.Sprintf("register_gift_%d", userID)`;写死常量会导致全站只能发放一次。
- **缓存一致性**:赠送成功后必须回读用户并把 `VIPType/VIPExpire` 回填到内存 `user`,否则第 97 行 `redisdao.UserInfoSet(ctx, user, 0)` 会把「非会员」写进缓存。
- **流水字段**:`ChangeType = VIPChangeTypeActivity(3) activity赠送`、`Source = VIPSourceSystem(3) 系统`、`Reason = "新用户注册赠送体验会员"`;`OldVIPType` 取回读前的值(注册场景为 1)。
- **幂等判定**:`isDuplicateKey(err)` → 视为已发放直接返回,不视为失败;其它错误 `slog.WarnContext` 后继续,绝不阻断登录。
- **通知文案**:`sendNotification(ctx, userID, 0, NotificationTypeSystem, "欢迎礼包已到账", "已为你开通 N 天体验会员,有效期至 YYYY-MM-DD")`,best-effort。
- **不扩大范围**:`grantVIP`(购买)、`VIPRevokeIdempotent`(退款回收)、`benefit_grant_tasks` 补偿链路均不动;体验会员续费时 `user.VIPType != 1 && vip_expire > now` 会自然记为「续费」,符合预期。
- **迁移 000017** 用 `ALTER TABLE ... MODIFY COLUMN` 时需完整重复列定义(`TINYINT NOT NULL DEFAULT 0 COMMENT '...'`),down 恢复旧注释;**不要**改动已应用的 000001/000010。
- 不改动 `configs/config.yaml`(用户本地、已 gitignore;其第 2 行 `Domain: *` 为既有非法 YAML 问题,本次不处理)。
- 验证:`go build ./...`、`go vet ./...` 通过;用 code-explorer 复核 vip_type 枚举全量同步、幂等键含 userID、注册后缓存不被写成非会员。
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose:实现完成后复核三件事——① 全仓库 vip_type 枚举说明(模型 2 处、DTO 1 处、迁移 3 处、接口文档 2 处、README 1 处、Stitch 2 段)是否全部补上 4=体验会员且无遗漏;② `ensureTrialVIP` 的幂等键是否带 userID、幂等分支是否正确、失败是否严格 best-effort 不阻断登录;③ 注册流程中内存 `user` 对象是否在赠送后被正确回填,避免缓存被写成非会员
- Expected outcome:输出差异清单(遗漏位置 / 错误逻辑 / 缓存一致性风险),据其修正后保证注册赠送幂等、可配置、可关闭且与既有会员链路零冲突