Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
前端: - 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>
178 lines
5.6 KiB
JavaScript
178 lines
5.6 KiB
JavaScript
// 后端投票规则验证(不污染生产数据,所有写操作都在事务里 rollback)
|
|
import { PrismaClient, Prisma } from "@prisma/client";
|
|
const prisma = new PrismaClient({ log: ["error"] });
|
|
|
|
const PASS = "\x1b[32mPASS\x1b[0m";
|
|
const FAIL = "\x1b[31mFAIL\x1b[0m";
|
|
|
|
let results = [];
|
|
function record(name, ok, detail = "") {
|
|
results.push({ name, ok, detail });
|
|
console.log(` [${ok ? PASS : FAIL}] ${name}${detail ? " -- " + detail : ""}`);
|
|
}
|
|
|
|
async function main() {
|
|
console.log("=== 后端投票规则验证(事务回滚,不留痕)===\n");
|
|
|
|
// 取一个真实用户(测试数据中的最大投票用户)
|
|
const sample = await prisma.$queryRaw`
|
|
SELECT user_id, COUNT(*) AS c FROM votes GROUP BY user_id ORDER BY c DESC LIMIT 1
|
|
`;
|
|
if (sample.length === 0) {
|
|
console.log("[note] votes 表为空,跳过有交互的测试");
|
|
return;
|
|
}
|
|
const sampleUserId = BigInt(sample[0].user_id);
|
|
const sampleVotes = await prisma.vote.findMany({
|
|
where: { userId: sampleUserId },
|
|
select: { artistId: true },
|
|
});
|
|
const sampleArtist = sampleVotes[0].artistId;
|
|
console.log(
|
|
`测试样本:user=${sampleUserId} 已投艺人=[${sampleVotes
|
|
.map((v) => v.artistId)
|
|
.join(",")}]\n`,
|
|
);
|
|
|
|
// 测试 1:DB unique 约束生效 —— 重复 (userId, artistId) INSERT 应失败 P2002
|
|
console.log("[测试 1] DB unique 约束:重复 (userId, artistId) 必被拒");
|
|
try {
|
|
await prisma.$transaction(
|
|
async (tx) => {
|
|
await tx.vote.create({
|
|
data: {
|
|
userId: sampleUserId,
|
|
artistId: sampleArtist,
|
|
count: 1,
|
|
source: "QUOTA",
|
|
},
|
|
});
|
|
throw new Error("ROLLBACK_AFTER_TEST"); // 强制回滚
|
|
},
|
|
{ timeout: 10000 },
|
|
);
|
|
record("unique 约束阻挡重复投票", false, "INSERT 居然成功了");
|
|
} catch (e) {
|
|
if (
|
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
|
e.code === "P2002"
|
|
) {
|
|
record("unique 约束阻挡重复投票", true, "P2002 unique constraint");
|
|
} else if (e.message === "ROLLBACK_AFTER_TEST") {
|
|
record(
|
|
"unique 约束阻挡重复投票",
|
|
false,
|
|
"INSERT 居然成功 = 没有 unique 约束!",
|
|
);
|
|
} else {
|
|
record("unique 约束阻挡重复投票", false, `异常: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// 测试 2:不同艺人 INSERT 应该成功(在事务内回滚,不留痕)
|
|
console.log(
|
|
"\n[测试 2] 不同艺人投票不会被 unique 阻挡(事务内验证后回滚)",
|
|
);
|
|
const candidateArtist = await prisma.artist.findFirst({
|
|
where: {
|
|
id: { notIn: sampleVotes.map((v) => v.artistId) },
|
|
status: "ACTIVE",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (!candidateArtist) {
|
|
record("跨艺人投票", false, "找不到该用户未投过的艺人样本");
|
|
} else {
|
|
try {
|
|
await prisma.$transaction(
|
|
async (tx) => {
|
|
await tx.vote.create({
|
|
data: {
|
|
userId: sampleUserId,
|
|
artistId: candidateArtist.id,
|
|
count: 1,
|
|
source: "QUOTA",
|
|
},
|
|
});
|
|
throw new Error("ROLLBACK_AFTER_TEST");
|
|
},
|
|
{ timeout: 10000 },
|
|
);
|
|
record("跨艺人投票", false, "事务未回滚");
|
|
} catch (e) {
|
|
if (e.message === "ROLLBACK_AFTER_TEST") {
|
|
record(
|
|
"跨艺人投票",
|
|
true,
|
|
`允许投给新艺人 ${candidateArtist.id},事务已回滚`,
|
|
);
|
|
} else {
|
|
record("跨艺人投票", false, `异常: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 测试 3:终身额度 12 票上限(由 /api/vote 应用层校验,DB 不强制)
|
|
// 验证:对所有用户跑 SELECT COUNT(*) < 12 是 true,确认目前没人超额
|
|
console.log("\n[测试 3] 终身额度上限 12 票 - 数据合规性");
|
|
const overflows = await prisma.$queryRaw`
|
|
SELECT user_id, COUNT(*) AS c FROM votes GROUP BY user_id HAVING c > 12
|
|
`;
|
|
if (overflows.length === 0) {
|
|
record(
|
|
"现存数据无超额用户",
|
|
true,
|
|
"12 票上限对所有现存用户都成立(应用层会兜底)",
|
|
);
|
|
} else {
|
|
record(
|
|
"现存数据无超额用户",
|
|
false,
|
|
`${overflows.length} 个用户已超过 12 票:${overflows
|
|
.map((r) => `user=${r.user_id} c=${r.c}`)
|
|
.join("; ")}`,
|
|
);
|
|
}
|
|
|
|
// 测试 4:回归确认 /api/me 用的 vote 关键查询能返回 votedArtists 列表
|
|
console.log("\n[测试 4] /api/me votedArtists 查询正确性");
|
|
const votedList = await prisma.vote.findMany({
|
|
where: { userId: sampleUserId },
|
|
select: { artistId: true, createdAt: true },
|
|
orderBy: { createdAt: "asc" },
|
|
});
|
|
const isAsc = votedList.every(
|
|
(v, i) =>
|
|
i === 0 || votedList[i - 1].createdAt.getTime() <= v.createdAt.getTime(),
|
|
);
|
|
record(
|
|
"votedArtists 按 createdAt 升序",
|
|
isAsc,
|
|
`共 ${votedList.length} 条`,
|
|
);
|
|
|
|
// 测试 5:旧 daily_quota / fan_supports 数据仍可读(向后兼容,不报错)
|
|
console.log("\n[测试 5] 旧数据兼容性");
|
|
try {
|
|
const dqCount = await prisma.dailyQuota.count();
|
|
const fsCount = await prisma.fanSupport.count();
|
|
record("旧 DailyQuota / FanSupport 仍可读", true, `dq=${dqCount} fs=${fsCount}`);
|
|
} catch (e) {
|
|
record("旧 DailyQuota / FanSupport 仍可读", false, e.message);
|
|
}
|
|
|
|
// 汇总
|
|
console.log("\n=== 汇总 ===");
|
|
const passed = results.filter((r) => r.ok).length;
|
|
const failed = results.length - passed;
|
|
console.log(`通过 ${passed} / 共 ${results.length}${failed ? ` 失败 ${failed}` : ""}`);
|
|
await prisma.$disconnect();
|
|
if (failed) process.exit(1);
|
|
}
|
|
|
|
main().catch(async (e) => {
|
|
console.error("\n[ABORT]", e);
|
|
await prisma.$disconnect();
|
|
process.exit(1);
|
|
});
|