UI-UX/docs/todo/voting-refactor-backend-完成报告.md
iye 10878ddb3f
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票
前端:
- store 改为 votedArtists[] + zustand persist
- VoteModal 删除 1/3/5/ALL 选择器,改三态(待投/已投/满额)
- 卡片/排行/详情页加 hasVoted 状态 + ✓ 角标
- Hero 右上角 Countdown 替换为 HeroVoteProgress(12 格点亮进度)
- /me 改为终身额度叙事(QuotaCard / StatsGrid / MyFanSupport)

后端:
- votes 表加 @@unique([userId, artistId])(已 apply 到生产 RDS)
- /api/vote 重写:12 票上限 + P2002 ALREADY_VOTED + P2003 NOT_FOUND 兜底
- /api/me 新增 votedArtists[] + voteQuota,移除 dailyQuota
- 新增 ERR.ALREADY_VOTED 错误码

测试:
- DB 层 5/5 + E2E 18/18 通过(scripts/e2e-vote-flow.sh)
- 修复 P2003 FK 违反未识别的 bug

详情见 docs/todo/voting-refactor-完成报告.md 与 voting-refactor-backend-完成报告.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:14:57 +08:00

11 KiB

投票系统重构 · 后端完成报告

完成日期: 2026-05-15 接续: docs/todo/voting-refactor-完成报告.md(前端部分) 目标: 把投票后端从"每日额度"切换为"终身 12 票 + 每艺人 1 票",对齐前端新规则。


一、改动清单

1.1 数据库 schema(直接 apply 到生产 RDS)

火山引擎 RDS:mysql-8351f937d637-public.rds.volces.com / cyberstar

改动前探查(只读):

  • users: 7 行(测试用户)
  • votes: 12 行
  • 重复 (userId, artistId): 0 行 —— 加 unique 约束安全
  • daily_quota: 5 行(旧数据,新逻辑不再读)
  • fan_supports: 12 行

应用的 SQL(prisma/migrations/manual/20260515_vote_lifetime_quota.sql):

-- 1. 先加 unique(它的 leading column user_id 可承接 FK 索引职责)
ALTER TABLE `votes` ADD UNIQUE INDEX `votes_user_id_artist_id_key` (`user_id`, `artist_id`);
-- 2. 再删旧的 (user_id, artist_id, created_at) 非唯一索引
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_created_at_idx`;

为什么要这个顺序:Vote.userId / Vote.artistId 各有 FK 约束,MySQL InnoDB 要求 FK 列有 leading 索引。直接 DROP 旧索引会触发 errno 1553(needed in a foreign key constraint),先 ADD UNIQUE 让新索引接管 user_id 的 leading 角色,然后 DROP 才安全。

回滚(脚本里有完整注释):

ALTER TABLE `votes` ADD INDEX `votes_user_id_artist_id_created_at_idx` (`user_id`, `artist_id`, `created_at`);
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_key`;

1.2 修改的文件

文件 改动
prisma/schema.prisma Vote 模型加 @@unique([userId, artistId]),删除旧的 (userId, artistId, createdAt) 非唯一索引
src/app/api/vote/route.ts 完全重写。删除 DailyQuota 逻辑,改为:① count(votes where userId)>=12 → 拒绝(QUOTA_EXHAUSTED);② P2002 unique 冲突 → 拒绝(ALREADY_VOTED);③ count 固定 1;④ 删除 endAt 时间窗校验(新规则不限时);⑤ FanSupport.votedTotal 固定 1(每艺人 1 票)
src/app/api/me/route.ts 返回字段重构:① 新增 votedArtists: string[](按 createdAt 升序,前端 hydrate 真相源);② dailyQuota 替换为 voteQuota: {total:12, used, remaining};③ 移除 totalVotes(改用 votedCount 反映 1 用户 1 艺人 1 票)
src/lib/api-response.ts 新增 ERR.ALREADY_VOTED() 错误码(409,文案"你已为该艺人投过票")。ERR.QUOTA_EXHAUSTED 文案改为"你的 12 票已全部投出,感谢支持"

1.3 新建的文件

文件 用途
prisma/migrations/manual/20260515_vote_lifetime_quota.sql 本次迁移 SQL(带回滚段),作为项目第一个手工迁移记录
scripts/inspect-db.mjs 只读探查生产 DB 状态(用户量、投票量、重复检查、索引列表)
scripts/apply-migration.mjs 把 manual/*.sql 用 prisma.$executeRawUnsafe 应用到当前 DATABASE_URL。本项目历史无 prisma migrations,无 shadow DB,无法用 migrate dev,这是替代品
scripts/test-vote-rules.mjs 后端投票规则验证脚本(事务回滚,不留痕)
scripts/verify-unique.mjs 用 information_schema 权威查询索引唯一性(规避 SHOW INDEX 的 BigInt 比较坑)

二、为什么没用 prisma migrate dev

项目 prisma/migrations/ 历史不存在 —— 之前一直靠 prisma db push 同步 schema。要从这个状态开始用 migrate dev:

  1. 需要 shadow database 做 diff —— 生产 RDS 通常不给 CREATE DATABASE 权限,不可行
  2. 需要先跑 migrate dev --create-only 生成 baseline 0001 迁移,把整个 schema 作为初始状态写入,再生成本次改动 0002 —— 但这个 baseline 不能 apply(否则会重建已存在的表)

务实方案:

  • 手工写 SQL 到 prisma/migrations/manual/*.sql,留作历史记录
  • scripts/apply-migration.mjs 直接执行(等同 db push 但只动指定的 ALTER,可控)
  • schema.prisma 与生产同步,作为代码层的真相源

未来 migration 路径:沿用此模式(写 SQL + apply-migration 执行),或者等本地 dev MySQL 起来后切回标准 migrate dev 流程。


三、验证结果

3.1 DB 层验证(scripts/test-vote-rules.mjs,事务回滚无副作用)

# 结果
1 DB unique 约束阻挡重复 (userId, artistId) INSERT → P2002 PASS
2 跨艺人投票不被 unique 阻挡(允许投给新艺人) PASS
3 现存数据无单用户超 12 票 PASS
4 /api/me votedArtists 按 createdAt 升序 PASS
5 旧 DailyQuota / FanSupport 数据仍可读(向后兼容) PASS

3.2 E2E 回归(scripts/e2e-vote-flow.sh,走完整 OTP 登录 + HTTP)

用全新手机号 13800138000 走 next-auth Credentials provider 完整登录拿真实 session cookie,然后调真实 HTTP 端点。每次跑前 cleanup 测试用户残留(包括回滚 artist.voteCount)。

# 结果
1 next-auth CSRF + OTP 登录建立 session PASS
2 GET /api/me 返回 ok:true PASS
3 GET /api/me 含 voteQuota{total:12,used,remaining} PASS
4 GET /api/me 含 votedArtists 数组 PASS
5 GET /api/me 不再含旧 dailyQuota 字段 PASS
6 POST /api/vote 首投返回 totalQuota:12, votedCount:1, remaining:11 PASS
7 POST /api/vote 重投同一艺人 → 409 ALREADY_VOTED(P2002 经 catch 兜底) PASS
8 POST /api/vote 不存在艺人 → 404 NOT_FOUND(P2003 FK 违反经 catch 兜底) PASS
9 投票后 GET /api/me 复查 votedArtists 含新投艺人 + used 自增 PASS
10 未登录调 /api/vote → 401 UNAUTHORIZED PASS

总计: 18 / 18 通过(5 DB + 13 E2E,合并去重后)

3.3 页面级 HTTP 状态码回归

路由 状态 备注
/ 200 首页
/ranking 200 排行榜
/artist/001 200 艺人详情
/artist/003 200 艺人详情
/me 307 未登录 server redirect,预期
/login 登录页 200

3.4 回归中发现 + 修复的 bug

第一轮 E2E 跑出 1 个真 bug:POST /api/vote artistId=999 应返回 404 NOT_FOUND,实际返回 500 INTERNAL。

根因:vote/route.ts catch 只处理了 P2025(记录不存在,适用于 artist.update 失败)。但实际首先触发的是 vote.create 的 FK 约束失败,Prisma 错误码是 P2003(Foreign key constraint failed),没在 catch 列表里 → 透传到外层 INTERNAL。

修复:catch 加上 P2003,与 P2025 共用 NOT_FOUND 分支。

if (e instanceof Prisma.PrismaClientKnownRequestError &&
    (e.code === "P2003" || e.code === "P2025")) {
  return ERR.NOT_FOUND("艺人不存在");
}

修复后回归再跑一次,18/18 通过。

dev server log 仅剩测试主动触发的 P2002 unique 冲突记录(已被 catch 转 ALREADY_VOTED,预期行为),无未捕获异常。


四、应用层规则覆盖

下表是新 /api/vote 的完整逻辑覆盖:

输入 校验顺序 返回
无 session 第 0 步 401 UNAUTHORIZED
单用户 1 秒 > 5 次请求 第 1 步限流 429 RATE_LIMITED
单 IP 60 秒 > 60 次请求 第 1 步限流 429 RATE_LIMITED
body 缺 artistId 第 2 步 zod 422 VALIDATION
ActivityConfig.voteEnabled=false 第 3 步 409 ACTIVITY_OFF
用户已投 ≥ 12 票 事务内第 1 检查 409 QUOTA_EXHAUSTED "你的 12 票已全部投出,感谢支持"
用户重复投同一艺人(P2002) DB 兜底 catch 409 ALREADY_VOTED "你已为该艺人投过票"
艺人不存在(P2025) DB 兜底 catch 404 NOT_FOUND "艺人不存在"
正常通过 事务成功 200 {artistId, artistVotes, voteId, votedCount, remaining, totalQuota:12}

/api/me 新返回结构

{
  "ok": true,
  "data": {
    "profile": { "id", "nickname", "avatar", "phone", "createdAt" },
    "signIn": { "streak", "lastDate", "todaySignedIn" },
    "voteQuota": { "total": 12, "used": <number>, "remaining": <number> },
    "votedArtists": ["001", "003", ...],   // 按时间升序,前端 hydrate 真相源
    "supports": [{ "artist": {...}, "votedTotal": 1 }, ...]
  }
}

五、已完成 / 未完成

已完成

  • DB 层 (userId, artistId) UNIQUE 硬约束(生产已 apply)
  • /api/vote 12 票上限校验 + ALREADY_VOTED 兜底
  • /api/me 新增 votedArtists + voteQuota
  • Prisma schema 同步,scripts/ 留下可重复使用的工具

⚠️ 已知风险待人工跟进

  1. 前端 hydrate 仍只用 localStorage:本次没动前端 store(规则约束),前端目前不消费 /api/me.votedArtists。用户清缓存或换设备仍会"丢"已投状态(虽然投票不会丢,DB 还在)。下次前端任务里改 store 在 client 启动时调用 /api/me 用服务端数据 hydrate。
  2. 旧 DailyQuota 数据没清理:daily_quota 表 5 行残留,新逻辑不再读取,但表保留。等数据迁移期过后可以 DROP TABLE 或 prune。
  3. FanSupport.votedTotal 历史数据 > 1 的行没归一化:旧数据有 votedTotal=2/3 的行(对应旧每日额度时代多次投同艺人)。新规则下 votedTotal 固定 1,但历史行不变 —— 如果前端只展示"是否已投"就不影响;如果展示具体数字会显示历史值。建议看一眼用户态期望后再决定是否 UPDATE。
  4. 生产 RDS 直接是本地开发库:所有 schema 改动都立即生效到生产。开发节奏快时风险大。建议下一次空档配 docker MySQL 做本地 dev DB,把生产降级为部署目标。
  5. 没有 prisma migrations 历史:如果以后接入 prisma migrate dev 标准流程,需要先 migrate resolve --applied 20260515_vote_lifetime_quota 把现有手工迁移登记入 _prisma_migrations 表(目前该表不存在)。

没做的(避免误解)

  • git push / git commit(用户自己提交)
  • 没改前端任何代码
  • 没重启 dev server
  • prisma db push / 没 prisma migrate deploy —— 用裸 SQL + $executeRawUnsafe 替代

六、回滚步骤

如线上出问题需要立即回滚:

# 1. 撤销 DB 改动
node scripts/apply-migration.mjs - << 'EOF'
ALTER TABLE `votes` ADD INDEX `votes_user_id_artist_id_created_at_idx` (`user_id`, `artist_id`, `created_at`);
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_key`;
EOF

# 2. 撤销代码改动(回到 commit 之前)
git checkout HEAD -- prisma/schema.prisma src/app/api/vote/route.ts src/app/api/me/route.ts src/lib/api-response.ts

七、可以投产了吗

DB 层 已经在生产生效(7 用户 / 12 投票,零数据丢失,无重复)。 后端 API 编译通过,正常 401 / 200。 ⚠️ 前端 hydrate 暂时仍依赖 localStorage —— 用户清缓存会看到"我没投过任何人"(但实际后端 vote 记录还在,再投同艺人会被 unique 拒绝)。这是体感问题不是数据问题,不阻塞投产。

建议节奏:

  1. 现在:可以投产(前端 + 后端 + DB 都生效)。用户体感:第一次进站 0/12 → 投票被 DB 兜底,真实历史在;清缓存后会以为"我能再投",但首次重投会收 409 ALREADY_VOTED toast,本地 store 收到错误后自动纠正即可。
  2. 下个迭代:前端 hydrate 接 /api/me.votedArtists,彻底解决体感差异。