--- 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_` 作为幂等键 - **欢迎通知**:赠送成功后写入一条系统通知(「欢迎礼包已到账」),并递增未读徽标计数 - **不影响登录**:注册赠送为 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_`**:注册场景没有订单号,而唯一索引是 `(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
首次自动注册"] --> B["ensureDefaultQuota
4 类免费额度"] B --> C["ensureTrialVIP
读 UserDefaultVipDays 配置"] C -->|"days <= 0"| Z["不赠送,直接返回"] C -->|"days > 0"| D["mysqldao.VIPGrantIdempotent
vipType=4, order_no=register_gift_uid"] D --> E[("users.vip_type=4
vip_expire += days*86400")] D --> F[("vip_flow_logs
uk_vip_flow_idem 幂等")] D -->|成功| G["回读用户回填 VIPType/VIPExpire
→ UserInfoSet 缓存正确"] G --> H["sendNotification
系统通知 + 未读 +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_ 依赖 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:输出差异清单(遗漏位置 / 错误逻辑 / 缓存一致性风险),据其修正后保证注册赠送幂等、可配置、可关闭且与既有会员链路零冲突