fix: account credentials, record deletion refresh and backup recovery

This commit is contained in:
陈煜 committed 2026-10-03 11:23:19 +08:00
1 parent 9adf7fdbc5
commit e77650f0c0
22 files changed
+711 -115

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@
"db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts",
"test:performance": "tsx scripts/performance.ts after",
"icons:seed": "node scripts/seed-icons.cjs"
},
+45 -2
View File
@@ -3,6 +3,8 @@ import {
Controller,
Get,
Post,
Patch,
BadRequestException,
Body,
Req,
Res,
@@ -18,7 +20,8 @@ import { Request, Response } from 'express';
import { randomBytes, createHash } from 'node:crypto';
import { hash, compare } from 'bcryptjs';
import { Database } from './database';
import { credentials } from './validation';
import { credentials, credentialChange } from './validation';
import { Prisma } from '@prisma/client';
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
const Public = () => SetMetadata('public', true);
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
@@ -58,6 +61,9 @@ export class AuthService {
const token = randomBytes(32).toString('hex'),
expiresAt = new Date(Date.now() + 7 * 86400000);
await this.db.session.create({ data: { id: digest(token), userId, expiresAt } });
this.cookie(token, expiresAt, res);
}
cookie(token: string, expiresAt: Date, res: Response) {
res.cookie('wp_session', token, {
httpOnly: true,
sameSite: 'strict',
@@ -166,7 +172,44 @@ export class AuthController {
idleMinutes: true,
},
});
return { ...user, hiddenMenus: user.hiddenMenus.split(',').filter(Boolean) };
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
return {
...user,
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
revealed: req.revealed,
revealUntil: session.revealUntil,
lastActivity: session.lastActivity,
};
}
@Patch('auth/credentials') async changeCredentials(
@Req() r: UserRequest,
@Body() body: unknown,
@Res({ passthrough: true }) res: Response,
) {
this.auth.limit(r);
const v = credentialChange.parse(body);
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
if (!(await compare(v.currentPassword, user.passwordHash)))
throw new ForbiddenException('当前密码错误');
if ((!v.username || v.username === user.username) && !v.newPassword)
throw new BadRequestException('请填写新的账号或密码');
const passwordHash = v.newPassword ? await hash(v.newPassword, 12) : user.passwordHash;
const token = randomBytes(32).toString('hex'),
expiresAt = new Date(Date.now() + 7 * 86400000);
await this.db.serial(async (tx) => {
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
const current = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
if (current.passwordHash !== user.passwordHash || current.username !== user.username)
throw new ForbiddenException('账号已变更,请重新登录后操作');
await tx.user.update({
where: { id: r.userId },
data: { username: v.username, passwordHash },
});
await tx.session.deleteMany({ where: { userId: r.userId } });
await tx.session.create({ data: { id: digest(token), userId: r.userId, expiresAt } });
});
this.auth.cookie(token, expiresAt, res);
return { ok: true };
}
@Post('auth/activity') async activity(@Req() r: UserRequest) {
await this.db.session.update({
+14 -15
View File
@@ -56,16 +56,14 @@ const record = positionMeta
importedFromId: z.string().uuid().nullable().optional(),
createdAt: timestamp,
updatedAt: timestamp,
revisions: z
.array(
revisionInput.extend({
id: z.string().uuid(),
sequence: z.number().int().positive().max(2147483647).optional(),
createdAt: timestamp,
updatedAt: timestamp,
}),
)
.min(1),
revisions: z.array(
revisionInput.extend({
id: z.string().uuid(),
sequence: z.number().int().positive().max(2147483647).optional(),
createdAt: timestamp,
updatedAt: timestamp,
}),
),
})
.strict();
const backupSchema = z
@@ -151,7 +149,7 @@ export function validateBackup(raw: unknown) {
archived: p.archived,
hidden: p.hidden,
amount: '0',
date: p.revisions[0].date,
date: p.revisions[0]?.date || '1900-01-01',
});
const sequences = new Set<number>();
for (const r of p.revisions) {
@@ -225,11 +223,11 @@ export function validateBackup(raw: unknown) {
current = ordered[index];
if (
usedRevisions.has(revId) ||
index < 1 ||
index < 0 ||
!current ||
current.reason !== reason ||
current.date !== t.date ||
!new Decimal(current.amount).minus(ordered[index - 1].amount).eq(delta)
!new Decimal(current.amount).minus(ordered[index - 1]?.amount || '0').eq(delta)
)
throw new BadRequestException('转账历史与双方金额不一致');
usedRevisions.add(revId);
@@ -296,7 +294,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
}
return prefix.toString() === 'PK'
? readBackupZip(path)
: JSON.parse(await readFile(path, 'utf8'));
: JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));
}
constructor(private db: Database) {}
private async data(
@@ -420,7 +418,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
});
return b;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
);
res.setHeader(
'Content-Disposition',
@@ -494,6 +492,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
);
data.currencies.sort();
data.transfers?.sort((a, b) => a.id.localeCompare(b.id));
data.schedules?.sort((a, b) => a.id.localeCompare(b.id));
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
}
+4 -3
View File
@@ -71,9 +71,10 @@ export class CalendarController {
name: p.name,
currency: p.currency,
date: businessDay(v.effectiveDate),
delta: hasBefore
? cashflowDelta(p.side, v.reason, before, v.amount.toString())
: new Decimal(0),
delta:
hasBefore || v.reason === 'scheduled_expense'
? cashflowDelta(p.side, v.reason, before, v.amount.toString())
: new Decimal(0),
notes: details ? (v as typeof v & { notes?: string }).notes || '' : '',
};
})
+1
View File
@@ -7,6 +7,7 @@ export class Database extends PrismaClient implements OnModuleInit, OnModuleDest
try {
return await this.$transaction(work, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
timeout: 30000,
});
} catch (error) {
// Serializable deadlock/write conflict; retry the entire atomic operation.
+2
View File
@@ -4,6 +4,7 @@ import { scheduleInput } from './schedules';
import { z } from 'zod';
import {
credentials,
credentialChange,
positionInput,
positionMeta,
revisionInput,
@@ -27,6 +28,7 @@ export function setupOpenApi(app: INestApplication) {
const bodies: Record<string, z.ZodType> = {
'POST /api/auth/register': credentials,
'POST /api/auth/login': credentials,
'PATCH /api/auth/credentials': credentialChange,
'POST /api/auth/reveal': credentials.pick({ password: true }),
'POST /api/positions': positionInput,
'PATCH /api/positions/{id}': positionMeta,
+12 -3
View File
@@ -30,6 +30,7 @@ import { Prisma } from '@prisma/client';
import { IconsService } from './icons';
import { RatesService } from './rates';
import { captureReplay } from './replay';
import { changeMovement } from './transfers';
@Controller('api')
export class PortfolioController {
constructor(
@@ -229,7 +230,6 @@ export class PortfolioController {
@Param('revisionId') revisionId: string,
) {
return this.db.serial(async (tx) => {
const replay = await captureReplay(tx, r.userId, [id]);
const p = await tx.position.findFirst({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
@@ -237,8 +237,17 @@ export class PortfolioController {
if (p.archived) throw new ConflictException('请先恢复归档项目');
const row = await tx.revision.findFirst({ where: { id: revisionId, positionId: id } });
if (!row) throw new NotFoundException('历史记录不存在');
if (pairedReasons.includes(row.reason))
throw new ConflictException('请在资金往来中删除完整配对记录');
if (pairedReasons.includes(row.reason)) {
const movement = await tx.transfer.findFirst({
where: {
userId: r.userId,
OR: [{ sourceRevisionId: revisionId }, { targetRevisionId: revisionId }],
},
});
if (!movement) throw new ConflictException('配对记录不完整,无法删除');
return changeMovement(tx, r, movement.id);
}
const replay = await captureReplay(tx, r.userId, [id]);
await tx.revision.delete({ where: { id: revisionId } });
await replay();
return { ok: true };
+2 -3
View File
@@ -51,10 +51,9 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string
: undefined;
if (pairedReasons.includes(row.reason) && !delta)
throw new ConflictException('配对记录不完整,无法重算');
if (delta && !balances.has(row.positionId))
throw new ConflictException('资金操作之前必须保留一条余额记录');
// With the initial observation deleted, remaining movements start at zero.
const amount = delta
? balances.get(row.positionId)!.plus(delta)
? (balances.get(row.positionId) || new Decimal(0)).plus(delta)
: new Decimal(row.amount.toString());
if (amount.isNegative() && (p.kind !== 'account' || p.side !== 'asset'))
throw new BadRequestException('修改后债务或资产金额不能为负数');
+2 -2
View File
@@ -222,8 +222,8 @@ export class SchedulesController {
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
},
});
if (!account?.revisions[0]) throw new BadRequestException('计划账户已归档或不可用');
const after = new Decimal(account.revisions[0].amount.toString()).minus(
if (!account) throw new BadRequestException('计划账户已归档或不可用');
const after = new Decimal(account.revisions[0]?.amount.toString() || '0').minus(
plan.amount.toString(),
);
if (after.abs().gte('10000000000000000'))
+69 -47
View File
@@ -48,6 +48,25 @@ export class TransfersController {
revealed: r.revealed,
};
}
@Get('revision/:revisionId') async byRevision(
@Req() r: UserRequest,
@Param('revisionId') revisionId: string,
) {
const row = await this.db.transfer.findFirst({
where: {
userId: r.userId,
OR: [{ sourceRevisionId: revisionId }, { targetRevisionId: revisionId }],
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
},
include: {
source: { select: { name: true, kind: true } },
target: { select: { name: true, kind: true } },
},
});
if (!row) throw new NotFoundException('资金往来记录不存在');
const { userId, importedFromId, effectiveDate, ...v } = row;
return { ...v, date: businessTime(effectiveDate) };
}
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
const v = transferInput.parse(body);
return this.change(r, id, v);
@@ -57,50 +76,7 @@ export class TransfersController {
}
private async change(r: UserRequest, id: string, v?: ReturnType<typeof transferInput.parse>) {
return this.db.serial(async (tx) => {
const row = await tx.transfer.findFirst({
where: {
id,
userId: r.userId,
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
},
include: { source: true, target: true },
});
if (!row) throw new NotFoundException('资金往来记录不存在');
if (row.source.archived || row.target.archived)
throw new ConflictException('请先恢复归档项目');
const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]);
if (v) {
if (
v.sourceId !== row.sourceId ||
v.targetId !== row.targetId ||
v.operation !== row.operation
)
throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建');
if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const effectiveDate = toBusinessDate(v.date);
await tx.transfer.update({
where: { id },
data: {
amount: v.amount,
received: v.received,
fee: v.fee,
notes: v.notes,
effectiveDate,
},
});
await tx.revision.updateMany({
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
data: { effectiveDate, notes: v.notes },
});
} else {
await tx.transfer.delete({ where: { id } });
await tx.revision.deleteMany({
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
});
}
await replay();
return { ok: true };
return changeMovement(tx, r, id, v);
});
}
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
@@ -108,6 +84,52 @@ export class TransfersController {
return this.db.serial((tx) => executeMovement(tx, r, v));
}
}
export async function changeMovement(
tx: Prisma.TransactionClient,
r: Pick<UserRequest, 'userId' | 'revealed'>,
id: string,
v?: ReturnType<typeof transferInput.parse>,
) {
const row = await tx.transfer.findFirst({
where: {
id,
userId: r.userId,
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
},
include: { source: true, target: true },
});
if (!row) throw new NotFoundException('资金往来记录不存在');
if (row.source.archived || row.target.archived) throw new ConflictException('请先恢复归档项目');
const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]);
if (v) {
if (v.sourceId !== row.sourceId || v.targetId !== row.targetId || v.operation !== row.operation)
throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建');
if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const effectiveDate = toBusinessDate(v.date);
await tx.transfer.update({
where: { id },
data: {
amount: v.amount,
received: v.received,
fee: v.fee,
notes: v.notes,
effectiveDate,
},
});
await tx.revision.updateMany({
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
data: { effectiveDate, notes: v.notes },
});
} else {
await tx.transfer.delete({ where: { id } });
await tx.revision.deleteMany({
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
});
}
await replay();
return { ok: true };
}
export async function executeMovement(
tx: Prisma.TransactionClient,
r: Pick<UserRequest, 'userId' | 'revealed'>,
@@ -166,14 +188,14 @@ export async function executeMovement(
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
)
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
if (accounts.some((p) => p.revisions[0] && +p.revisions[0].effectiveDate > +when))
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee);
const before = new Decimal(source.revisions[0].amount.toString());
const before = new Decimal(source.revisions[0]?.amount.toString() || '0');
const sourceAfter = before.plus(deltas.source);
const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target);
const after = new Decimal(target.revisions[0]?.amount.toString() || '0').plus(deltas.target);
if (target.kind === 'debt' && after.isNegative())
throw new BadRequestException('收款或还款不能超过剩余债务');
if (after.abs().gte('10000000000000000') || sourceAfter.abs().gte('10000000000000000'))
+9
View File
@@ -173,3 +173,12 @@ export const pairedReasons = [
'loan_collect',
'loan_repay',
];
export const credentialChange = z
.object({
currentPassword: credentials.shape.password,
username: credentials.shape.username.optional(),
newPassword: credentials.shape.password.optional(),
})
.strict()
.refine((v) => !!v.username || !!v.newPassword, '请填写新的账号或密码');
+18 -1
View File
@@ -119,7 +119,24 @@ test('record edits replay paired movements, expense deltas, anchors and calendar
await portfolio.deleteRevision(r, a.id, expense.id);
assert.equal(await balance(a.id), '190');
assert.equal((await calendar.day(r, '2026-09-03')).items.length, 0);
await assert.rejects(portfolio.deleteRevision(r, a.id, a.revisions[0].id));
await portfolio.deleteRevision(r, a.id, a.revisions[0].id);
assert.equal(await balance(a.id), '-10');
const restoredInitial = await db.revision.create({
data: {
positionId: a.id,
amount: '200',
effectiveDate: toBusinessDate('2026-09-01T00:00'),
reason: 'initial',
notes: '',
},
});
a.revisions[0].id = restoredInitial.id;
// Replay after restoring a baseline through the correction API.
await portfolio.correct(r, a.id, restoredInitial.id, {
amount: '200',
date: '2026-09-01T00:00',
notes: '',
});
assert.equal(await balance(a.id), '190');
// An observed absolute balance is an anchor, including after a moved transfer.
await portfolio.revise(r, a.id, {
+247
View File
@@ -0,0 +1,247 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { hash } from 'bcryptjs';
import { PrismaClient } from '@prisma/client';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('credentials rotate sessions; deleting paired and empty histories preserves ZIP recovery', async () => {
const db = new PrismaClient(),
ids: string[] = [];
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
method,
headers: {
Cookie: cookie,
Origin: origin,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return {
status: res.status,
data: await res.json(),
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
};
}
async function session(userId: string) {
const token = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(token).digest('hex'),
userId,
expiresAt: new Date(Date.now() + 3600000),
},
});
return 'wp_session=' + token;
}
async function user() {
const username = 'wp_fix_' + randomUUID(),
password = randomBytes(18).toString('hex');
const user = await db.user.create({
data: { username, passwordHash: await hash(password, 12) },
});
ids.push(user.id);
const cookie = await session(user.id);
return { id: user.id, username, password, cookie };
}
try {
const a = await user(),
b = await user();
const other = { cookie: await session(a.id) };
assert.equal(
(
await call('/auth/credentials', a.cookie, 'PATCH', {
username: b.username,
currentPassword: 'wrong-password',
})
).status,
403,
);
assert.equal(
(
await call('/auth/credentials', a.cookie, 'PATCH', {
username: b.username,
currentPassword: a.password,
})
).status,
409,
);
assert.equal((await call('/auth/me', other.cookie)).status, 200);
const username = 'wp_changed_' + randomUUID(),
password = randomBytes(18).toString('hex');
const changed = await call('/auth/credentials', a.cookie, 'PATCH', {
username,
currentPassword: a.password,
newPassword: password,
});
assert.equal(changed.status, 200);
a.cookie = changed.cookie;
assert.equal((await call('/auth/me', other.cookie)).status, 401);
assert.equal((await call('/auth/me', a.cookie)).data.username, username);
assert.equal(
(await call('/auth/login', '', 'POST', { username: a.username, password: a.password }))
.status,
401,
);
assert.equal(
(await call('/auth/login', '', 'POST', { username, password: a.password })).status,
401,
);
assert.equal((await call('/auth/login', '', 'POST', { username, password })).status, 201);
await db.session.updateMany({
where: { userId: a.id },
data: { revealUntil: new Date(Date.now() + 300000) },
});
assert.equal((await call('/auth/me', a.cookie)).data.revealed, true);
async function create(name: string, amount: string) {
const result = await call('/positions', a.cookie, 'POST', {
name,
amount,
kind: 'account',
side: 'asset',
category: 'bank',
currency: 'CNY',
date: '2026-09-01T00:00',
});
assert.equal(result.status, 201);
return result.data.id;
}
const sourceId = await create('source', '100'),
targetId = await create('target', '0'),
emptyId = await create('empty', '1');
const rev = (id: string) =>
db.revision.findFirstOrThrow({ where: { positionId: id }, orderBy: { sequence: 'asc' } });
const remove = (id: string, revisionId: string, cookie = a.cookie) =>
call('/positions/' + id + '/revisions/' + revisionId, cookie, 'DELETE');
assert.equal((await remove(emptyId, (await rev(emptyId)).id)).status, 200);
const movement = await call('/transfers', a.cookie, 'POST', {
sourceId,
targetId,
amount: '10',
received: '10',
fee: '0',
date: '2026-09-02T00:00',
});
assert.equal(movement.status, 201);
const pair = await db.transfer.findUniqueOrThrow({ where: { id: movement.data.id } });
assert.equal((await remove(sourceId, pair.sourceRevisionId, b.cookie)).status, 404);
assert.equal((await remove(sourceId, (await rev(sourceId)).id)).status, 200);
assert.equal((await remove(targetId, (await rev(targetId)).id)).status, 200);
assert.equal(
(await call('/transfers/revision/' + pair.sourceRevisionId, a.cookie)).data.id,
pair.id,
);
const download = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
assert.equal(download.status, 200);
assert.equal(download.headers.get('content-type')?.includes('application/zip'), true);
const bytes = Buffer.from(await download.arrayBuffer());
const backup: any = await readBackupZip(bytes);
assert.equal(backup.positions.find((p: any) => p.id === emptyId).revisions.length, 0);
assert.equal(backup.positions.find((p: any) => p.id === sourceId).revisions[0].amount, '-10');
const form = new FormData();
form.append('file', new Blob([bytes]), 'backup.zip');
const upload = await fetch(base + '/backup/upload', {
method: 'POST',
headers: { Cookie: b.cookie, Origin: origin },
body: form,
});
assert.equal(upload.status, 201);
const preview: any = await upload.json();
assert.equal(
(
await call('/backup/import-file', a.cookie, 'POST', {
token: preview.token,
confirmed: true,
})
).status,
400,
);
assert.equal(
(
await call('/backup/import-file', b.cookie, 'POST', {
token: preview.token,
confirmed: true,
})
).status,
201,
);
const restored = (await call('/positions', b.cookie)).data;
assert.equal(restored.find((p: any) => p.name === 'source').amount, '-10');
assert.equal(restored.find((p: any) => p.name === 'target').amount, '10');
assert.equal(restored.find((p: any) => p.name === 'empty').amount, '0');
await db.position.update({ where: { id: targetId }, data: { hidden: true } });
await call('/auth/lock', a.cookie, 'POST');
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 404);
assert.equal(await db.transfer.count({ where: { id: pair.id } }), 1);
await db.session.updateMany({
where: { userId: a.id },
data: { revealUntil: new Date(Date.now() + 300000) },
});
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 200);
assert.equal(await db.transfer.count({ where: { id: pair.id } }), 0);
assert.equal(
await db.revision.count({
where: { id: { in: [pair.sourceRevisionId, pair.targetRevisionId] } },
}),
0,
);
assert.equal((await call('/positions/' + sourceId, a.cookie)).data.amount, '0');
assert.equal((await call('/positions/' + targetId, a.cookie)).data.amount, '0');
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 404);
const minute = (delta = 0) =>
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
const plan = await call('/schedules', a.cookie, 'POST', {
name: 'Empty account expense',
operation: 'expense',
sourceId,
targetId: null,
amount: '2',
received: '0',
nextAt: minute(-60000),
intervalDays: 0,
notes: '',
});
assert.equal(plan.status, 201);
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 1);
const today = minute().slice(0, 10);
const calendar = (await call('/calendar?month=' + today.slice(0, 7), a.cookie)).data;
assert.equal(
calendar.items.find((v: any) => v.date === minute(-60000).slice(0, 10)).expense,
'2.00',
);
assert.equal(
(
await call('/transfers', a.cookie, 'POST', {
sourceId,
targetId,
amount: '3',
received: '3',
fee: '0',
date: minute(),
})
).status,
201,
);
const emptyAccountBackup = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
assert.equal(emptyAccountBackup.status, 200);
const emptyAccountData: any = await readBackupZip(
Buffer.from(await emptyAccountBackup.arrayBuffer()),
);
assert.equal(
emptyAccountData.positions.find((p: any) => p.id === sourceId).revisions.at(-1).amount,
'-5',
);
assert.equal((await call('/backup/preview', b.cookie, 'POST', emptyAccountData)).status, 409);
const fresh = await user();
assert.equal(
(await call('/backup/preview', fresh.cookie, 'POST', emptyAccountData)).status,
201,
);
} finally {
await db.user.deleteMany({ where: { id: { in: ids } } });
await db.$disconnect();
}
});
+201 -35
View File
@@ -258,6 +258,8 @@ export default function App() {
),
[rangeTo, setRangeTo] = useState(today()),
[grain, setGrain] = useState('day');
const [dataVersion, setDataVersion] = useState(0);
const previousDataVersion = useRef(0);
const viewKey = [page, selected, rangeFrom, rangeTo, grain, settingsSection].join('|');
const viewRef = useRef(viewKey);
viewRef.current = viewKey;
@@ -296,7 +298,7 @@ export default function App() {
clearAccount();
}
};
async function load(refreshUser = false, rangeOnly = false) {
async function load(refreshUser = false, rangeOnly = false, runDue = true) {
const session = sessionGeneration.current,
request = ++loadGeneration.current,
view = viewRef.current;
@@ -308,6 +310,8 @@ export default function App() {
try {
const s = refreshUser ? await api<User>('/auth/me') : userRef.current;
if (!active() || !s) return;
const visibilityChanged = !!s.revealed !== !!userRef.current?.revealed;
if (visibilityChanged) rangeOnly = false;
if (refreshUser) {
setUser(s);
setPage((current) => (s.hiddenMenus.includes(current) ? 'settings' : current));
@@ -315,7 +319,7 @@ export default function App() {
setPositions((rows) => rows.filter((p) => !p.hidden));
setHistoryRows([]);
setTransfers([]);
setOverview(null);
if (visibilityChanged) setOverview(null);
}
}
if (page === 'overview' && rangeOnly) {
@@ -346,7 +350,7 @@ export default function App() {
const rows = await api<Position[]>('/positions?kind=account');
if (active()) setPositions(rows);
} else if (['account', 'asset', 'debt'].includes(page)) {
if (page === 'account' && !rangeOnly) {
if (page === 'account' && !rangeOnly && runDue) {
const result = await api<{
executed: number;
errors: { message: string }[];
@@ -489,19 +493,21 @@ export default function App() {
const previousView = useRef('');
useEffect(() => {
if (!user) return;
const mutated = previousDataVersion.current !== dataVersion;
previousDataVersion.current = dataVersion;
const rangeOnly =
page === 'overview' && previousView.current.startsWith('overview|') && !!overview;
!mutated && page === 'overview' && previousView.current.startsWith('overview|') && !!overview;
previousView.current = viewKey;
if (!rangeOnly) setOverview(null);
setHistoryRows([]);
setHistoryCursor(null);
setTransfers([]);
setTransferCursor(null);
void load(false, rangeOnly);
void load(true, rangeOnly, !mutated);
return () => {
loadGeneration.current++;
};
}, [viewKey, !!user]);
}, [viewKey, !!user, dataVersion]);
useEffect(() => {
const session = sessionGeneration.current;
authCheck.current ||= api<User>('/auth/me');
@@ -598,7 +604,7 @@ export default function App() {
setPage(createdKind);
setSelected(result.id);
}
await load(true);
setDataVersion((value) => value + 1);
} catch (e) {
if (session === sessionGeneration.current) report(e);
} finally {
@@ -748,8 +754,15 @@ export default function App() {
);
async function correctHistory(h: History) {
if (h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')) {
setSelected(null);
setPage('history');
const session = sessionGeneration.current,
view = viewRef.current;
try {
const transfer = await api<Transfer>('/transfers/revision/' + h.id);
if (session === sessionGeneration.current && view === viewRef.current)
setModal({ kind: 'transfer-edit', transfer });
} catch (e) {
if (session === sessionGeneration.current && view === viewRef.current) report(e);
}
return;
}
const session = sessionGeneration.current,
@@ -763,11 +776,40 @@ export default function App() {
}
}
function deleteHistory(h: History) {
if (!window.confirm(tr('删除这条余额记录并重算后续余额?'))) return;
void act(
() => api('/positions/' + h.positionId + '/revisions/' + h.id, 'DELETE'),
tr('记录已删除,余额和日历已更新'),
);
setError('');
setModal({ kind: 'delete-record', h });
}
async function downloadBackup(forClear = false) {
const session = sessionGeneration.current;
setBusy(true);
setError('');
setSuccess('');
try {
const response = await fetch('/api/backup', { credentials: 'same-origin' });
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new ApiError(result.message || tr('备份下载失败'), response.status);
}
if (!response.headers.get('content-type')?.includes('application/zip'))
throw new Error(tr('备份文件格式错误'));
const blob = await response.blob();
if (!blob.size) throw new Error(tr('备份文件为空'));
if (session !== sessionGeneration.current) return;
const url = URL.createObjectURL(blob),
a = document.createElement('a');
a.href = url;
a.download = 'worthpath-' + today() + '.zip';
document.body.appendChild(a);
a.click();
a.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60000);
setSuccess(tr('备份已生成,请确认文件已保存'));
if (forClear) setClearStep(1);
} catch (e) {
if (session === sessionGeneration.current) report(e);
} finally {
if (session === sessionGeneration.current) setBusy(false);
}
}
const nav = [
['overview', LayoutDashboard],
@@ -1241,6 +1283,7 @@ export default function App() {
rows={historyRows}
correct={p.archived ? undefined : (h) => void correctHistory(h)}
remove={p.archived ? undefined : deleteHistory}
busy={busy}
/>
{historyCursor && (
<button disabled={loading} onClick={() => void more('history')}>
@@ -1427,11 +1470,8 @@ export default function App() {
className="text danger-text"
disabled={busy}
onClick={() => {
if (window.confirm(tr('删除这笔资金往来并重算双方余额?')))
void act(
() => api('/transfers/' + t.id, 'DELETE'),
tr('记录已删除,余额和日历已更新'),
);
setError('');
setModal({ kind: 'delete-record', transfer: t });
}}
>
{tr('删除')}
@@ -1456,6 +1496,7 @@ export default function App() {
rows={historyRows}
correct={(h) => void correctHistory(h)}
remove={deleteHistory}
busy={busy}
open={(id) => {
setSelected(id);
setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account');
@@ -1492,6 +1533,7 @@ export default function App() {
onChange={(e) => setSettingsSection(e.target.value)}
>
<option value="general">{tr('个人与菜单设置')}</option>
<option value="security">{tr('账号与密码')}</option>
<option value="icons">{tr('图标库')}</option>
<option value="rates">{tr('每日汇率')}</option>
<option value="backup">{tr('备份与恢复')}</option>
@@ -1506,6 +1548,77 @@ export default function App() {
<IconLibrary />
</section>
)}
{settingsSection === 'security' && (
<section className="panel">
<h2>{tr('账号与密码')}</h2>
<p className="muted">{tr('修改后其他登录会话将退出,当前会话保持登录。')}</p>
<form
key={user.username}
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget,
f = new FormData(form);
const username = String(f.get('username')).trim(),
newPassword = String(f.get('newPassword'));
if (newPassword !== String(f.get('confirmPassword'))) {
setError(tr('两次输入的新密码不一致'));
return;
}
void act(async () => {
const result = await api('/auth/credentials', 'PATCH', {
currentPassword: f.get('currentPassword'),
...(username !== user.username ? { username } : {}),
...(newPassword ? { newPassword } : {}),
});
form.reset();
return result;
}, tr('账号与密码已更新'));
}}
>
<Field label={tr('账号')}>
<input
name="username"
autoComplete="username"
required
minLength={3}
maxLength={64}
defaultValue={user.username}
/>
</Field>
<Field label={tr('当前密码')}>
<input
name="currentPassword"
type="password"
autoComplete="current-password"
required
minLength={10}
maxLength={72}
/>
</Field>
<Field label={tr('新密码(不修改请留空)')}>
<input
name="newPassword"
type="password"
autoComplete="new-password"
minLength={10}
maxLength={72}
/>
</Field>
<Field label={tr('确认新密码')}>
<input
name="confirmPassword"
type="password"
autoComplete="new-password"
minLength={10}
maxLength={72}
/>
</Field>
<button className="primary" disabled={busy}>
{tr('保存修改')}
</button>
</form>
</section>
)}
<div className="settings-grid">
<section className="panel" hidden={settingsSection !== 'general'}>
<div className="panel-title">
@@ -1707,15 +1820,14 @@ export default function App() {
)}
</p>
{clearStep === 0 ? (
<a
<button
className="secondary"
href="/api/backup"
download
onClick={() => setClearStep(1)}
disabled={busy}
onClick={() => void downloadBackup(true)}
>
<Download size={16} />
{tr('第一步:下载备份')}
</a>
</button>
) : clearStep === 1 ? (
<button
className="secondary"
@@ -1767,15 +1879,14 @@ export default function App() {
)}
</p>
<div className="actions">
<a
<button
className="secondary"
href="/api/backup"
download={'worthpath-' + today() + '.zip'}
onClick={() => setSuccess(tr('备份下载请求已发起,请检查浏览器下载列表'))}
disabled={busy}
onClick={() => void downloadBackup()}
>
<Download size={16} />
{tr('下载我的数据备份')}
</a>
</button>{' '}
<label className="secondary file-button">
<Upload size={16} />
{tr('选择备份并验证')}
@@ -1936,6 +2047,59 @@ export default function App() {
)}
</Modal>
)}
{modal?.kind === 'delete-record' && (
<Modal
title={tr('删除记录')}
close={() => {
if (!busy) setModal(null);
}}
>
<p>
{modal.h
? modal.h.name
: modal.transfer?.source.name + ' ↔ ' + modal.transfer?.target.name}
</p>
<p className="muted">{displayTime(modal.h?.time || modal.transfer!.date)}</p>
<p>
{tr(
modal.transfer ||
modal.h?.reason.startsWith('transfer_') ||
modal.h?.reason.startsWith('loan_')
? '这笔资金往来的双方记录将一起删除,并重算后续余额。'
: '这条记录将被删除,并重算后续余额。',
)}
</p>
<p className="danger-text">{tr('删除后无法撤销,请确认已保存所需备份。')}</p>
{error && (
<p role="alert" className="notice error">
{tr(error)}
</p>
)}
<div className="modal-actions clear-confirm-actions">
<button autoFocus disabled={busy} onClick={() => setModal(null)}>
{tr('取消')}
</button>
<button
className="danger"
disabled={busy}
onClick={() =>
void act(
() =>
api(
modal.h
? '/positions/' + modal.h.positionId + '/revisions/' + modal.h.id
: '/transfers/' + modal.transfer!.id,
'DELETE',
),
tr('记录已删除,余额和日历已更新'),
)
}
>
{busy ? tr('正在处理…') : tr('确认删除')}
</button>
</div>
</Modal>
)}
{modal?.kind === 'transfer-edit' && modal.transfer && (
<Modal
title={tr('修改资金往来')}
@@ -2005,7 +2169,7 @@ export default function App() {
</form>
</Modal>
)}
{modal && modal.kind !== 'transfer-edit' && (
{modal && !['transfer-edit', 'delete-record'].includes(modal.kind) && (
<Modal
title={
modal.kind === 'debt-payment'
@@ -2356,11 +2520,13 @@ function HistoryTable({
open,
correct,
remove,
busy = false,
}: {
rows: History[];
open?: (id: string) => void;
correct?: (h: History) => void;
remove?: (h: History) => void;
busy?: boolean;
}) {
return rows.length ? (
<div className="table-wrap">
@@ -2448,16 +2614,16 @@ function HistoryTable({
</td>
<td className="notes-cell">{h.notes || '—'}</td>
{correct && (
<td>
<button className="text" onClick={() => correct(h)}>
<td className="record-actions">
<button className="text" disabled={busy} onClick={() => correct(h)}>
{tr(
h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')
? '查看资金往来'
? '修改资金往来'
: '更正',
)}
</button>
{remove && !h.reason.startsWith('transfer_') && !h.reason.startsWith('loan_') && (
<button className="text danger-text" onClick={() => remove(h)}>
{remove && (
<button className="text danger-text" disabled={busy} onClick={() => remove(h)}>
{tr('删除')}
</button>
)}
+20 -1
View File
@@ -444,5 +444,24 @@
"资金往来记录不存在": "Movement not found.",
"修改记录不能更换账户或操作类型,请删除后重新创建": "To change accounts or the operation type, delete the movement and create it again.",
"修改": "Edit",
"统计粒度": "Time interval"
"统计粒度": "Time interval",
"删除记录": "Delete record",
"确认删除": "Confirm deletion",
"这笔资金往来的双方记录将一起删除,并重算后续余额。": "Both sides of this movement will be deleted and later balances recalculated.",
"这条记录将被删除,并重算后续余额。": "This record will be deleted and later balances recalculated.",
"删除后无法撤销,请确认已保存所需备份。": "Deletion cannot be undone. Make sure you have saved any backup you need.",
"账号与密码": "Account and password",
"修改后其他登录会话将退出,当前会话保持登录。": "Other sessions will be signed out. This session will stay signed in.",
"当前密码": "Current password",
"新密码(不修改请留空)": "New password (leave blank to keep it)",
"确认新密码": "Confirm new password",
"两次输入的新密码不一致": "The new passwords do not match.",
"账号与密码已更新": "Account and password updated.",
"当前密码错误": "Incorrect current password.",
"请填写新的账号或密码": "Enter a new username or password.",
"账号已变更,请重新登录后操作": "Your account has changed. Sign in again to continue.",
"备份下载失败": "Backup download failed.",
"备份文件格式错误": "Invalid backup file format.",
"备份文件为空": "The backup file is empty.",
"备份已生成,请确认文件已保存": "Backup generated. Please confirm the file has been saved."
}
+20 -1
View File
@@ -444,5 +444,24 @@
"资金往来记录不存在": "資金往來記錄不存在",
"修改记录不能更换账户或操作类型,请删除后重新创建": "修改記錄不能更換賬戶或操作類型,請刪除後重新建立",
"修改": "修改",
"统计粒度": "統計粒度"
"统计粒度": "統計粒度",
"删除记录": "刪除記錄",
"确认删除": "確認刪除",
"这笔资金往来的双方记录将一起删除,并重算后续余额。": "這筆資金往來的雙方記錄將一起刪除,並重算後續餘額。",
"这条记录将被删除,并重算后续余额。": "這條記錄將被刪除,並重算後續餘額。",
"删除后无法撤销,请确认已保存所需备份。": "刪除後無法撤銷,請確認已保存所需備份。",
"账号与密码": "賬號與密碼",
"修改后其他登录会话将退出,当前会话保持登录。": "修改後其他登入會話將退出,目前會話保持登入。",
"当前密码": "目前密碼",
"新密码(不修改请留空)": "新密碼(不修改請留空)",
"确认新密码": "確認新密碼",
"两次输入的新密码不一致": "兩次輸入的新密碼不一致",
"账号与密码已更新": "賬號與密碼已更新",
"当前密码错误": "目前密碼錯誤",
"请填写新的账号或密码": "請填寫新的賬號或密碼",
"账号已变更,请重新登录后操作": "賬號已變更,請重新登入後操作",
"备份下载失败": "備份下載失敗",
"备份文件格式错误": "備份檔案格式錯誤",
"备份文件为空": "備份檔案為空",
"备份已生成,请确认文件已保存": "備份已產生,請確認檔案已保存"
}
+7
View File
@@ -1487,3 +1487,10 @@ textarea,
font-size: 10px;
line-height: 1.5;
}
.record-actions {
white-space: nowrap;
}
.record-actions button + button {
margin-left: 16px;
}
+4
View File
@@ -62,3 +62,7 @@
类型检查、生产构建、28 项单元测试通过。新增真实数据库记录编辑专项涵盖双边余额、历史重放、计划支出更正、日历联动、精确金额、债务回滚及权限边界。13 个迁移已应用;9 张表与 88 个字段均有注释;内置图标扩充至 34 项。浏览器虚构数据检查与限制见 [更新说明](update-2026-10-02.md)。
最终完整真实数据库回归 10 项全部通过;连续测试触发限流后的重启复验与新增记录专项见更新说明。
## 2026-10-03 账号、删除与备份修复
28 项单元测试、11 项真实 MySQL 集成检查、前后端类型检查和生产构建通过。新增账号凭据变更与会话撤销、配对历史直接删除、零历史项目及 ZIP 上传恢复验证;浏览器确认删除弹窗和修改后的自动刷新。详情与验证边界见 [更新说明](update-2026-10-03.md)。无数据库结构变更。
+3 -1
View File
@@ -42,4 +42,6 @@ Transfer 归属于 User,关联转出/转入 Position(启用资产账户)
User.hiddenMenus 保存受枚举校验的菜单键,settings 不可隐藏;User.showNotes 默认 true,仅控制客户端显示,财务备注和导出保持完整。ZIP v5 新增 transfers.json,追加恢复时映射账户及配对历史 ID,空空间恢复显示偏好,已有空间保留当前设置。
2026-10-02 记录编辑更新:允许更正转账之前的独立余额历史。`replay.ts` 重放涉及项目的历史,绝对余额保留为锚点,配对资金往来与定时支出按增量更新;整笔修改/删除与两侧余额在同一事务提交。单侧配对历史仍禁止直接更正/删除。日历读取保存后的账户历史重算。完整边界见 [更新说明](update-2026-10-02.md)。
2026-10-02 记录编辑更新:允许更正转账之前的独立余额历史。`replay.ts` 重放涉及项目的历史,绝对余额保留为锚点,配对资金往来与定时支出按增量更新;整笔修改/删除与两侧余额在同一事务提交。单侧配对历史禁止独立更正;2026-10-03 更新后,从任一侧删除会原子删除完整配对记录。日历读取保存后的账户历史重算。完整边界见 [更新说明](update-2026-10-02.md)。
2026-10-03 修复:历史重放允许没有绝对余额锚点时从零计算增量,空历史项目金额为零;备份校验、转账和计划支出使用相同规则。账号凭据变更锁定用户行、验证当前密码、更新凭据并在同一事务撤销旧会话和创建新会话;无需新增字段。/auth/me 返回完整隐藏授权状态和会话时间。前端 mutation 更新数据版本,以当前页面重新读取数据,旧视图请求通过 generation 检查丢弃。见 [更新说明](update-2026-10-03.md)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+24
View File
@@ -0,0 +1,24 @@
# 2026-10-03 账号、删除与备份更新
## 已完成
- 设置分类新增“账号与密码”。可单独修改账号、密码或同时修改,须验证当前密码;新密码须二次输入。服务端沿用账号格式、密码长度和认证限流,重复账号返回冲突;在事务中撤销全部旧会话并重新签发当前会话,财务数据和个人设置保留。
- 余额历史与资金往来使用统一删除弹窗,显示项目和时间、删除范围及不可撤销提示,默认聚焦取消。历史表中的配对记录支持直接修改完整资金往来,或一次删除双侧历史与资金往来;双方权限检查、删除和余额重算在同一事务内完成。
- 删除初始余额后,增量记录从零重算;删空历史的项目保留,余额为零,可以继续转账和执行计划支出。债务不能重算为负数,相关非法操作仍整体回滚;隐藏项目须先解锁,归档项目须先恢复。
- 修复 /auth/me 缺少隐藏授权和活动时间造成的重复刷新。修改后通过统一数据版本触发当前视图重新加载,刷新账户金额、历史和日历;避免创建项目切页时旧请求覆盖新页面,避免删除后顺带再次执行到期计划。范围切换仍只更新趋势。
- 备份支持零条历史的项目,以及以配对资金记录开头的历史。旧 JSON 上传兼容 UTF-8 BOM;导出事务上限调整为 5 分钟;备份内容摘要按计划 ID 排序,避免顺序造成假冲突。
- 备份下载先检查响应状态、ZIP 类型和非空内容,再发起保存;错误展示在页面中。清空流程仅在备份响应成功后进入保存确认步骤,仍须用户确认文件已保存。
## 验证
- 前后端 TypeScript 检查及生产构建通过;28 项单元测试和完整 11 项真实 MySQL 集成检查通过。
- 新增专项验证错误密码、重复账号、旧账号/旧密码失效、旧会话撤销、当前会话继续有效、隐藏授权状态、跨用户及隐藏配对删除拒绝、整笔删除、重复删除、零历史 ZIP 下载、上传预览、会话令牌隔离和追加恢复。
- 补充专项覆盖删空账户后执行支出计划、日历首条支出统计、继续转账和备份校验。
- 浏览器使用临时模拟账号验证账号设置表单、删除弹窗、余额更正后的详情和历史自动刷新,以及备份生成成功提示。浏览器自动化未能取得内置浏览器的下载文件事件,文件内容和上传/恢复使用真实 HTTP 集成测试验证;不能据页面提示证明用户已保存文件。
- 本轮临时账号和模拟财务数据已清理。删除最终提交及真实账号密码修改通过 API 临时账号测试;未操作实际用户的这些数据。
## 数据库与交付
复用现有 User、Session、Position、Revision、Transfer、Schedule 字段,无新增表或字段,无迁移;已有表与字段注释保持。未执行数据库 reset、db push 或实际用户数据清空。保留用户原有 vite.config.ts 修改,未纳入本轮功能提交。仅本地 Git 提交,不 push。
![删除确认弹窗](delete-record-preview.png)
+6
View File
@@ -28,9 +28,15 @@
- ~~优化资产总览的UI,看着太乱了~~ — 已完成并验证:2026-10-02 18:34
- ~~余额和转账记录都可以删除,修改(收支日历要与记录联动)~~ — 已完成并验证:2026-10-02 18:34
- ~~图标新增ZA BANK、大象银行、微众银行等~~ — 已完成并验证:2026-10-02 18:43
- ~~添加修改账号和密码的功能~~ — 已完成并验证:2026-10-03 11:22
- ~~删除记录有问题,有时候无法删除,UI也不对~~ — 已完成并验证:2026-10-03 11:22
- ~~删除记录后部分UI需要手动刷新~~ — 已完成并验证:2026-10-03 11:22
- ~~备份功能出现问题~~ — 已完成并验证:2026-10-03 11:22
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。
数据库验收完成:2026-10-02 17:54。12 个迁移状态最新,31 个内置图标已初始化;28 项单元测试、9 项真实数据库集成检查通过。
本轮补充验收完成:2026-10-02 18:44。13 个迁移状态最新,34 个内置图标已初始化;28 项单元测试、10 项真实数据库集成检查通过,9 张表与 88 个字段注释已核验。重新检查文档,无新增未完成要求。
本轮验收完成:2026-10-03 11:22。28 项单元测试、11 项真实 MySQL 集成检查、前后端类型检查与生产构建通过;未新增数据库表或字段。账号、删除与备份更新详情见 [更新说明](docs/update-2026-10-03.md)。重新检查文档,无新增未完成要求。