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

189 lines
6.1 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { DatabaseService } from '../db/database.service';
import { WritableDbService } from '../db/writable-db.service';
import { EDITABLE_MAP } from '../contrib/editable-fields';
import { QueryModelsDto, SORTABLE_FIELDS } from './query-models.dto';
export interface PagedResult<T> {
total: number;
page: number;
pageSize: number;
items: T[];
}
@Injectable()
export class ModelsService {
constructor(
private readonly database: DatabaseService,
private readonly wdb: WritableDbService,
) {}
/** 构造 WHERE 子句与绑定参数(参数化,避免注入)。*/
private buildWhere(q: QueryModelsDto): { clause: string; params: any[] } {
const conds: string[] = [];
const params: any[] = [];
if (q.category) {
if (/^\d+$/.test(q.category)) {
conds.push('m.category_id = ?');
params.push(Number(q.category));
} else {
conds.push('c.name LIKE ?');
params.push(`%${q.category}%`);
}
}
if (q.yearFrom != null) {
conds.push('m.first_year >= ?');
params.push(q.yearFrom);
}
if (q.yearTo != null) {
conds.push('m.first_year <= ?');
params.push(q.yearTo);
}
if (q.speedMin != null) {
conds.push('m.max_speed_value >= ?');
params.push(q.speedMin);
}
if (q.speedMax != null) {
conds.push('m.max_speed_value <= ?');
params.push(q.speedMax);
}
if (q.manufacturer) {
conds.push('m.manufacturer LIKE ?');
params.push(`%${q.manufacturer}%`);
}
if (q.country) {
conds.push('m.country_type = ?');
params.push(q.country);
}
if (q.status) {
conds.push('m.status = ?');
params.push(q.status);
}
if (q.q) {
conds.push('(m.model_code LIKE ? OR m.full_name LIKE ? OR m.series LIKE ?)');
params.push(`%${q.q}%`, `%${q.q}%`, `%${q.q}%`);
}
const clause = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
return { clause, params };
}
list(q: QueryModelsDto): PagedResult<any> {
const { clause, params } = this.buildWhere(q);
const db = this.database.db;
const total = (
db
.prepare(
`SELECT COUNT(*) AS n FROM model m
JOIN category c ON c.id = m.category_id ${clause}`,
)
.get(...params) as { n: number }
).n;
const sort = (SORTABLE_FIELDS as readonly string[]).includes(q.sort)
? q.sort
: 'first_year';
const order = q.order === 'desc' ? 'DESC' : 'ASC';
const offset = (q.page - 1) * q.pageSize;
const items = db
.prepare(
`SELECT m.id, m.model_code, m.full_name, m.series, m.manufacturer,
m.country, m.country_type, m.first_year, m.last_year, m.status,
m.usage, m.max_speed_value, m.max_speed_unit,
m.weight_value, m.weight_unit, m.axle_arrangement,
c.name AS category, c.subcat
FROM model m JOIN category c ON c.id = m.category_id
${clause}
ORDER BY (m.${sort} IS NULL), m.${sort} ${order}, m.id ASC
LIMIT ? OFFSET ?`,
)
.all(...params, q.pageSize, offset) as any[];
// 附加共享图库封面(每个车型最早一张照片)
if (items.length) {
const ids = items.map((i) => i.id);
const ph = ids.map(() => '?').join(',');
const covers = this.wdb.db
.prepare(
`SELECT p.model_id, p.filename FROM photos p
WHERE p.status='confirmed' AND p.model_id IN (${ph})
AND p.id = (SELECT p2.id FROM photos p2
WHERE p2.model_id = p.model_id AND p2.status='confirmed'
ORDER BY p2.featured DESC, p2.id ASC LIMIT 1)`,
)
.all(...ids) as { model_id: number; filename: string }[];
const map = new Map(covers.map((c) => [c.model_id, c.filename]));
for (const it of items) {
const f = map.get(it.id);
it.cover_url = f ? `/uploads/${f}` : null;
}
}
return { total, page: q.page, pageSize: q.pageSize, items };
}
getById(id: number): any {
const db = this.database.db;
const row: any = db
.prepare(
`SELECT m.*, c.name AS category, c.subcat
FROM model m JOIN category c ON c.id = m.category_id
WHERE m.id = ?`,
)
.get(id);
if (!row) {
throw new NotFoundException(`车型 ${id} 不存在`);
}
if (row.raw_json) {
try {
row.raw = JSON.parse(row.raw_json);
} catch {
row.raw = {};
}
}
// 叠加众包字段覆盖(已审核通过的编辑)
const overrides = this.wdb.db
.prepare('SELECT field, value FROM model_overrides WHERE model_id = ?')
.all(id) as { field: string; value: string }[];
if (overrides.length) {
row.overridden = [];
for (const o of overrides) {
const def = EDITABLE_MAP.get(o.field);
row[o.field] =
def?.type === 'int' ? (o.value === '' ? null : Number(o.value)) : o.value;
row.overridden.push(o.field);
}
}
return row;
}
/** 技术族谱:按分类→系列聚合现有车型(series 字段)。*/
families(category?: string) {
const rows = this.database.db
.prepare(
`SELECT m.id, m.model_code, m.series, m.first_year, m.last_year,
m.country_type, m.max_speed_value, m.max_speed_unit,
c.name AS category
FROM model m JOIN category c ON c.id = m.category_id
${category ? 'WHERE c.name = ?' : ''}
ORDER BY c.name, m.series, (m.first_year IS NULL), m.first_year`,
)
.all(...(category ? [category] : [])) as any[];
const cats = new Map<string, Map<string, any[]>>();
for (const r of rows) {
if (!cats.has(r.category)) cats.set(r.category, new Map());
const series = r.series || '(未归类)';
const sm = cats.get(r.category)!;
if (!sm.has(series)) sm.set(series, []);
sm.get(series)!.push(r);
}
return [...cats.entries()].map(([cat, sm]) => ({
category: cat,
series: [...sm.entries()].map(([name, models]) => ({ name, models })),
}));
}
}