189 lines
6.1 KiB
TypeScript
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 })),
|
|
}));
|
|
}
|
|
}
|