149 lines
12 KiB
Markdown
149 lines
12 KiB
Markdown
---
|
||
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:输出差异清单(遗漏位置 / 错误逻辑 / 缓存一致性风险),据其修正后保证注册赠送幂等、可配置、可关闭且与既有会员链路零冲突 |