12 KiB
12 KiB
name, overview, todos
| name | overview | todos | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 新用户注册赠送体验会员 | 新增 vip_type=4 体验会员,并在微信登录首次注册时按可配置天数(默认 3 天,允许 0)幂等发放,同时下发系统通知;同步配置、常量、注释、迁移列注释与三份文档。 |
|
产品概述
为「纸条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 与注册专用的幂等键,从而不新增表、不改发放语义、天然获得并发幂等能力。
关键决策与取舍:
- 复用
VIPGrantIdempotent而非新写发放函数:该函数第 51 行已把vipType作为入参,传 4 即可,SQL 无需改动;其原生 SQLvip_expire = (CASE WHEN vip_expire > now THEN vip_expire ELSE now END) + days*86400保证并发安全与「已过期则重置」语义,重复实现反而易错。 - 幂等键用
register_gift_<userID>:注册场景没有订单号,而唯一索引是(order_no, change_type)而非按 user 维度——order_no 必须带 userID,若写死常量会导致全站只能发放一次(第二个用户注册即冲突回滚)。此做法与quota_flow_logs在非订单场景把流水 UUID 写入order_no的既有约定一致,order_no VARCHAR(64)长度充足。 - 配置用对象段 +
*int64:UserDefaultVipDays: { Days: 3 },与UserDefaultQuota同构、共用ReadSection,零解码风险;指针用于区分「未配置(nil)→回落 3 天」与「显式 0→不赠送」,沿用刚落地的额度配置语义,避免二义。 - 不改已应用的迁移文件:
000001/000010的列注释更新走新增的000017(ALTER TABLE ... MODIFY COLUMN),保持迁移历史可重放、不漂移。 - 同步内存 user 对象再写缓存(易漏点):注册流程第 97 行
redisdao.UserInfoSet(ctx, user, 0)会把内存对象写入缓存,若赠送后不更新user.VIPType/VIPExpire,缓存会被写成「非会员」导致登录响应与缓存不一致。故赠送成功后必须回读用户并回填这两个字段。 - 响应层归一(可选,默认纳入):
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:输出差异清单(遗漏位置 / 错误逻辑 / 缓存一致性风险),据其修正后保证注册赠送幂等、可配置、可关闭且与既有会员链路零冲突