Files
Train/apps/api/src/contrib/revisions.service.ts
T
2026-06-16 00:55:20 +08:00

201 lines
6.9 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DatabaseService } from '../db/database.service';
import { WritableDbService } from '../db/writable-db.service';
import { ROLE_RANK, type JwtPayload } from '../auth/roles';
import { validateField, EDITABLE_FIELDS } from './editable-fields';
const POINTS_PER_REVISION = 5;
@Injectable()
export class RevisionsService {
constructor(
private readonly ro: DatabaseService,
private readonly wdb: WritableDbService,
) {}
/** 某车型字段的当前生效值(覆盖优先于基础数据),字符串化。*/
private effectiveValue(modelId: number, field: string, baseRow: any): string {
const ov = this.wdb.db
.prepare('SELECT value FROM model_overrides WHERE model_id = ? AND field = ?')
.get(modelId, field) as { value: string } | undefined;
if (ov) return ov.value ?? '';
const v = baseRow?.[field];
return v == null ? '' : String(v);
}
private baseRow(modelId: number): any {
const row = this.ro.db.prepare('SELECT * FROM model WHERE id = ?').get(modelId);
if (!row) throw new NotFoundException(`车型 ${modelId} 不存在`);
return row;
}
/** 提交编辑:生成修订。信任用户及以上自动通过并应用。*/
submitEdit(
modelId: number,
user: JwtPayload,
changes: Record<string, unknown>,
note = '',
) {
const base = this.baseRow(modelId);
const entries = Object.entries(changes || {});
if (entries.length === 0) throw new BadRequestException('没有任何改动');
const diffs: { field: string; oldValue: string; newValue: string }[] = [];
for (const [field, raw] of entries) {
const newValue = validateField(field, raw);
const oldValue = this.effectiveValue(modelId, field, base);
if (newValue !== oldValue) diffs.push({ field, oldValue, newValue });
}
if (diffs.length === 0) throw new BadRequestException('与当前值相同,无需提交');
const autoApprove = ROLE_RANK[user.role] >= ROLE_RANK.trusted;
const db = this.wdb.db;
const tx = db.transaction(() => {
const info = db
.prepare(
`INSERT INTO revisions (model_id, author_id, status, note, reviewed_by, reviewed_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
modelId,
user.sub,
autoApprove ? 'approved' : 'pending',
note,
autoApprove ? user.sub : null,
autoApprove ? new Date().toISOString() : null,
);
const revId = Number(info.lastInsertRowid);
const ins = db.prepare(
`INSERT INTO revision_changes (revision_id, field, old_value, new_value)
VALUES (?, ?, ?, ?)`,
);
for (const d of diffs) ins.run(revId, d.field, d.oldValue, d.newValue);
if (autoApprove) {
this.applyChanges(modelId, diffs, revId);
this.award(user.sub);
}
return revId;
});
const revId = tx();
return this.getRevision(revId);
}
private applyChanges(
modelId: number,
diffs: { field: string; newValue: string }[],
revId: number,
) {
const up = this.wdb.db.prepare(
`INSERT INTO model_overrides (model_id, field, value, revision_id, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(model_id, field) DO UPDATE SET
value = excluded.value, revision_id = excluded.revision_id, updated_at = datetime('now')`,
);
for (const d of diffs) up.run(modelId, d.field, d.newValue, revId);
}
private award(userId: number, points = POINTS_PER_REVISION) {
this.wdb.db
.prepare('UPDATE users SET contribution_points = contribution_points + ? WHERE id = ?')
.run(points, userId);
}
approve(revId: number, reviewer: JwtPayload) {
const db = this.wdb.db;
const rev: any = db.prepare('SELECT * FROM revisions WHERE id = ?').get(revId);
if (!rev) throw new NotFoundException('修订不存在');
if (rev.status !== 'pending') throw new BadRequestException('该修订已处理');
const changes = db
.prepare('SELECT field, new_value AS newValue FROM revision_changes WHERE revision_id = ?')
.all(revId) as { field: string; newValue: string }[];
const tx = db.transaction(() => {
db.prepare(
`UPDATE revisions SET status='approved', reviewed_by=?, reviewed_at=datetime('now') WHERE id=?`,
).run(reviewer.sub, revId);
this.applyChanges(rev.model_id, changes, revId);
this.award(rev.author_id);
});
tx();
return this.getRevision(revId);
}
reject(revId: number, reviewer: JwtPayload) {
const db = this.wdb.db;
const rev: any = db.prepare('SELECT * FROM revisions WHERE id = ?').get(revId);
if (!rev) throw new NotFoundException('修订不存在');
if (rev.status !== 'pending') throw new BadRequestException('该修订已处理');
db.prepare(
`UPDATE revisions SET status='rejected', reviewed_by=?, reviewed_at=datetime('now') WHERE id=?`,
).run(reviewer.sub, revId);
return this.getRevision(revId);
}
getRevision(revId: number) {
const db = this.wdb.db;
const rev: any = db
.prepare(
`SELECT r.*, u.display_name AS author_name
FROM revisions r JOIN users u ON u.id = r.author_id WHERE r.id = ?`,
)
.get(revId);
if (!rev) throw new NotFoundException('修订不存在');
rev.changes = db
.prepare('SELECT field, old_value, new_value FROM revision_changes WHERE revision_id = ?')
.all(revId);
return rev;
}
listForModel(modelId: number) {
const db = this.wdb.db;
const revs = db
.prepare(
`SELECT r.*, u.display_name AS author_name
FROM revisions r JOIN users u ON u.id = r.author_id
WHERE r.model_id = ? ORDER BY r.created_at DESC, r.id DESC`,
)
.all(modelId) as any[];
const stmt = db.prepare(
'SELECT field, old_value, new_value FROM revision_changes WHERE revision_id = ?',
);
for (const r of revs) r.changes = stmt.all(r.id);
return revs;
}
listPending() {
const db = this.wdb.db;
const revs = db
.prepare(
`SELECT r.*, u.display_name AS author_name
FROM revisions r JOIN users u ON u.id = r.author_id
WHERE r.status = 'pending' ORDER BY r.created_at ASC`,
)
.all() as any[];
const stmt = db.prepare(
'SELECT field, old_value, new_value FROM revision_changes WHERE revision_id = ?',
);
for (const r of revs) r.changes = stmt.all(r.id);
return revs;
}
/** 供 ModelsService 合并:返回 model 的字段覆盖 map。*/
overridesFor(modelId: number): Record<string, string> {
const rows = this.wdb.db
.prepare('SELECT field, value FROM model_overrides WHERE model_id = ?')
.all(modelId) as { field: string; value: string }[];
const out: Record<string, string> = {};
for (const r of rows) out[r.field] = r.value;
return out;
}
editableFields() {
return EDITABLE_FIELDS;
}
}