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

12 KiB
Raw Blame History

name, overview, todos
name overview todos
新用户注册赠送体验会员 新增 vip_type=4 体验会员,并在微信登录首次注册时按可配置天数(默认 3 天,允许 0)幂等发放,同时下发系统通知;同步配置、常量、注释、迁移列注释与三份文档。
id content status
add-config 新增 UserDefaultVipDays 配置段(section.go/global/main.go/config.example.yaml) completed
id content status
add-vip-type 新增 VIPTypeTrial=4 与 DefaultVipDays 常量并同步模型与 DTO 注释 completed
id content status dependencies
impl-register-gift 实现 ensureTrialVIP:幂等发放、回填缓存字段、系统通知 completed
add-config
add-vip-type
id content status dependencies
add-migration 新增 000017 迁移更新三处 vip_type 列注释 completed
add-vip-type
id content status dependencies
sync-docs 同步接口文档、README 与 Stitch 的体验会员说明 completed
impl-register-gift
id content status dependencies
verify 编译校验并用 [subagent:code-explorer] 复核枚举同步与幂等缓存 completed
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 + 子代理复核兜底。

架构设计

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