init: AI培训与智能巡检系统

This commit is contained in:
selfrelease
2026-06-16 00:55:20 +08:00
commit c55598494b
201 changed files with 53131 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# 复制为 .env 并填入你的密钥(AI 识车需要)
# 通义千问 DashScope API Keyhttps://dashscope.console.aliyun.com/
DASHSCOPE_API_KEY=
# 可选:视觉模型,默认 qwen-vl-plus(也可用 qwen-vl-max
# DASHSCOPE_VL_MODEL=qwen-vl-plus
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
coverage/
*.log
.env
+39
View File
@@ -0,0 +1,39 @@
// 简易性能基准 — 对应 T-1.8 性能验收。
// 用法:先启动 APInode dist/main.js),再 `node bench.mjs`
const BASE = process.env.BENCH_BASE || 'http://127.0.0.1:3001';
const N = Number(process.env.BENCH_N || 200);
function pct(arr, p) {
const s = [...arr].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
}
async function bench(name, path, threshold) {
const lat = [];
// 预热
for (let i = 0; i < 5; i++) await fetch(`${BASE}${path}`);
for (let i = 0; i < N; i++) {
const t = performance.now();
const res = await fetch(`${BASE}${path}`);
await res.text();
lat.push(performance.now() - t);
}
const p50 = pct(lat, 50);
const p95 = pct(lat, 95);
const ok = p95 < threshold;
console.log(
`${ok ? '✓' : '✗'} ${name.padEnd(16)} p50=${p50.toFixed(1)}ms ` +
`p95=${p95.toFixed(1)}ms (阈值 ${threshold}ms)`,
);
return ok;
}
const results = [];
results.push(await bench('list', '/api/models?pageSize=24', 1500));
results.push(await bench('list+filter', '/api/models?category=动车组&sort=max_speed_value&order=desc', 1500));
results.push(await bench('detail', '/api/models/1', 1500));
results.push(await bench('search', '/api/search?q=HXD', 500));
const allOk = results.every(Boolean);
console.log(`\n基准结果:${allOk ? '全部达标 ✓' : '存在未达标项 ✗'}(每项 ${N} 次请求)`);
process.exit(allOk ? 0 : 1);
+7
View File
@@ -0,0 +1,7 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+8576
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@train/api",
"version": "0.1.0",
"description": "中国机车图鉴 后端 API (NestJS)",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main.js",
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"fetch-images": "node scripts/fetch-images.mjs"
},
"dependencies": {
"@nestjs/common": "^10.4.4",
"@nestjs/core": "^10.4.4",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^10.4.4",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^11.3.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.4",
"@nestjs/testing": "^10.4.4",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.11",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/multer": "^2.1.0",
"@types/node": "^22.7.4",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.2"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+255
View File
@@ -0,0 +1,255 @@
/**
* 批量取图脚本(在有网络的本机运行):
* 按 Wikidata P18 → Commons 同名分类 → Commons 关键词搜索 的顺序,
* 为每个车型下载若干张真实照片到「本地共享图库」(uploads + photos 表),
* 并记录署名(作者/许可证/来源)。下载后应用即从本地提供图片,运行时不再访问 Commons。
*
* 用法(在 apps/api 目录):
* node scripts/fetch-images.mjs # 全部缺图车型,每个 1 张
* node scripts/fetch-images.mjs --per 3 # 每个车型最多 3 张
* node scripts/fetch-images.mjs --limit 20 # 仅处理前 20 个缺图车型
* node scripts/fetch-images.mjs --category 电力机车
* node scripts/fetch-images.mjs --dry # 只检索不下载不入库
*
* 所有下载图片一律入库为「候选(candidate)」,进入管理员「候选审图」队列,
* 由人工确认后才会作为封面/图册展示(候选 + 人工确认 双闸门,保证准确与合规)。
*
* 注意:需联网访问 wikidata.org / commons.wikimedia.org(自由授权,已记录署名)。
* 下载完成后图片保存在本地 app/data/uploads/,库表记录在 app/data/app.db。
*/
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { writeFile } from 'fs/promises';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { randomBytes } from 'crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..', '..'); // Train/
const MACHINES_DB = join(ROOT, 'app', 'data', 'machines.db');
const APP_DB = process.env.APP_DB_PATH || join(ROOT, 'app', 'data', 'app.db');
const UPLOAD_DIR = process.env.UPLOAD_DIR || join(ROOT, 'app', 'data', 'uploads');
const UA = 'ChinaLocoAtlas/0.1 (image fetcher; contact admin)';
const args = process.argv.slice(2);
const opt = (k) => {
const i = args.indexOf(k);
return i >= 0 ? args[i + 1] : undefined;
};
const LIMIT = opt('--limit') ? Number(opt('--limit')) : Infinity;
const PER = opt('--per') ? Math.max(1, Number(opt('--per'))) : 1;
const CATEGORY = opt('--category');
const DRY = args.includes('--dry');
const STATUS = 'candidate'; // 一律入候选,交管理员「候选审图」确认
const CAT_HINT = {
蒸汽机车: 'steam locomotive',
电力机车: 'electric locomotive',
内燃机车: 'diesel locomotive',
动车组: 'EMU',
客车: 'passenger car',
货车: 'freight car',
检测车: 'inspection car',
旅游列车: 'tourist train',
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const latin = (s) => (s.match(/[A-Za-z0-9-]+/g) || []).join('');
const strip = (h) => (h || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
const isImg = (s) => /\.(jpe?g|png)$/i.test(s || '');
async function api(url) {
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
/** 从 Commons File:xxx 取缩略图 url + 署名。*/
async function commonsFileInfo(title) {
const u =
'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*' +
`&titles=${encodeURIComponent(title)}&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=1280`;
const d = await api(u);
const pages = d?.query?.pages || {};
const p = Object.values(pages)[0];
const ii = p?.imageinfo?.[0];
if (!ii?.thumburl) return null;
const em = ii.extmetadata || {};
return {
url: ii.thumburl,
descriptionUrl: ii.descriptionurl || '',
author: strip(em.Artist?.value) || '未署名',
license: strip(em.LicenseShortName?.value) || '见来源',
};
}
/** 1) Wikidata P18 主图(最多 1 张,最准)。*/
async function viaWikidata(model) {
const q = `${latin(model.model_code) || model.model_code} ${CAT_HINT[model.category] || 'train'}`;
const s = await api(
'https://www.wikidata.org/w/api.php?action=wbsearchentities&format=json&origin=*' +
`&language=en&type=item&limit=3&search=${encodeURIComponent(q)}`,
);
for (const hit of s?.search || []) {
const e = await api(
'https://www.wikidata.org/w/api.php?action=wbgetentities&format=json&origin=*' +
`&props=claims&ids=${hit.id}`,
);
const p18 = e?.entities?.[hit.id]?.claims?.P18?.[0]?.mainsnak?.datavalue?.value;
if (p18) {
const info = await commonsFileInfo('File:' + p18);
if (info) return [{ ...info, via: 'wikidata' }];
}
}
return [];
}
/** 2) Commons 同名分类(可多张)。*/
async function viaCategory(model, want) {
const code = latin(model.model_code) || model.model_code;
const out = [];
for (const cat of [`Category:China Railways ${code}`, `Category:${code}`]) {
const d = await api(
'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*' +
`&list=categorymembers&cmtype=file&cmlimit=20&cmtitle=${encodeURIComponent(cat)}`,
);
for (const f of d?.query?.categorymembers || []) {
if (!isImg(f.title)) continue;
const info = await commonsFileInfo(f.title);
if (info) out.push({ ...info, via: 'category' });
if (out.length >= want) return out;
}
}
return out;
}
/** 3) Commons 关键词搜索(最不准,兜底,可多张)。*/
async function viaSearch(model, want) {
const q = `${latin(model.model_code) || model.model_code} ${CAT_HINT[model.category] || 'train'} China Railway`;
const d = await api(
'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*' +
`&generator=search&gsrnamespace=6&gsrlimit=${Math.max(3, want)}&gsrsearch=${encodeURIComponent(q)}` +
'&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=1280',
);
const pages = d?.query?.pages || {};
const out = [];
for (const p of Object.values(pages)) {
const ii = p?.imageinfo?.[0];
if (!ii?.thumburl || !isImg(ii.thumburl)) continue;
const em = ii.extmetadata || {};
out.push({
url: ii.thumburl,
descriptionUrl: ii.descriptionurl || '',
author: strip(em.Artist?.value) || '未署名',
license: strip(em.LicenseShortName?.value) || '见来源',
via: 'search',
});
if (out.length >= want) break;
}
return out;
}
/** 聚合各来源,按 url 去重,返回最多 want 张。*/
async function collectImages(model, want) {
const seen = new Set();
const out = [];
const push = (arr) => {
for (const it of arr) {
if (out.length >= want) break;
if (seen.has(it.url)) continue;
seen.add(it.url);
out.push(it);
}
};
push(await viaWikidata(model));
if (out.length < want) push(await viaCategory(model, want - out.length));
if (out.length < want) push(await viaSearch(model, want - out.length));
return out;
}
async function main() {
mkdirSync(UPLOAD_DIR, { recursive: true });
const mdb = new Database(MACHINES_DB, { readonly: true });
const adb = new Database(APP_DB);
const admin =
adb.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get() ||
adb.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get();
if (!admin) {
console.error('找不到用户:请先启动后端注册一个管理员(或设 ADMIN_EMAIL)。');
process.exit(1);
}
const where = CATEGORY ? 'WHERE c.name = ?' : '';
const models = mdb
.prepare(
`SELECT m.id, m.model_code, c.name AS category
FROM model m JOIN category c ON c.id = m.category_id ${where}
ORDER BY m.id`,
)
.all(...(CATEGORY ? [CATEGORY] : []));
const hasPhoto = adb.prepare('SELECT 1 FROM photos WHERE model_id = ? LIMIT 1');
const insert = adb.prepare(
`INSERT INTO photos (model_id, uploader_id, filename, caption, status, source_url, author, license)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
console.log(
`取图:每车型最多 ${PER} 张,入库状态=候选(candidate)${DRY ? 'dry-run' : ''};保存到 ${UPLOAD_DIR}`,
);
const report = [];
let done = 0;
let saved = 0;
for (const m of models) {
if (done >= LIMIT) break;
if (hasPhoto.get(m.id)) continue;
done++;
let hits = [];
try {
hits = await collectImages(m, PER);
} catch (e) {
report.push(`${m.model_code}:检索出错 ${e.message}`);
await sleep(300);
continue;
}
if (hits.length === 0) {
report.push(`${m.model_code}:未找到`);
await sleep(200);
continue;
}
if (DRY) {
report.push(`${m.model_code}[dry] ${hits.map((h) => h.via).join(',')} (${hits.length} 张)`);
await sleep(200);
continue;
}
let n = 0;
for (const hit of hits) {
try {
const buf = Buffer.from(
await (await fetch(hit.url, { headers: { 'User-Agent': UA } })).arrayBuffer(),
);
const ext = (hit.url.match(/\.(jpe?g|png)/i) || ['.jpg'])[0].toLowerCase();
const fn = `${Date.now()}-${randomBytes(6).toString('hex')}${ext}`;
await writeFile(join(UPLOAD_DIR, fn), buf);
insert.run(m.id, admin.id, fn, `${m.model_code}${hit.via}`, STATUS, hit.descriptionUrl, hit.author, hit.license);
n++;
saved++;
} catch (e) {
report.push(`${m.model_code}:下载失败 ${e.message}`);
}
await sleep(300); // 礼貌限速
}
if (n > 0) report.push(`${m.model_code}:入库 ${n} 张(${STATUS}`);
}
const ok = report.filter((r) => r.startsWith('✓')).length;
const miss = report.filter((r) => r.startsWith('—')).length;
const err = report.filter((r) => r.startsWith('✗')).length;
console.log(report.join('\n'));
console.log(`\n命中 ${ok} · 未找到 ${miss} · 出错 ${err}(处理 ${done} 个车型,保存 ${saved} 张)`);
console.log('候选图已入库,请用管理员在网页"候选审图 / 图册"逐个确认后作为封面展示。');
}
main();
+37
View File
@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from './db/database.module';
import { WritableDbModule } from './db/writable-db.module';
import { CategoriesModule } from './categories/categories.module';
import { ModelsModule } from './models/models.module';
import { UnitsModule } from './units/units.module';
import { SearchModule } from './search/search.module';
import { AuthModule } from './auth/auth.module';
import { ContribModule } from './contrib/contrib.module';
import { CommunityModule } from './community/community.module';
import { ForumModule } from './forum/forum.module';
import { SightingsModule } from './sightings/sightings.module';
import { StatsModule } from './stats/stats.module';
import { PhotosModule } from './photos/photos.module';
import { IdentifyModule } from './identify/identify.module';
import { HealthController } from './health.controller';
@Module({
imports: [
DatabaseModule,
WritableDbModule,
AuthModule,
CategoriesModule,
ModelsModule,
UnitsModule,
SearchModule,
ContribModule,
CommunityModule,
ForumModule,
SightingsModule,
StatsModule,
PhotosModule,
IdentifyModule,
],
controllers: [HealthController],
})
export class AppModule {}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto, RegisterDto } from './dto';
import { CurrentUser, JwtAuthGuard, type JwtPayload } from './roles';
@Controller('api/auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.auth.register(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: JwtPayload) {
return this.auth.me(user.sub);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard, RolesGuard } from './roles';
export const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
@Module({
imports: [
JwtModule.register({
secret: JWT_SECRET,
signOptions: { expiresIn: '7d' },
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard, RolesGuard],
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
})
export class AuthModule {}
+66
View File
@@ -0,0 +1,66 @@
import Database from 'better-sqlite3';
import { JwtService } from '@nestjs/jwt';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
/** AuthService 单元测试 — 对应 T-2.1 UT。*/
function makeService() {
const db = new Database(':memory:');
db.exec(`CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
contribution_points INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);`);
const jwt = new JwtService({ secret: 'test-secret', signOptions: { expiresIn: '1h' } });
const svc = new AuthService({ db } as any, jwt);
return { svc, jwt, db };
}
describe('AuthService', () => {
it('注册返回 token 与用户,密码不外泄', async () => {
const { svc } = makeService();
const { token, user } = await svc.register({
email: 'A@Example.com',
password: 'secret123',
displayName: '老司机',
});
expect(token).toBeTruthy();
expect(user.email).toBe('a@example.com'); // 规范化小写
expect(user.role).toBe('user');
expect((user as any).password_hash).toBeUndefined();
});
it('重复邮箱注册抛 409', async () => {
const { svc } = makeService();
const dto = { email: 'b@x.com', password: 'secret123', displayName: '阿强' };
await svc.register(dto);
await expect(svc.register(dto)).rejects.toBeInstanceOf(ConflictException);
});
it('登录校验密码,错误抛 401', async () => {
const { svc } = makeService();
await svc.register({ email: 'c@x.com', password: 'secret123', displayName: 'C' });
const ok = await svc.login({ email: 'c@x.com', password: 'secret123' });
expect(ok.token).toBeTruthy();
await expect(
svc.login({ email: 'c@x.com', password: 'wrongpass' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('签发的 token 可被验证且含角色', async () => {
const { svc, jwt } = makeService();
const { token } = await svc.register({
email: 'd@x.com',
password: 'secret123',
displayName: 'D',
});
const payload = jwt.verify(token);
expect(payload.email).toBe('d@x.com');
expect(payload.role).toBe('user');
expect(payload.sub).toBeGreaterThan(0);
});
});
+135
View File
@@ -0,0 +1,135 @@
import {
ConflictException,
Injectable,
OnModuleInit,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { WritableDbService } from '../db/writable-db.service';
import { LoginDto, RegisterDto } from './dto';
import type { JwtPayload, Role } from './roles';
import { badgesFor, levelFor, type Badge, type LevelInfo } from '../community/levels';
export interface PublicUser {
id: number;
email: string;
displayName: string;
role: Role;
contributionPoints: number;
createdAt: string;
}
export interface UserStats extends PublicUser {
level: LevelInfo;
approvedCount: number;
badges: Badge[];
}
@Injectable()
export class AuthService implements OnModuleInit {
constructor(
private readonly database: WritableDbService,
private readonly jwt: JwtService,
) {}
/** 开机预置测试账号,便于演示/测试(生产可用 SEED_TEST_USERS=false 关闭)。*/
onModuleInit() {
if (process.env.SEED_TEST_USERS === 'false') return;
const accounts: { email: string; password: string; displayName: string; role: Role }[] = [
{ email: 'admin@demo.com', password: 'demo1234', displayName: '演示管理员', role: 'admin' },
{ email: 'mod@demo.com', password: 'demo1234', displayName: '演示版主', role: 'moderator' },
{ email: 'user@demo.com', password: 'demo1234', displayName: '演示用户', role: 'user' },
];
const db = this.database.db;
for (const a of accounts) {
const email = a.email.toLowerCase();
const existing: any = db
.prepare('SELECT id, role FROM users WHERE email = ?')
.get(email);
if (existing) {
if (existing.role !== a.role) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(a.role, existing.id);
}
continue;
}
db.prepare(
`INSERT INTO users (email, password_hash, display_name, role)
VALUES (?, ?, ?, ?)`,
).run(email, bcrypt.hashSync(a.password, 10), a.displayName, a.role);
}
}
private toPublic(row: any): PublicUser {
return {
id: row.id,
email: row.email,
displayName: row.display_name,
role: row.role,
contributionPoints: row.contribution_points,
createdAt: row.created_at,
};
}
private sign(user: PublicUser): string {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role,
};
return this.jwt.sign(payload);
}
async register(dto: RegisterDto): Promise<{ token: string; user: PublicUser }> {
const db = this.database.db;
const email = dto.email.trim().toLowerCase();
const exists = db.prepare('SELECT 1 FROM users WHERE email = ?').get(email);
if (exists) throw new ConflictException('该邮箱已注册');
const hash = await bcrypt.hash(dto.password, 10);
const adminEmail = (process.env.ADMIN_EMAIL || '').trim().toLowerCase();
const role: Role = adminEmail && email === adminEmail ? 'admin' : 'user';
const info = db
.prepare(
`INSERT INTO users (email, password_hash, display_name, role)
VALUES (?, ?, ?, ?)`,
)
.run(email, hash, dto.displayName.trim(), role);
const row = db
.prepare('SELECT * FROM users WHERE id = ?')
.get(info.lastInsertRowid);
const user = this.toPublic(row);
return { token: this.sign(user), user };
}
async login(dto: LoginDto): Promise<{ token: string; user: PublicUser }> {
const db = this.database.db;
const email = dto.email.trim().toLowerCase();
const row: any = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!row) throw new UnauthorizedException('邮箱或密码错误');
const ok = await bcrypt.compare(dto.password, row.password_hash);
if (!ok) throw new UnauthorizedException('邮箱或密码错误');
const user = this.toPublic(row);
return { token: this.sign(user), user };
}
me(userId: number): UserStats {
const row = this.database.db
.prepare('SELECT * FROM users WHERE id = ?')
.get(userId);
if (!row) throw new UnauthorizedException('用户不存在');
const user = this.toPublic(row);
const approvedCount = (
this.database.db
.prepare(
"SELECT COUNT(*) AS n FROM revisions WHERE author_id = ? AND status = 'approved'",
)
.get(userId) as { n: number }
).n;
return {
...user,
level: levelFor(user.contributionPoints),
approvedCount,
badges: badgesFor(user.contributionPoints, approvedCount),
};
}
}
+25
View File
@@ -0,0 +1,25 @@
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
export class RegisterDto {
@IsEmail({}, { message: '邮箱格式不正确' })
email!: string;
@IsString()
@MinLength(6, { message: '密码至少 6 位' })
@MaxLength(72)
password!: string;
@IsString()
@MinLength(2, { message: '昵称至少 2 个字符' })
@MaxLength(30)
displayName!: string;
}
export class LoginDto {
@IsEmail({}, { message: '邮箱格式不正确' })
email!: string;
@IsString()
@MinLength(6)
password!: string;
}
+81
View File
@@ -0,0 +1,81 @@
import {
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
UnauthorizedException,
ForbiddenException,
createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
export type Role = 'guest' | 'user' | 'trusted' | 'moderator' | 'admin';
/** 角色等级,用于"至少某角色"的比较。*/
export const ROLE_RANK: Record<Role, number> = {
guest: 0,
user: 1,
trusted: 2,
moderator: 3,
admin: 4,
};
export interface JwtPayload {
sub: number;
email: string;
role: Role;
}
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
/** 取当前登录用户(来自 JwtAuthGuard 注入的 req.user)。*/
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) =>
ctx.switchToHttp().getRequest().user as JwtPayload,
);
function extractToken(req: any): string | null {
const h = req.headers?.authorization;
if (typeof h === 'string' && h.startsWith('Bearer ')) return h.slice(7);
return null;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService) {}
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest();
const token = extractToken(req);
if (!token) throw new UnauthorizedException('需要登录');
try {
req.user = this.jwt.verify<JwtPayload>(token);
return true;
} catch {
throw new UnauthorizedException('登录已失效');
}
}
}
/** 需要"至少"指定角色之一(按等级比较)。配合 @Roles 与 JwtAuthGuard 使用。*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
ctx.getHandler(),
ctx.getClass(),
]);
if (!required || required.length === 0) return true;
const user = ctx.switchToHttp().getRequest().user as JwtPayload | undefined;
if (!user) throw new UnauthorizedException('需要登录');
const min = Math.min(...required.map((r) => ROLE_RANK[r]));
if (ROLE_RANK[user.role] < min) {
throw new ForbiddenException('权限不足');
}
return true;
}
}
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { CategoriesService } from './categories.service';
@Controller('api/categories')
export class CategoriesController {
constructor(private readonly categories: CategoriesService) {}
@Get()
findAll() {
return this.categories.findAll();
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
})
export class CategoriesModule {}
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../db/database.service';
@Injectable()
export class CategoriesService {
constructor(private readonly database: DatabaseService) {}
/** 分类列表 + 各分类车型/个体计数。*/
findAll(): any[] {
return this.database.db
.prepare(
`SELECT c.id, c.name, c.subcat,
(SELECT COUNT(*) FROM model m WHERE m.category_id = c.id) AS modelCount,
(SELECT COUNT(*) FROM unit u WHERE u.category_id = c.id) AS unitCount
FROM category c
ORDER BY c.name, c.subcat`,
)
.all();
}
}
@@ -0,0 +1,40 @@
import {
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { CommunityService } from './community.service';
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
@Controller('api')
export class CommunityController {
constructor(private readonly community: CommunityService) {}
@Get('leaderboard')
leaderboard(@Query('limit') limit?: string) {
const n = limit ? Math.min(100, Math.max(1, Number(limit))) : 20;
return this.community.leaderboard(n);
}
@Get('models/:id/maintainers')
maintainers(@Param('id', ParseIntPipe) id: number) {
return this.community.maintainers(id);
}
@Post('models/:id/maintainers')
@UseGuards(JwtAuthGuard)
claim(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtPayload) {
return this.community.claim(id, user.sub);
}
@Delete('models/:id/maintainers')
@UseGuards(JwtAuthGuard)
unclaim(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtPayload) {
return this.community.unclaim(id, user.sub);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { CommunityService } from './community.service';
import { CommunityController } from './community.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [CommunityController],
providers: [CommunityService],
})
export class CommunityModule {}
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { WritableDbService } from '../db/writable-db.service';
import { levelFor } from './levels';
@Injectable()
export class CommunityService {
constructor(private readonly wdb: WritableDbService) {}
/** 贡献榜(公开):按积分降序。*/
leaderboard(limit = 20) {
const rows = this.wdb.db
.prepare(
`SELECT id, display_name, role, contribution_points
FROM users WHERE contribution_points > 0
ORDER BY contribution_points DESC, id ASC LIMIT ?`,
)
.all(limit) as any[];
return rows.map((r, i) => ({
rank: i + 1,
id: r.id,
displayName: r.display_name,
role: r.role,
points: r.contribution_points,
title: levelFor(r.contribution_points).title,
}));
}
/** 某车型的维护者署名列表。*/
maintainers(modelId: number) {
return this.wdb.db
.prepare(
`SELECT u.id, u.display_name AS displayName, u.role, m.created_at AS since
FROM model_maintainers m JOIN users u ON u.id = m.user_id
WHERE m.model_id = ? ORDER BY m.created_at ASC`,
)
.all(modelId);
}
claim(modelId: number, userId: number) {
this.wdb.db
.prepare(
`INSERT INTO model_maintainers (model_id, user_id) VALUES (?, ?)
ON CONFLICT(model_id, user_id) DO NOTHING`,
)
.run(modelId, userId);
return this.maintainers(modelId);
}
unclaim(modelId: number, userId: number) {
this.wdb.db
.prepare('DELETE FROM model_maintainers WHERE model_id = ? AND user_id = ?')
.run(modelId, userId);
return this.maintainers(modelId);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { badgesFor, levelFor } from './levels';
describe('levelFor', () => {
it('0 分为见习巡道员', () => {
const l = levelFor(0);
expect(l.title).toBe('见习巡道员');
expect(l.level).toBe(1);
expect(l.nextThreshold).toBe(10);
});
it('跨阈值升级', () => {
expect(levelFor(30).title).toBe('司炉');
expect(levelFor(159).title).toBe('副司机');
expect(levelFor(160).title).toBe('司机');
});
it('满级 nextThreshold 为 null', () => {
const l = levelFor(1000);
expect(l.title).toBe('总工程师');
expect(l.nextThreshold).toBeNull();
});
});
describe('badgesFor', () => {
it('按积分/采纳数派发', () => {
expect(badgesFor(0, 0)).toHaveLength(0);
expect(badgesFor(5, 1).map((b) => b.key)).toContain('first_edit');
expect(badgesFor(200, 12).map((b) => b.key)).toEqual(
expect.arrayContaining(['first_edit', 'prolific', 'veteran']),
);
});
});
+45
View File
@@ -0,0 +1,45 @@
/** 铁路主题等级(按贡献积分)。纯函数,便于单测。*/
export interface LevelInfo {
level: number;
title: string;
min: number;
nextThreshold: number | null; // 升到下一级所需积分;满级为 null
}
export const LEVELS: { min: number; title: string }[] = [
{ min: 0, title: '见习巡道员' },
{ min: 10, title: '巡道员' },
{ min: 30, title: '司炉' },
{ min: 80, title: '副司机' },
{ min: 160, title: '司机' },
{ min: 320, title: '机务段长' },
{ min: 640, title: '总工程师' },
];
export function levelFor(points: number): LevelInfo {
const p = Math.max(0, points || 0);
let idx = 0;
for (let i = 0; i < LEVELS.length; i++) if (p >= LEVELS[i].min) idx = i;
const next = LEVELS[idx + 1] ?? null;
return {
level: idx + 1,
title: LEVELS[idx].title,
min: LEVELS[idx].min,
nextThreshold: next ? next.min : null,
};
}
export interface Badge {
key: string;
label: string;
}
/** 根据积分与被采纳修订数派生徽章。*/
export function badgesFor(points: number, approvedCount: number): Badge[] {
const out: Badge[] = [];
if (approvedCount >= 1) out.push({ key: 'first_edit', label: '首改采纳' });
if (approvedCount >= 10) out.push({ key: 'prolific', label: '高产编辑' });
if (points >= 160) out.push({ key: 'veteran', label: '资深贡献' });
if (points >= 640) out.push({ key: 'chief', label: '总工殊荣' });
return out;
}
@@ -0,0 +1,82 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
import { RevisionsService } from './revisions.service';
import {
CurrentUser,
JwtAuthGuard,
Roles,
RolesGuard,
type JwtPayload,
} from '../auth/roles';
class SubmitEditDto {
@IsObject()
changes!: Record<string, unknown>;
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
/** 编辑/修订(登录用户)。*/
@Controller('api/models/:id/revisions')
export class ModelRevisionsController {
constructor(private readonly revisions: RevisionsService) {}
@Get()
history(@Param('id', ParseIntPipe) id: number) {
return this.revisions.listForModel(id);
}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
submit(
@Param('id', ParseIntPipe) id: number,
@Body() dto: SubmitEditDto,
@CurrentUser() user: JwtPayload,
) {
return this.revisions.submitEdit(id, user, dto.changes, dto.note ?? '');
}
}
/** 审核(版主及以上)+ 可编辑字段元数据。*/
@Controller('api')
export class ReviewController {
constructor(private readonly revisions: RevisionsService) {}
@Get('editable-fields')
fields() {
return this.revisions.editableFields();
}
@Get('revisions/pending')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('moderator')
pending() {
return this.revisions.listPending();
}
@Post('revisions/:rid/approve')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('moderator')
approve(@Param('rid', ParseIntPipe) rid: number, @CurrentUser() user: JwtPayload) {
return this.revisions.approve(rid, user);
}
@Post('revisions/:rid/reject')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('moderator')
reject(@Param('rid', ParseIntPipe) rid: number, @CurrentUser() user: JwtPayload) {
return this.revisions.reject(rid, user);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { RevisionsService } from './revisions.service';
import { ModelRevisionsController, ReviewController } from './contrib.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [ModelRevisionsController, ReviewController],
providers: [RevisionsService],
exports: [RevisionsService],
})
export class ContribModule {}
+61
View File
@@ -0,0 +1,61 @@
import { BadRequestException } from '@nestjs/common';
export type FieldType = 'text' | 'int';
export interface EditableField {
field: string;
label: string;
type: FieldType;
}
/** 可众包编辑的字段白名单(映射到 model 表列)。*/
export const EDITABLE_FIELDS: EditableField[] = [
{ field: 'full_name', label: '车型全称', type: 'text' },
{ field: 'series', label: '系列', type: 'text' },
{ field: 'manufacturer', label: '生产商', type: 'text' },
{ field: 'country', label: '制造国/地区', type: 'text' },
{ field: 'country_type', label: '国别属性', type: 'text' },
{ field: 'usage', label: '用途', type: 'text' },
{ field: 'status', label: '状态', type: 'text' },
{ field: 'drive', label: '传动/供电方式', type: 'text' },
{ field: 'efficiency', label: '传动效率', type: 'text' },
{ field: 'axle_arrangement', label: '轴列式', type: 'text' },
{ field: 'production_count', label: '产量', type: 'text' },
{ field: 'first_year', label: '首产年', type: 'int' },
{ field: 'last_year', label: '停产年', type: 'int' },
{ field: 'max_speed_value', label: '最高时速(值)', type: 'int' },
{ field: 'weight_value', label: '整备重量(值)', type: 'int' },
];
export const EDITABLE_MAP = new Map(EDITABLE_FIELDS.map((f) => [f.field, f]));
export const STATUS_ENUM = [
'现役', '封存', '半封存', '退役', '报废', '保存', '试验', '未知',
];
export const COUNTRY_TYPE_ENUM = ['国产', '进口', '引进仿制', '中外合资', '未知'];
/** 校验并规范化单个字段值,返回入库用字符串;非法抛 400。*/
export function validateField(field: string, raw: unknown): string {
const def = EDITABLE_MAP.get(field);
if (!def) throw new BadRequestException(`字段不可编辑: ${field}`);
const s = String(raw ?? '').trim();
if (def.type === 'int') {
if (s === '') return '';
if (!/^-?\d+$/.test(s)) throw new BadRequestException(`${def.label} 必须为整数`);
const n = Number(s);
if (field === 'first_year' || field === 'last_year') {
if (n < 1800 || n > 2100)
throw new BadRequestException(`${def.label} 年代超出合理范围`);
}
if (n < 0) throw new BadRequestException(`${def.label} 不能为负`);
return String(n);
}
if (s.length > 200) throw new BadRequestException(`${def.label} 过长`);
if (field === 'status' && s && !STATUS_ENUM.includes(s))
throw new BadRequestException(`状态取值非法`);
if (field === 'country_type' && s && !COUNTRY_TYPE_ENUM.includes(s))
throw new BadRequestException(`国别属性取值非法`);
return s;
}
+200
View File
@@ -0,0 +1,200 @@
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;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { DatabaseService } from './database.service';
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
import Database from 'better-sqlite3';
import { existsSync } from 'fs';
import { join, resolve } from 'path';
/**
* 打开 ETL 产出的 SQLite 数据库(app/data/machines.db)。
* 开发期数据底座;schema 设计为可移植到 PostgreSQL。
* DB 路径可通过环境变量 DB_PATH 覆盖(测试用)。
*/
@Injectable()
export class DatabaseService implements OnModuleDestroy {
private readonly logger = new Logger(DatabaseService.name);
public readonly db: Database.Database;
constructor() {
const dbPath =
process.env.DB_PATH ||
resolve(join(__dirname, '..', '..', '..', '..', 'app', 'data', 'machines.db'));
if (!existsSync(dbPath)) {
this.logger.warn(
`数据库不存在: ${dbPath} —— 请先运行 ETL: python3 -m app.etl.importer`,
);
}
this.db = new Database(dbPath, { readonly: true, fileMustExist: false });
// 只读连接无需(也不允许)设置 WAL;容错跳过。
try {
this.db.pragma('query_only = ON');
} catch {
/* ignore */
}
}
onModuleDestroy() {
this.db.close();
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { WritableDbService } from './writable-db.service';
@Global()
@Module({
providers: [WritableDbService],
exports: [WritableDbService],
})
export class WritableDbModule {}
+173
View File
@@ -0,0 +1,173 @@
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { dirname, join, resolve } from 'path';
/**
* 可写库(用户 / 社区数据)。与只读的 machines.db 分离——后者由 ETL 重建会被清空。
* 默认 app/data/app.db,可用 APP_DB_PATH 覆盖(测试用)。
*/
@Injectable()
export class WritableDbService implements OnModuleDestroy {
private readonly logger = new Logger(WritableDbService.name);
public readonly db: Database.Database;
constructor() {
const dbPath =
process.env.APP_DB_PATH ||
resolve(join(__dirname, '..', '..', '..', '..', 'app', 'data', 'app.db'));
mkdirSync(dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
this.migrate();
this.logger.log(`可写库就绪: ${dbPath}`);
}
private migrate() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
contribution_points INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id INTEGER NOT NULL,
author_id INTEGER NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected
note TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
reviewed_by INTEGER REFERENCES users(id),
reviewed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_rev_model ON revisions(model_id);
CREATE INDEX IF NOT EXISTS idx_rev_status ON revisions(status);
CREATE TABLE IF NOT EXISTS revision_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
field TEXT NOT NULL,
old_value TEXT,
new_value TEXT
);
CREATE INDEX IF NOT EXISTS idx_revchg_rev ON revision_changes(revision_id);
CREATE TABLE IF NOT EXISTS model_overrides (
model_id INTEGER NOT NULL,
field TEXT NOT NULL,
value TEXT,
revision_id INTEGER REFERENCES revisions(id),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (model_id, field)
);
CREATE TABLE IF NOT EXISTS model_maintainers (
model_id INTEGER NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (model_id, user_id)
);
CREATE TABLE IF NOT EXISTS threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
board TEXT NOT NULL, -- general/photography/history/entry
model_id INTEGER, -- 非空表示绑定到某车型(词条讨论)
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
author_id INTEGER NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_activity_at TEXT NOT NULL DEFAULT (datetime('now')),
reply_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_threads_board ON threads(board);
CREATE INDEX IF NOT EXISTS idx_threads_model ON threads(model_id);
CREATE TABLE IF NOT EXISTS thread_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_replies_thread ON thread_replies(thread_id);
CREATE TABLE IF NOT EXISTS sightings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id INTEGER NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id),
car_number TEXT DEFAULT '',
lat REAL NOT NULL,
lng REAL NOT NULL,
station TEXT DEFAULT '',
spotted_at TEXT DEFAULT '',
description TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sightings_model ON sightings(model_id);
CREATE INDEX IF NOT EXISTS idx_sightings_created ON sightings(created_at);
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id INTEGER NOT NULL,
uploader_id INTEGER NOT NULL REFERENCES users(id),
filename TEXT NOT NULL,
caption TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'confirmed',
source_url TEXT DEFAULT '',
author TEXT DEFAULT '',
license TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_photos_model ON photos(model_id);
CREATE TABLE IF NOT EXISTS identifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
filename TEXT NOT NULL,
image_hash TEXT NOT NULL,
summary TEXT DEFAULT '',
guesses_json TEXT DEFAULT '[]',
matches_json TEXT DEFAULT '[]',
top_code TEXT DEFAULT '',
top_name TEXT DEFAULT '',
note TEXT DEFAULT '',
error TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ident_user ON identifications(user_id);
CREATE INDEX IF NOT EXISTS idx_ident_hash ON identifications(image_hash);
`);
// 已存在的库补齐新列(无 IF NOT EXISTS,需手动检查)
this.ensureColumns('photos', {
status: "TEXT NOT NULL DEFAULT 'confirmed'",
source_url: "TEXT DEFAULT ''",
author: "TEXT DEFAULT ''",
license: "TEXT DEFAULT ''",
featured: 'INTEGER NOT NULL DEFAULT 0',
});
}
private ensureColumns(table: string, cols: Record<string, string>) {
const existing = new Set(
(this.db.prepare(`PRAGMA table_info(${table})`).all() as any[]).map(
(c) => c.name,
),
);
for (const [name, def] of Object.entries(cols)) {
if (!existing.has(name)) {
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${def}`);
}
}
}
onModuleDestroy() {
this.db.close();
}
}
+81
View File
@@ -0,0 +1,81 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
IsInt,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ForumService } from './forum.service';
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
class CreateThreadDto {
@IsString() @MinLength(1) @MaxLength(40)
board!: string;
@IsOptional() @Type(() => Number) @IsInt()
modelId?: number;
@IsString() @MinLength(2) @MaxLength(80)
title!: string;
@IsString() @MinLength(1) @MaxLength(5000)
body!: string;
}
class ReplyDto {
@IsString() @MinLength(1) @MaxLength(5000)
body!: string;
}
@Controller('api')
export class ForumController {
constructor(private readonly forum: ForumService) {}
@Get('boards')
boards() {
return this.forum.boards();
}
@Get('threads')
list(
@Query('board') board?: string,
@Query('modelId') modelId?: string,
) {
return this.forum.listThreads({
board,
modelId: modelId ? Number(modelId) : undefined,
});
}
@Get('threads/:id')
get(@Param('id', ParseIntPipe) id: number) {
return this.forum.getThread(id);
}
@Post('threads')
@UseGuards(JwtAuthGuard)
create(@Body() dto: CreateThreadDto, @CurrentUser() user: JwtPayload) {
return this.forum.createThread(user.sub, dto);
}
@Post('threads/:id/replies')
@UseGuards(JwtAuthGuard)
reply(
@Param('id', ParseIntPipe) id: number,
@Body() dto: ReplyDto,
@CurrentUser() user: JwtPayload,
) {
return this.forum.addReply(user.sub, id, dto.body);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ForumService } from './forum.service';
import { ForumController } from './forum.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [ForumController],
providers: [ForumService],
})
export class ForumModule {}
+92
View File
@@ -0,0 +1,92 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WritableDbService } from '../db/writable-db.service';
export const BOARDS = [
{ key: 'general', label: '综合讨论' },
{ key: 'photography', label: '拍车 / 打卡' },
{ key: 'history', label: '历史考证' },
];
const BOARD_KEYS = new Set([...BOARDS.map((b) => b.key), 'entry']);
@Injectable()
export class ForumService {
constructor(private readonly wdb: WritableDbService) {}
boards() {
return BOARDS;
}
listThreads(opts: { board?: string; modelId?: number; limit?: number }) {
const conds: string[] = [];
const params: any[] = [];
if (opts.modelId != null) {
conds.push('t.model_id = ?');
params.push(opts.modelId);
} else if (opts.board) {
conds.push('t.board = ?');
params.push(opts.board);
}
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const limit = Math.min(100, Math.max(1, opts.limit ?? 50));
return this.wdb.db
.prepare(
`SELECT t.id, t.board, t.model_id, t.title, t.author_id,
u.display_name AS author_name, t.created_at,
t.last_activity_at, t.reply_count
FROM threads t JOIN users u ON u.id = t.author_id
${where}
ORDER BY t.last_activity_at DESC, t.id DESC
LIMIT ?`,
)
.all(...params, limit);
}
createThread(
userId: number,
data: { board: string; modelId?: number | null; title: string; body: string },
) {
const board = BOARD_KEYS.has(data.board) ? data.board : 'general';
const info = this.wdb.db
.prepare(
`INSERT INTO threads (board, model_id, title, body, author_id)
VALUES (?, ?, ?, ?, ?)`,
)
.run(board, data.modelId ?? null, data.title.trim(), data.body.trim(), userId);
return this.getThread(Number(info.lastInsertRowid));
}
getThread(id: number) {
const t: any = this.wdb.db
.prepare(
`SELECT t.*, u.display_name AS author_name
FROM threads t JOIN users u ON u.id = t.author_id WHERE t.id = ?`,
)
.get(id);
if (!t) throw new NotFoundException('帖子不存在');
t.replies = this.wdb.db
.prepare(
`SELECT r.id, r.body, r.author_id, u.display_name AS author_name, r.created_at
FROM thread_replies r JOIN users u ON u.id = r.author_id
WHERE r.thread_id = ? ORDER BY r.created_at ASC, r.id ASC`,
)
.all(id);
return t;
}
addReply(userId: number, threadId: number, body: string) {
const db = this.wdb.db;
const exists = db.prepare('SELECT 1 FROM threads WHERE id = ?').get(threadId);
if (!exists) throw new NotFoundException('帖子不存在');
const tx = db.transaction(() => {
db.prepare(
'INSERT INTO thread_replies (thread_id, author_id, body) VALUES (?, ?, ?)',
).run(threadId, userId, body.trim());
db.prepare(
`UPDATE threads SET reply_count = reply_count + 1,
last_activity_at = datetime('now') WHERE id = ?`,
).run(threadId);
});
tx();
return this.getThread(threadId);
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Controller, Get } from '@nestjs/common';
import { DatabaseService } from './db/database.service';
@Controller('api/health')
export class HealthController {
constructor(private readonly database: DatabaseService) {}
@Get()
health() {
const models = (
this.database.db.prepare('SELECT COUNT(*) AS n FROM model').get() as {
n: number;
}
).n;
const units = (
this.database.db.prepare('SELECT COUNT(*) AS n FROM unit').get() as {
n: number;
}
).n;
return { status: 'ok', models, units };
}
}
@@ -0,0 +1,68 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { IdentifyService } from './identify.service';
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
const ALLOWED = /\.(jpe?g|png|webp|gif)$/i;
@Controller('api')
@UseGuards(JwtAuthGuard)
export class IdentifyController {
constructor(private readonly svc: IdentifyService) {}
/** AI 识车:上传一张照片,调用通义千问视觉模型识别并持久化(命中哈希缓存则复用)。*/
@Post('identify')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 8 * 1024 * 1024 },
fileFilter: (_req, file, cb) =>
ALLOWED.test(file.originalname) || /^image\//.test(file.mimetype)
? cb(null, true)
: cb(new BadRequestException('仅支持图片文件'), false),
}),
)
async identify(
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: JwtPayload,
) {
if (!file) throw new BadRequestException('未收到图片');
return this.svc.identifyAndSave(user.sub, file.buffer, file.mimetype);
}
/** 当前用户的识别历史。*/
@Get('identifications')
list(@CurrentUser() user: JwtPayload) {
return this.svc.list(user.sub);
}
/** 修改备注(人工纠正/标注)。*/
@Patch('identifications/:id')
update(
@Param('id', ParseIntPipe) id: number,
@Body('note') note: string,
@CurrentUser() user: JwtPayload,
) {
return this.svc.updateNote(user.sub, id, note ?? '');
}
/** 删除一条识别历史。*/
@Delete('identifications/:id')
remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtPayload) {
return this.svc.remove(user.sub, id);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { IdentifyController } from './identify.controller';
import { IdentifyService } from './identify.service';
@Module({
imports: [AuthModule],
controllers: [IdentifyController],
providers: [IdentifyService],
})
export class IdentifyModule {}
+293
View File
@@ -0,0 +1,293 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { createHash, randomBytes } from 'crypto';
import { unlinkSync, writeFileSync } from 'fs';
import { join } from 'path';
import { DatabaseService } from '../db/database.service';
import { WritableDbService } from '../db/writable-db.service';
import { PUBLIC_PREFIX, uploadDir } from '../photos/uploads';
/** DashScope(阿里云·通义千问)OpenAI 兼容多模态接口。*/
const ENDPOINT =
process.env.DASHSCOPE_ENDPOINT ||
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
const MODEL = process.env.DASHSCOPE_VL_MODEL || 'qwen-vl-plus';
export interface Guess {
model_code: string;
name: string;
confidence: number;
reason: string;
}
export interface ModelMatch {
id: number;
model_code: string;
category: string;
}
export interface Identification {
id: number;
url: string;
summary: string;
guesses: Guess[];
matches: ModelMatch[];
topCode: string;
topName: string;
note: string;
error: string;
cached: boolean;
configured: boolean;
createdAt: string;
}
const EXT: Record<string, string> = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
};
@Injectable()
export class IdentifyService {
private readonly logger = new Logger(IdentifyService.name);
constructor(
private readonly db: DatabaseService,
private readonly wdb: WritableDbService,
) {}
/** 识别并持久化:命中图片哈希缓存则复用结果(不再调用 LLM)。*/
async identifyAndSave(
userId: number,
buffer: Buffer,
mimetype: string,
): Promise<Identification> {
const hash = createHash('sha256').update(buffer).digest('hex');
// 缓存:同一张图(哈希一致)此前已成功识别 → 复用结果与图片文件
const cachedRow: any = this.wdb.db
.prepare(
`SELECT * FROM identifications
WHERE image_hash = ? AND guesses_json <> '[]' AND error = ''
ORDER BY id DESC LIMIT 1`,
)
.get(hash);
let filename: string;
let summary = '';
let guesses: Guess[] = [];
let matches: ModelMatch[] = [];
let error = '';
let cached = false;
let configured = true;
if (cachedRow) {
cached = true;
filename = cachedRow.filename;
summary = cachedRow.summary || '';
guesses = JSON.parse(cachedRow.guesses_json || '[]');
matches = JSON.parse(cachedRow.matches_json || '[]');
} else {
// 落盘图片
const ext = EXT[mimetype] || '.jpg';
filename = `idf-${Date.now()}-${randomBytes(5).toString('hex')}${ext}`;
writeFileSync(join(uploadDir(), filename), buffer);
const dataUrl = `data:${mimetype};base64,${buffer.toString('base64')}`;
const r = await this.runModel(dataUrl);
configured = r.configured;
error = r.error;
summary = r.summary;
guesses = r.guesses;
matches = r.matches;
}
const top = guesses[0];
const info = this.wdb.db
.prepare(
`INSERT INTO identifications
(user_id, filename, image_hash, summary, guesses_json, matches_json, top_code, top_name, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
userId,
filename,
hash,
summary,
JSON.stringify(guesses),
JSON.stringify(matches),
top?.model_code ?? '',
top?.name ?? '',
error,
);
const row: any = this.wdb.db
.prepare('SELECT * FROM identifications WHERE id = ?')
.get(Number(info.lastInsertRowid));
return { ...this.toPublic(row), cached, configured };
}
list(userId: number): Identification[] {
const rows = this.wdb.db
.prepare('SELECT * FROM identifications WHERE user_id = ? ORDER BY id DESC')
.all(userId) as any[];
return rows.map((r) => this.toPublic(r));
}
updateNote(userId: number, id: number, note: string): Identification {
const row: any = this.wdb.db
.prepare('SELECT * FROM identifications WHERE id = ? AND user_id = ?')
.get(id, userId);
if (!row) throw new NotFoundException('记录不存在');
this.wdb.db
.prepare('UPDATE identifications SET note = ? WHERE id = ?')
.run((note ?? '').slice(0, 500), id);
return this.toPublic(
this.wdb.db.prepare('SELECT * FROM identifications WHERE id = ?').get(id),
);
}
remove(userId: number, id: number): { ok: boolean } {
const row: any = this.wdb.db
.prepare('SELECT * FROM identifications WHERE id = ? AND user_id = ?')
.get(id, userId);
if (!row) throw new NotFoundException('记录不存在');
this.wdb.db.prepare('DELETE FROM identifications WHERE id = ?').run(id);
// 仅当没有其它记录共用该图片文件(缓存去重)时删除文件
const others = this.wdb.db
.prepare('SELECT 1 FROM identifications WHERE filename = ? LIMIT 1')
.get(row.filename);
if (!others) {
try {
unlinkSync(join(uploadDir(), row.filename));
} catch {
/* 文件可能已不存在 */
}
}
return { ok: true };
}
private toPublic(row: any): Identification {
return {
id: row.id,
url: `${PUBLIC_PREFIX}/${row.filename}`,
summary: row.summary || '',
guesses: JSON.parse(row.guesses_json || '[]'),
matches: JSON.parse(row.matches_json || '[]'),
topCode: row.top_code || '',
topName: row.top_name || '',
note: row.note || '',
error: row.error || '',
cached: false,
configured: true,
createdAt: row.created_at,
};
}
/** 调用通义千问视觉模型并解析候选 + 匹配图鉴库。*/
private async runModel(dataUrl: string): Promise<{
configured: boolean;
summary: string;
guesses: Guess[];
matches: ModelMatch[];
error: string;
}> {
const key = process.env.DASHSCOPE_API_KEY;
if (!key) {
return {
configured: false,
summary: '',
guesses: [],
matches: [],
error:
'AI 识图未配置:请在 apps/api/.env 设置 DASHSCOPE_API_KEY(通义千问 DashScope 密钥)后重启后端。',
};
}
const prompt =
'这是一张中国铁路机车或动车组的照片。请作为资深铁路机车识别专家,判断它最可能是哪一(几)种车型。\n' +
'只输出 JSON(不要任何额外文字、不要 markdown 代码块),格式:\n' +
'{"summary":"一句话总体判断","guesses":[{"model_code":"型号代码,如 HXD1/CR400AF/DF4/SS8/前进型","name":"中文名称","confidence":0.0到1.0之间的数,"reason":"判断依据(车头/涂装/受电弓/轮式等外观特征)"}]}\n' +
'最多给 3 个候选,按可能性从高到低排序。若无法判断也要给出最接近的猜测。';
let content = '';
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: dataUrl } },
],
},
],
}),
});
const j: any = await res.json().catch(() => ({}));
if (!res.ok) {
const m = j?.error?.message || j?.message || `HTTP ${res.status}`;
return { configured: true, summary: '', guesses: [], matches: [], error: `AI 服务返回错误:${m}` };
}
const raw = j?.choices?.[0]?.message?.content;
content = Array.isArray(raw)
? raw.map((c: any) => c?.text || '').join('')
: String(raw ?? '');
} catch (e: any) {
this.logger.error(`identify 调用失败: ${e?.message || e}`);
return { configured: true, summary: '', guesses: [], matches: [], error: `调用 AI 服务失败:${e?.message || e}` };
}
const parsed = this.parse(content);
const matches = this.matchModels(parsed.guesses);
return { configured: true, summary: parsed.summary, guesses: parsed.guesses, matches, error: '' };
}
/** 从模型文本中解析 JSON(容错:截取首个 {...} 块)。*/
private parse(text: string): { summary: string; guesses: Guess[] } {
const block = text.match(/\{[\s\S]*\}/);
if (block) {
try {
const o = JSON.parse(block[0]);
const guesses: Guess[] = Array.isArray(o.guesses)
? o.guesses.slice(0, 3).map((g: any) => ({
model_code: String(g.model_code ?? g.code ?? '').trim(),
name: String(g.name ?? '').trim(),
confidence: Math.max(0, Math.min(1, Number(g.confidence) || 0)),
reason: String(g.reason ?? '').trim(),
}))
: [];
return { summary: String(o.summary ?? '').trim(), guesses };
} catch {
/* 解析失败则回退原文 */
}
}
return { summary: text.trim().slice(0, 300), guesses: [] };
}
/** 把 AI 猜测的型号匹配到图鉴库,给出可点击的详情链接。*/
private matchModels(guesses: Guess[]): ModelMatch[] {
const stmt = this.db.db.prepare(
`SELECT m.id, m.model_code, c.name AS category
FROM model m JOIN category c ON c.id = m.category_id
WHERE m.model_code LIKE ? OR m.full_name LIKE ? OR m.series LIKE ?
LIMIT 4`,
);
const out = new Map<number, ModelMatch>();
for (const g of guesses) {
for (const raw of [g.model_code, g.name].filter(Boolean)) {
const latin = (raw.match(/[A-Za-z0-9]+/g) || []).join('');
const key = latin || raw;
if (key.length < 2) continue;
const rows = stmt.all(`%${key}%`, `%${key}%`, `%${key}%`) as ModelMatch[];
for (const r of rows) if (!out.has(r.id)) out.set(r.id, r);
}
}
return [...out.values()].slice(0, 8);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import * as express from 'express';
import { AppModule } from './app.module';
import { uploadDir, PUBLIC_PREFIX } from './photos/uploads';
// 加载 apps/api/.envNode 20.12+/22 内置;缺失时忽略)
try {
(process as any).loadEnvFile?.();
} catch {
/* 没有 .env 文件时忽略 */
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: false,
}),
);
app.enableCors();
// 静态服务上传的图片(共享图库)
app.use(PUBLIC_PREFIX, express.static(uploadDir()));
const port = process.env.PORT || 3001;
await app.listen(port);
new Logger('Bootstrap').log(`API 已启动: http://localhost:${port}/api/health`);
}
bootstrap();
+29
View File
@@ -0,0 +1,29 @@
import {
Controller,
Get,
Param,
ParseIntPipe,
Query,
} from '@nestjs/common';
import { ModelsService } from './models.service';
import { QueryModelsDto } from './query-models.dto';
@Controller('api/models')
export class ModelsController {
constructor(private readonly models: ModelsService) {}
@Get()
list(@Query() query: QueryModelsDto) {
return this.models.list(query);
}
@Get('families')
families(@Query('category') category?: string) {
return this.models.families(category);
}
@Get(':id')
detail(@Param('id', ParseIntPipe) id: number) {
return this.models.getById(id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ModelsController } from './models.controller';
import { ModelsService } from './models.service';
@Module({
controllers: [ModelsController],
providers: [ModelsService],
exports: [ModelsService],
})
export class ModelsModule {}
@@ -0,0 +1,84 @@
import Database from 'better-sqlite3';
import { NotFoundException } from '@nestjs/common';
import { ModelsService } from './models.service';
import { QueryModelsDto } from './query-models.dto';
import { seedDatabase } from '../testing/seed';
/** ModelsService 单元测试 — 对应 T-1.3 UT。*/
describe('ModelsService', () => {
let service: ModelsService;
let db: Database.Database;
beforeAll(() => {
db = new Database(':memory:');
seedDatabase(db);
const wdb = new Database(':memory:');
wdb.exec(
`CREATE TABLE model_overrides (model_id INTEGER, field TEXT, value TEXT,
revision_id INTEGER, updated_at TEXT, PRIMARY KEY(model_id, field));
CREATE TABLE photos (id INTEGER PRIMARY KEY AUTOINCREMENT, model_id INTEGER,
uploader_id INTEGER, filename TEXT, caption TEXT DEFAULT '',
status TEXT DEFAULT 'confirmed', source_url TEXT DEFAULT '',
author TEXT DEFAULT '', license TEXT DEFAULT '',
featured INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT '');`,
);
service = new ModelsService({ db } as any, { db: wdb } as any);
});
afterAll(() => db.close());
const q = (over: Partial<QueryModelsDto> = {}): QueryModelsDto =>
Object.assign(new QueryModelsDto(), over);
it('返回分页结构', () => {
const res = service.list(q());
expect(res.total).toBe(2);
expect(res.page).toBe(1);
expect(res.items.length).toBe(2);
});
it('按分类名模糊筛选', () => {
const res = service.list(q({ category: '电力' }));
expect(res.total).toBe(1);
expect(res.items[0].model_code).toBe('HXD1型');
});
it('按年代区间筛选', () => {
const res = service.list(q({ yearFrom: 2010 }));
expect(res.total).toBe(1);
expect(res.items[0].model_code).toBe('CR400AF');
});
it('按时速区间筛选', () => {
const res = service.list(q({ speedMin: 300 }));
expect(res.total).toBe(1);
expect(res.items[0].model_code).toBe('CR400AF');
});
it('关键字搜索', () => {
const res = service.list(q({ q: 'HXD' }));
expect(res.total).toBe(1);
});
it('排序:按时速降序', () => {
const res = service.list(q({ sort: 'max_speed_value', order: 'desc' }));
expect(res.items[0].model_code).toBe('CR400AF');
});
it('空结果不报错', () => {
const res = service.list(q({ manufacturer: '不存在' }));
expect(res.total).toBe(0);
expect(res.items).toEqual([]);
});
it('详情含解析后的 raw 字段', () => {
const list = service.list(q({ category: '电力' }));
const detail = service.getById(list.items[0].id);
expect(detail.model_code).toBe('HXD1型');
expect(detail.raw).toBeDefined();
});
it('详情不存在抛 404', () => {
expect(() => service.getById(99999)).toThrow(NotFoundException);
});
});
+188
View File
@@ -0,0 +1,188 @@
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 })),
}));
}
}
+82
View File
@@ -0,0 +1,82 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
/** 车型列表查询参数 — 对应 FR-1.1 / FR-1.2。*/
export const SORTABLE_FIELDS = [
'first_year',
'last_year',
'max_speed_value',
'weight_value',
'model_code',
'id',
] as const;
export class QueryModelsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
pageSize = 20;
/** 分类:可传 category_id(数字)或分类名(模糊)。*/
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
yearFrom?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
yearTo?: number;
@IsOptional()
@Type(() => Number)
speedMin?: number;
@IsOptional()
@Type(() => Number)
speedMax?: number;
@IsOptional()
@IsString()
manufacturer?: string;
@IsOptional()
@IsString()
country?: string;
@IsOptional()
@IsString()
status?: string;
/** 快速关键字(型号/全称模糊)。*/
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsIn(SORTABLE_FIELDS as unknown as string[])
sort: string = 'first_year';
@IsOptional()
@IsIn(['asc', 'desc'])
order: 'asc' | 'desc' = 'asc';
}
+101
View File
@@ -0,0 +1,101 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { randomBytes } from 'crypto';
import { PhotosService } from './photos.service';
import { uploadDir } from './uploads';
import {
CurrentUser,
JwtAuthGuard,
Roles,
RolesGuard,
type JwtPayload,
} from '../auth/roles';
const ALLOWED = /\.(jpe?g|png|webp|gif)$/i;
const storage = diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir()),
filename: (_req, file, cb) =>
cb(null, `${Date.now()}-${randomBytes(6).toString('hex')}${extname(file.originalname).toLowerCase()}`),
});
@Controller('api')
export class PhotosController {
constructor(private readonly photos: PhotosService) {}
/** 公共图库:某车型的全部照片(所有人可看)。*/
@Get('models/:id/photos')
list(@Param('id', ParseIntPipe) id: number) {
return this.photos.listForModel(id);
}
/** 管理员候选审图:全站待确认照片。*/
@Get('photos/candidates')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
candidates() {
return this.photos.listCandidates();
}
/** 管理员上传到共享图库。*/
@Post('models/:id/photos')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@UseInterceptors(
FileInterceptor('file', {
storage,
limits: { fileSize: 8 * 1024 * 1024 },
fileFilter: (_req, file, cb) =>
ALLOWED.test(file.originalname)
? cb(null, true)
: cb(new BadRequestException('仅支持 jpg/png/webp/gif'), false),
}),
)
upload(
@Param('id', ParseIntPipe) id: number,
@UploadedFile() file: Express.Multer.File,
@Body('caption') caption: string,
@CurrentUser() user: JwtPayload,
) {
if (!file) throw new BadRequestException('未收到文件');
return this.photos.add(id, user.sub, file.filename, {
caption: caption ?? '',
status: 'confirmed',
});
}
@Post('photos/:pid/confirm')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
confirm(@Param('pid', ParseIntPipe) pid: number) {
return this.photos.confirm(pid);
}
@Post('photos/:pid/feature')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
feature(@Param('pid', ParseIntPipe) pid: number) {
return this.photos.setFeatured(pid);
}
@Delete('photos/:pid')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
remove(@Param('pid', ParseIntPipe) pid: number) {
return this.photos.remove(pid);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PhotosService } from './photos.service';
import { PhotosController } from './photos.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [PhotosController],
providers: [PhotosService],
})
export class PhotosModule {}
+142
View File
@@ -0,0 +1,142 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { unlinkSync } from 'fs';
import { join } from 'path';
import { WritableDbService } from '../db/writable-db.service';
import { DatabaseService } from '../db/database.service';
import { PUBLIC_PREFIX, uploadDir } from './uploads';
export interface AddPhotoOpts {
caption?: string;
status?: 'candidate' | 'confirmed';
sourceUrl?: string;
author?: string;
license?: string;
}
@Injectable()
export class PhotosService {
constructor(
private readonly wdb: WritableDbService,
private readonly ro: DatabaseService,
) {}
private toPublic(row: any) {
return {
id: row.id,
modelId: row.model_id,
url: `${PUBLIC_PREFIX}/${row.filename}`,
caption: row.caption,
status: row.status,
featured: !!row.featured,
sourceUrl: row.source_url,
author: row.author,
license: row.license,
uploaderId: row.uploader_id,
uploaderName: row.uploader_name,
createdAt: row.created_at,
};
}
add(modelId: number, uploaderId: number, filename: string, opts: AddPhotoOpts = {}) {
const info = this.wdb.db
.prepare(
`INSERT INTO photos (model_id, uploader_id, filename, caption, status, source_url, author, license)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
modelId,
uploaderId,
filename,
(opts.caption ?? '').trim(),
opts.status ?? 'confirmed',
opts.sourceUrl ?? '',
opts.author ?? '',
opts.license ?? '',
);
return this.get(Number(info.lastInsertRowid));
}
get(id: number) {
const row = this.wdb.db
.prepare(
`SELECT p.*, u.display_name AS uploader_name
FROM photos p JOIN users u ON u.id = p.uploader_id WHERE p.id = ?`,
)
.get(id);
if (!row) throw new NotFoundException('照片不存在');
return this.toPublic(row);
}
listForModel(modelId: number) {
return (
this.wdb.db
.prepare(
`SELECT p.*, u.display_name AS uploader_name
FROM photos p JOIN users u ON u.id = p.uploader_id
WHERE p.model_id = ?
ORDER BY (p.status='confirmed') DESC, p.created_at ASC, p.id ASC`,
)
.all(modelId) as any[]
).map((r) => this.toPublic(r));
}
confirm(id: number) {
const row = this.wdb.db.prepare('SELECT 1 FROM photos WHERE id = ?').get(id);
if (!row) throw new NotFoundException('照片不存在');
this.wdb.db.prepare("UPDATE photos SET status='confirmed' WHERE id = ?").run(id);
return this.get(id);
}
/** 设为该车型封面(同时置为已确认;清掉同车型其它封面标记)。*/
setFeatured(id: number) {
const row: any = this.wdb.db.prepare('SELECT model_id FROM photos WHERE id = ?').get(id);
if (!row) throw new NotFoundException('照片不存在');
const tx = this.wdb.db.transaction(() => {
this.wdb.db.prepare('UPDATE photos SET featured=0 WHERE model_id=?').run(row.model_id);
this.wdb.db
.prepare("UPDATE photos SET featured=1, status='confirmed' WHERE id=?")
.run(id);
});
tx();
return this.get(id);
}
/** 全站候选图(管理员审图页),附车型信息。*/
listCandidates(limit = 300) {
const rows = this.wdb.db
.prepare(
`SELECT p.*, u.display_name AS uploader_name
FROM photos p JOIN users u ON u.id = p.uploader_id
WHERE p.status = 'candidate'
ORDER BY p.model_id, p.id LIMIT ?`,
)
.all(limit) as any[];
if (rows.length === 0) return [];
const ids = [...new Set(rows.map((r) => r.model_id))];
const ph = ids.map(() => '?').join(',');
const models = this.ro.db
.prepare(
`SELECT m.id, m.model_code, c.name AS category
FROM model m JOIN category c ON c.id = m.category_id WHERE m.id IN (${ph})`,
)
.all(...ids) as { id: number; model_code: string; category: string }[];
const map = new Map(models.map((m) => [m.id, m]));
return rows.map((r) => ({
...this.toPublic(r),
modelCode: map.get(r.model_id)?.model_code ?? `#${r.model_id}`,
category: map.get(r.model_id)?.category ?? '',
}));
}
remove(id: number) {
const row: any = this.wdb.db.prepare('SELECT * FROM photos WHERE id = ?').get(id);
if (!row) throw new NotFoundException('照片不存在');
try {
unlinkSync(join(uploadDir(), row.filename));
} catch {
/* 文件可能已不存在,忽略 */
}
this.wdb.db.prepare('DELETE FROM photos WHERE id = ?').run(id);
return { ok: true };
}
}
+13
View File
@@ -0,0 +1,13 @@
import { mkdirSync } from 'fs';
import { join, resolve } from 'path';
/** 图片上传存储目录(可用 UPLOAD_DIR 覆盖,测试用)。*/
export function uploadDir(): string {
const dir =
process.env.UPLOAD_DIR ||
resolve(join(__dirname, '..', '..', '..', '..', 'app', 'data', 'uploads'));
mkdirSync(dir, { recursive: true });
return dir;
}
export const PUBLIC_PREFIX = '/uploads';
+13
View File
@@ -0,0 +1,13 @@
import { Controller, Get, Query } from '@nestjs/common';
import { SearchService } from './search.service';
@Controller('api/search')
export class SearchController {
constructor(private readonly search: SearchService) {}
@Get()
doSearch(@Query('q') q: string, @Query('limit') limit?: string) {
const lim = limit ? Math.min(50, Math.max(1, Number(limit))) : 20;
return this.search.search(q, lim);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
@Module({
controllers: [SearchController],
providers: [SearchService],
})
export class SearchModule {}
@@ -0,0 +1,41 @@
import Database from 'better-sqlite3';
import { SearchService } from './search.service';
import { seedDatabase } from '../testing/seed';
/** SearchService 单元测试 — 对应 T-1.4 UT。*/
describe('SearchService', () => {
let service: SearchService;
let db: Database.Database;
beforeAll(() => {
db = new Database(':memory:');
seedDatabase(db);
service = new SearchService({ db } as any);
});
afterAll(() => db.close());
it('空查询返回空结果', () => {
expect(service.search('').results).toEqual([]);
});
it('按型号命中', () => {
const res = service.search('CR400');
expect(res.results.length).toBeGreaterThan(0);
expect(res.results[0].model_code).toBe('CR400AF');
});
it('按生产商命中', () => {
const res = service.search('株洲');
expect(res.results.some((r) => r.model_code === 'HXD1型')).toBe(true);
});
it('精确匹配排在前', () => {
const res = service.search('CR400AF');
expect(res.results[0].rank).toBe(0);
});
it('特殊字符不崩溃', () => {
expect(() => service.search("%_'")).not.toThrow();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../db/database.service';
/**
* 搜索服务 — 对应 FR-1.3。
* 当前为 SQLite LIKE 实现(540 车型 / 307 个体规模足够),
* 按匹配位置排序(精确 > 前缀 > 包含)。
* Phase 1B 后续可替换为 Meilisearch(外部检索服务,本期延后)。
*/
@Injectable()
export class SearchService {
constructor(private readonly database: DatabaseService) {}
search(q: string, limit = 20): { query: string; results: any[] } {
const query = (q || '').trim();
if (!query) {
return { query, results: [] };
}
const like = `%${query}%`;
const prefix = `${query}%`;
const db = this.database.db;
const models = db
.prepare(
`SELECT m.id, m.model_code, m.full_name, m.manufacturer, m.series,
c.name AS category,
CASE
WHEN m.model_code = ? THEN 0
WHEN m.model_code LIKE ? THEN 1
WHEN m.model_code LIKE ? THEN 2
ELSE 3
END AS rank
FROM model m JOIN category c ON c.id = m.category_id
WHERE m.model_code LIKE ? OR m.full_name LIKE ?
OR m.manufacturer LIKE ? OR m.series LIKE ?
ORDER BY rank ASC, m.first_year IS NULL, m.first_year ASC
LIMIT ?`,
)
.all(query, prefix, like, like, like, like, like, limit)
.map((r: any) => ({ type: 'model', ...r }));
return { query, results: models };
}
}
@@ -0,0 +1,75 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
IsLatitude,
IsLongitude,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { SightingsService } from './sightings.service';
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
class CreateSightingDto {
@Type(() => Number) @IsLatitude()
lat!: number;
@Type(() => Number) @IsLongitude()
lng!: number;
@IsOptional() @IsString() @MaxLength(60)
station?: string;
@IsOptional() @IsString() @MaxLength(20)
spottedAt?: string;
@IsOptional() @IsString() @MaxLength(40)
carNumber?: string;
@IsOptional() @IsString() @MaxLength(300)
description?: string;
}
@Controller('api')
export class SightingsController {
constructor(private readonly sightings: SightingsService) {}
@Get('models/:id/sightings')
forModel(@Param('id', ParseIntPipe) id: number) {
return this.sightings.listForModel(id);
}
@Post('models/:id/sightings')
@UseGuards(JwtAuthGuard)
create(
@Param('id', ParseIntPipe) id: number,
@Body() dto: CreateSightingDto,
@CurrentUser() user: JwtPayload,
) {
return this.sightings.create(user.sub, id, dto);
}
@Get('sightings/recent')
recent(@Query('limit') limit?: string) {
return this.sightings.recent(limit ? Number(limit) : 30);
}
@Get('sightings/map')
map() {
return this.sightings.mapPoints();
}
@Get('sightings/spots')
spots() {
return this.sightings.spots();
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SightingsService } from './sightings.service';
import { SightingsController } from './sightings.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [SightingsController],
providers: [SightingsService],
})
export class SightingsModule {}
+125
View File
@@ -0,0 +1,125 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WritableDbService } from '../db/writable-db.service';
import { DatabaseService } from '../db/database.service';
export interface CreateSighting {
lat: number;
lng: number;
station?: string;
spottedAt?: string;
carNumber?: string;
description?: string;
}
@Injectable()
export class SightingsService {
constructor(
private readonly wdb: WritableDbService,
private readonly ro: DatabaseService,
) {}
/** 给 sighting 行补充 model_code / category(来自只读库)。*/
private enrich(rows: any[]): any[] {
if (rows.length === 0) return rows;
const ids = [...new Set(rows.map((r) => r.model_id))];
const ph = ids.map(() => '?').join(',');
const models = this.ro.db
.prepare(
`SELECT m.id, m.model_code, c.name AS category
FROM model m JOIN category c ON c.id = m.category_id
WHERE m.id IN (${ph})`,
)
.all(...ids) as { id: number; model_code: string; category: string }[];
const map = new Map(models.map((m) => [m.id, m]));
return rows.map((r) => ({
...r,
model_code: map.get(r.model_id)?.model_code ?? `#${r.model_id}`,
category: map.get(r.model_id)?.category ?? '',
}));
}
create(userId: number, modelId: number, dto: CreateSighting) {
const exists = this.ro.db.prepare('SELECT 1 FROM model WHERE id = ?').get(modelId);
if (!exists) throw new NotFoundException(`车型 ${modelId} 不存在`);
const info = this.wdb.db
.prepare(
`INSERT INTO sightings (model_id, user_id, car_number, lat, lng, station, spotted_at, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
modelId,
userId,
(dto.carNumber ?? '').trim(),
dto.lat,
dto.lng,
(dto.station ?? '').trim(),
(dto.spottedAt ?? '').trim(),
(dto.description ?? '').trim(),
);
return this.getOne(Number(info.lastInsertRowid));
}
private getOne(id: number) {
const row = this.wdb.db
.prepare(
`SELECT s.*, u.display_name AS user_name
FROM sightings s JOIN users u ON u.id = s.user_id WHERE s.id = ?`,
)
.get(id);
return this.enrich([row])[0];
}
listForModel(modelId: number) {
const rows = this.wdb.db
.prepare(
`SELECT s.*, u.display_name AS user_name
FROM sightings s JOIN users u ON u.id = s.user_id
WHERE s.model_id = ? ORDER BY s.created_at DESC`,
)
.all(modelId);
return this.enrich(rows as any[]);
}
recent(limit = 30) {
const rows = this.wdb.db
.prepare(
`SELECT s.*, u.display_name AS user_name
FROM sightings s JOIN users u ON u.id = s.user_id
ORDER BY s.created_at DESC LIMIT ?`,
)
.all(Math.min(100, Math.max(1, limit)));
return this.enrich(rows as any[]);
}
mapPoints() {
const rows = this.wdb.db
.prepare(
`SELECT s.id, s.model_id, s.lat, s.lng, s.station, s.car_number
FROM sightings s ORDER BY s.created_at DESC LIMIT 1000`,
)
.all();
return this.enrich(rows as any[]);
}
/** 拍车攻略:按车站聚合,列出该站出现过的车型。*/
spots() {
const rows = this.wdb.db
.prepare(
`SELECT station, model_id, COUNT(*) AS n
FROM sightings
WHERE station != ''
GROUP BY station, model_id`,
)
.all() as { station: string; model_id: number; n: number }[];
const enriched = this.enrich(rows as any[]);
const byStation = new Map<string, { station: string; total: number; models: any[] }>();
for (const r of enriched) {
if (!byStation.has(r.station))
byStation.set(r.station, { station: r.station, total: 0, models: [] });
const e = byStation.get(r.station)!;
e.total += r.n;
e.models.push({ model_id: r.model_id, model_code: r.model_code, count: r.n });
}
return [...byStation.values()].sort((a, b) => b.total - a.total);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Controller, Get, Header } from '@nestjs/common';
import { StatsService } from './stats.service';
import { DatabaseService } from '../db/database.service';
@Controller('api')
export class StatsController {
constructor(
private readonly stats: StatsService,
private readonly db: DatabaseService,
) {}
@Get('stats')
overview() {
return this.stats.overview();
}
/** 开放数据导出(JSON)。*/
@Get('export/models.json')
exportJson() {
return this.db.db
.prepare(
`SELECT m.id, m.model_code, m.series, m.full_name, 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, c.name AS category
FROM model m JOIN category c ON c.id = m.category_id
ORDER BY m.id`,
)
.all();
}
/** 开放数据导出(CSV)。*/
@Get('export/models.csv')
@Header('Content-Type', 'text/csv; charset=utf-8')
@Header('Content-Disposition', 'attachment; filename="models.csv"')
exportCsv(): string {
const rows = this.exportJson() as Record<string, unknown>[];
if (rows.length === 0) return '';
const cols = Object.keys(rows[0]);
const esc = (v: unknown) => {
const s = v == null ? '' : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const head = cols.join(',');
const body = rows.map((r) => cols.map((c) => esc(r[c])).join(',')).join('\n');
return `${head}\n${body}\n`;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { StatsService } from './stats.service';
import { StatsController } from './stats.controller';
@Module({
controllers: [StatsController],
providers: [StatsService],
})
export class StatsModule {}
+50
View File
@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../db/database.service';
@Injectable()
export class StatsService {
constructor(private readonly db: DatabaseService) {}
overview() {
const d = this.db.db;
const models = (d.prepare('SELECT COUNT(*) n FROM model').get() as any).n;
const units = (d.prepare('SELECT COUNT(*) n FROM unit').get() as any).n;
const categories = (d.prepare('SELECT COUNT(*) n FROM category').get() as any).n;
const byCategory = d
.prepare(
`SELECT c.name AS label, COUNT(*) AS count
FROM model m JOIN category c ON c.id = m.category_id
GROUP BY c.name ORDER BY count DESC`,
)
.all();
const byDecade = d
.prepare(
`SELECT (m.first_year/10)*10 AS decade, COUNT(*) AS count
FROM model m WHERE m.first_year IS NOT NULL
GROUP BY decade ORDER BY decade`,
)
.all();
const speedByDecade = d
.prepare(
`SELECT (m.first_year/10)*10 AS decade,
MAX(m.max_speed_value) AS maxSpeed,
ROUND(AVG(m.max_speed_value)) AS avgSpeed
FROM model m
WHERE m.first_year IS NOT NULL AND m.max_speed_value IS NOT NULL
GROUP BY decade ORDER BY decade`,
)
.all();
const byCountryType = d
.prepare(
`SELECT COALESCE(NULLIF(country_type,''),'未知') AS label, COUNT(*) AS count
FROM model GROUP BY label ORDER BY count DESC`,
)
.all();
return { totals: { models, units, categories }, byCategory, byDecade, speedByDecade, byCountryType };
}
}
+53
View File
@@ -0,0 +1,53 @@
import Database from 'better-sqlite3';
import { readFileSync } from 'fs';
import { join } from 'path';
/** 读取 ETL 的 schema.sql 并在给定 db 上建表 + 注入测试数据。*/
export function seedDatabase(db: Database.Database): void {
const schemaPath = join(
__dirname,
'..',
'..',
'..',
'..',
'app',
'etl',
'schema.sql',
);
db.exec(readFileSync(schemaPath, 'utf-8'));
const cat = db.prepare(
'INSERT INTO category(name, subcat) VALUES (?, ?)',
);
const elec = cat.run('电力机车', '').lastInsertRowid as number;
const emu = cat.run('动车组', '复兴号').lastInsertRowid as number;
const insp = cat.run('检测车', '普速检测').lastInsertRowid as number;
const model = db.prepare(
`INSERT INTO model(category_id, series, model_code, full_name, manufacturer,
country, country_type, first_year, last_year, status, usage,
max_speed_value, max_speed_unit, weight_value, weight_unit, raw_json)
VALUES (@category_id, @series, @model_code, @full_name, @manufacturer,
@country, @country_type, @first_year, @last_year, @status, @usage,
@max_speed_value, @max_speed_unit, @weight_value, @weight_unit, @raw_json)`,
);
model.run({
category_id: elec, series: '和谐', model_code: 'HXD1型', full_name: '',
manufacturer: '中车株洲', country: '中国', country_type: '国产',
first_year: 2006, last_year: null, status: '现役', usage: '干线货运',
max_speed_value: 120, max_speed_unit: 'km/h', weight_value: 150,
weight_unit: 't', raw_json: JSON.stringify({ model_code: 'HXD1型' }),
});
model.run({
category_id: emu, series: '', model_code: 'CR400AF', full_name: '复兴号',
manufacturer: '中车四方', country: '中国', country_type: '国产',
first_year: 2017, last_year: null, status: '现役', usage: '高速客运',
max_speed_value: 350, max_speed_unit: 'km/h', weight_value: null,
weight_unit: '', raw_json: JSON.stringify({ model_code: 'CR400AF' }),
});
db.prepare(
`INSERT INTO unit(category_id, car_number, model_name, function, depot, status)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(insp, 'SYJZ-0001', '移动式线路动态加载试验车', '试验车', '国铁', '半封存');
}
+26
View File
@@ -0,0 +1,26 @@
import { Controller, Get, Query } from '@nestjs/common';
import { UnitsService } from './units.service';
@Controller('api/units')
export class UnitsController {
constructor(private readonly units: UnitsService) {}
@Get()
list(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('category') category?: string,
@Query('depot') depot?: string,
@Query('status') status?: string,
@Query('q') q?: string,
) {
return this.units.list({
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
category,
depot,
status,
q,
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UnitsController } from './units.controller';
import { UnitsService } from './units.service';
@Module({
controllers: [UnitsController],
providers: [UnitsService],
})
export class UnitsModule {}
+67
View File
@@ -0,0 +1,67 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../db/database.service';
@Injectable()
export class UnitsService {
constructor(private readonly database: DatabaseService) {}
list(opts: {
page?: number;
pageSize?: number;
category?: string;
depot?: string;
status?: string;
q?: string;
}): { total: number; page: number; pageSize: number; items: any[] } {
const page = Math.max(1, Number(opts.page) || 1);
const pageSize = Math.min(100, Math.max(1, Number(opts.pageSize) || 20));
const conds: string[] = [];
const params: any[] = [];
if (opts.category) {
if (/^\d+$/.test(opts.category)) {
conds.push('u.category_id = ?');
params.push(Number(opts.category));
} else {
conds.push('c.name LIKE ?');
params.push(`%${opts.category}%`);
}
}
if (opts.depot) {
conds.push('u.depot LIKE ?');
params.push(`%${opts.depot}%`);
}
if (opts.status) {
conds.push('u.status = ?');
params.push(opts.status);
}
if (opts.q) {
conds.push('(u.car_number LIKE ? OR u.model_name LIKE ?)');
params.push(`%${opts.q}%`, `%${opts.q}%`);
}
const clause = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const db = this.database.db;
const total = (
db
.prepare(
`SELECT COUNT(*) AS n FROM unit u
JOIN category c ON c.id = u.category_id ${clause}`,
)
.get(...params) as { n: number }
).n;
const items = db
.prepare(
`SELECT u.id, u.car_number, u.model_name, u.function, u.depot,
u.livery, u.status, u.location, c.name AS category, c.subcat
FROM unit u JOIN category c ON c.id = u.category_id
${clause}
ORDER BY u.id ASC
LIMIT ? OFFSET ?`,
)
.all(...params, pageSize, (page - 1) * pageSize);
return { total, page, pageSize, items };
}
}
+524
View File
@@ -0,0 +1,524 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import Database from 'better-sqlite3';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { seedDatabase } from '../src/testing/seed';
/** API 端到端测试 — 对应 T-1.3 / T-1.4 E2E。*/
describe('API (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const dir = mkdtempSync(join(tmpdir(), 'train-e2e-'));
const dbPath = join(dir, 'test.db');
const seedDb = new Database(dbPath);
seedDatabase(seedDb);
seedDb.close();
process.env.DB_PATH = dbPath;
process.env.APP_DB_PATH = join(dir, 'app.db'); // 可写库(用户等)
process.env.ADMIN_EMAIL = 'admin@example.com'; // 引导管理员(审核测试)
process.env.UPLOAD_DIR = join(dir, 'uploads'); // 图片上传目录(隔离)
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
await app.init();
});
afterAll(async () => {
await app.close();
});
it('GET /api/health', async () => {
const res = await request(app.getHttpServer()).get('/api/health').expect(200);
expect(res.body.status).toBe('ok');
expect(res.body.models).toBe(2);
expect(res.body.units).toBe(1);
});
it('GET /api/categories', async () => {
const res = await request(app.getHttpServer())
.get('/api/categories')
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBe(3);
});
it('GET /api/models 列表+分页', async () => {
const res = await request(app.getHttpServer())
.get('/api/models?pageSize=1')
.expect(200);
expect(res.body.total).toBe(2);
expect(res.body.items.length).toBe(1);
});
it('GET /api/models 筛选+排序', async () => {
const res = await request(app.getHttpServer())
.get('/api/models?sort=max_speed_value&order=desc')
.expect(200);
expect(res.body.items[0].model_code).toBe('CR400AF');
});
it('GET /api/models 非法参数被拒', async () => {
await request(app.getHttpServer())
.get('/api/models?pageSize=99999')
.expect(400);
});
it('GET /api/models/:id 详情', async () => {
const list = await request(app.getHttpServer()).get('/api/models?q=HXD');
const id = list.body.items[0].id;
const res = await request(app.getHttpServer())
.get(`/api/models/${id}`)
.expect(200);
expect(res.body.model_code).toBe('HXD1型');
});
it('GET /api/models/:id 不存在 404', async () => {
await request(app.getHttpServer()).get('/api/models/99999').expect(404);
});
it('GET /api/search', async () => {
const res = await request(app.getHttpServer())
.get('/api/search?q=CR400')
.expect(200);
expect(res.body.results[0].model_code).toBe('CR400AF');
});
it('GET /api/units 列表', async () => {
const res = await request(app.getHttpServer())
.get('/api/units')
.expect(200);
expect(res.body.total).toBe(1);
expect(res.body.items[0].car_number).toBe('SYJZ-0001');
});
// ===== T-2.1 账户体系 =====
const cred = { email: 'tester@example.com', password: 'secret123', displayName: '测试员' };
let token = '';
it('POST /api/auth/register 注册', async () => {
const res = await request(app.getHttpServer())
.post('/api/auth/register')
.send(cred)
.expect(201);
expect(res.body.token).toBeTruthy();
expect(res.body.user.email).toBe('tester@example.com');
expect(res.body.user.role).toBe('user');
token = res.body.token;
});
it('POST /api/auth/register 重复邮箱 409', async () => {
await request(app.getHttpServer())
.post('/api/auth/register')
.send(cred)
.expect(409);
});
it('POST /api/auth/register 非法输入 400', async () => {
await request(app.getHttpServer())
.post('/api/auth/register')
.send({ email: 'bad', password: '1', displayName: '' })
.expect(400);
});
it('POST /api/auth/login 正确/错误', async () => {
await request(app.getHttpServer())
.post('/api/auth/login')
.send({ email: cred.email, password: cred.password })
.expect(201);
await request(app.getHttpServer())
.post('/api/auth/login')
.send({ email: cred.email, password: 'wrongpass' })
.expect(401);
});
it('GET /api/auth/me 需要 token', async () => {
await request(app.getHttpServer()).get('/api/auth/me').expect(401);
const res = await request(app.getHttpServer())
.get('/api/auth/me')
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(res.body.email).toBe('tester@example.com');
});
// ===== T-2.2 / T-2.3 编辑·修订(图鉴仅管理员可编辑)=====
let adminToken = '';
it('普通用户提交编辑被拒 403(仅管理员可编辑图鉴)', async () => {
await request(app.getHttpServer())
.post('/api/models/1/revisions')
.set('Authorization', `Bearer ${token}`)
.send({ changes: { usage: '干线货运(实测)' } })
.expect(403);
});
it('未登录提交编辑 401', async () => {
await request(app.getHttpServer())
.post('/api/models/1/revisions')
.send({ changes: { usage: 'x' } })
.expect(401);
});
it('管理员编辑 → 自动通过 + 覆盖生效 + 计分', async () => {
const reg = await request(app.getHttpServer())
.post('/api/auth/register')
.send({ email: 'admin@example.com', password: 'secret123', displayName: '管理员' })
.expect(201);
adminToken = reg.body.token;
expect(reg.body.user.role).toBe('admin');
const res = await request(app.getHttpServer())
.post('/api/models/1/revisions')
.set('Authorization', `Bearer ${adminToken}`)
.send({ changes: { usage: '干线货运(实测)' }, note: '管理员维护' })
.expect(201);
expect(res.body.status).toBe('approved');
const detail = await request(app.getHttpServer()).get('/api/models/1').expect(200);
expect(detail.body.usage).toBe('干线货运(实测)');
expect(detail.body.overridden).toContain('usage');
});
it('修订历史可见', async () => {
const res = await request(app.getHttpServer())
.get('/api/models/1/revisions')
.expect(200);
expect(res.body.length).toBeGreaterThanOrEqual(1);
});
it('普通用户访问审核队列 403', async () => {
await request(app.getHttpServer())
.get('/api/revisions/pending')
.set('Authorization', `Bearer ${token}`)
.expect(403);
});
it('管理员编辑模型2立即生效', async () => {
const res = await request(app.getHttpServer())
.post('/api/models/2/revisions')
.set('Authorization', `Bearer ${adminToken}`)
.send({ changes: { status: '封存' } })
.expect(201);
expect(res.body.status).toBe('approved');
const detail = await request(app.getHttpServer()).get('/api/models/2').expect(200);
expect(detail.body.status).toBe('封存');
});
it('字段白名单外/非法值被拒 400', async () => {
await request(app.getHttpServer())
.post('/api/models/1/revisions')
.set('Authorization', `Bearer ${adminToken}`)
.send({ changes: { password_hash: 'hack' } })
.expect(400);
await request(app.getHttpServer())
.post('/api/models/1/revisions')
.set('Authorization', `Bearer ${adminToken}`)
.send({ changes: { first_year: '99' } })
.expect(400);
});
// ===== T-2.4 荣誉与激励 =====
it('GET /api/auth/me 含等级与徽章', async () => {
const res = await request(app.getHttpServer())
.get('/api/auth/me')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(res.body.level).toBeDefined();
expect(res.body.level.title).toBeTruthy();
expect(Array.isArray(res.body.badges)).toBe(true);
expect(res.body.approvedCount).toBeGreaterThanOrEqual(1); // 管理员有被采纳修订
});
it('GET /api/leaderboard 含贡献者与头衔', async () => {
const res = await request(app.getHttpServer())
.get('/api/leaderboard')
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body[0].rank).toBe(1);
expect(res.body[0].title).toBeTruthy();
});
it('认领词条维护 → 出现在维护者列表', async () => {
await request(app.getHttpServer())
.post('/api/models/1/maintainers')
.set('Authorization', `Bearer ${token}`)
.expect(201);
const res = await request(app.getHttpServer())
.get('/api/models/1/maintainers')
.expect(200);
expect(res.body.some((mm: any) => mm.displayName === '测试员')).toBe(true);
});
it('未登录认领维护 401', async () => {
await request(app.getHttpServer())
.post('/api/models/1/maintainers')
.expect(401);
});
// ===== T-3.1/3.2 论坛 + 词条讨论 =====
let threadId = 0;
it('创建帖子并回复', async () => {
const t = await request(app.getHttpServer())
.post('/api/threads')
.set('Authorization', `Bearer ${token}`)
.send({ board: 'general', title: '大家好啊', body: '第一帖' })
.expect(201);
expect(t.body.title).toBe('大家好啊');
threadId = t.body.id;
const r = await request(app.getHttpServer())
.post(`/api/threads/${threadId}/replies`)
.set('Authorization', `Bearer ${token}`)
.send({ body: '沙发' })
.expect(201);
expect(r.body.reply_count).toBe(1);
expect(r.body.replies[0].body).toBe('沙发');
});
it('帖子列表按板块', async () => {
const res = await request(app.getHttpServer())
.get('/api/threads?board=general')
.expect(200);
expect(res.body.some((t: any) => t.id === threadId)).toBe(true);
});
it('词条讨论:绑定 model_id', async () => {
await request(app.getHttpServer())
.post('/api/threads')
.set('Authorization', `Bearer ${token}`)
.send({ board: 'entry', modelId: 1, title: 'HXD1 求资料', body: '有铭牌照吗' })
.expect(201);
const res = await request(app.getHttpServer())
.get('/api/threads?modelId=1')
.expect(200);
expect(res.body.length).toBeGreaterThanOrEqual(1);
expect(res.body[0].model_id).toBe(1);
});
it('未登录发帖 401,非法输入 400', async () => {
await request(app.getHttpServer())
.post('/api/threads')
.send({ board: 'general', title: 'x', body: 'y' })
.expect(401);
await request(app.getHttpServer())
.post('/api/threads')
.set('Authorization', `Bearer ${token}`)
.send({ board: 'general', title: 'a', body: '' })
.expect(400);
});
// ===== T-3.3 打卡(sightings=====
it('打卡 → 出现在车型打卡列表与近期动态', async () => {
const res = await request(app.getHttpServer())
.post('/api/models/1/sightings')
.set('Authorization', `Bearer ${token}`)
.send({ lat: 39.9, lng: 116.4, station: '北京站', carNumber: 'HXD1-0001', description: '清晨拍到' })
.expect(201);
expect(res.body.station).toBe('北京站');
expect(res.body.model_code).toBe('HXD1型');
expect(res.body.user_name).toBe('测试员');
const list = await request(app.getHttpServer())
.get('/api/models/1/sightings')
.expect(200);
expect(list.body.length).toBeGreaterThanOrEqual(1);
const recent = await request(app.getHttpServer())
.get('/api/sightings/recent')
.expect(200);
expect(recent.body[0].model_code).toBe('HXD1型');
const map = await request(app.getHttpServer()).get('/api/sightings/map').expect(200);
expect(map.body.some((p: any) => p.station === '北京站')).toBe(true);
});
it('未登录打卡 401,非法坐标 400', async () => {
await request(app.getHttpServer())
.post('/api/models/1/sightings')
.send({ lat: 39.9, lng: 116.4 })
.expect(401);
await request(app.getHttpServer())
.post('/api/models/1/sightings')
.set('Authorization', `Bearer ${token}`)
.send({ lat: 999, lng: 116.4 })
.expect(400);
});
// ===== T-4.2 族谱 / T-4.3 拍车攻略 =====
it('GET /api/models/families 按系列聚合', async () => {
const res = await request(app.getHttpServer())
.get('/api/models/families')
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body[0].series).toBeDefined();
});
it('GET /api/sightings/spots 拍车攻略聚合', async () => {
const res = await request(app.getHttpServer())
.get('/api/sightings/spots')
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
// 前面打卡过北京站
expect(res.body.some((s: any) => s.station === '北京站')).toBe(true);
expect(res.body[0].models[0].model_code).toBeTruthy();
});
// ===== T-5.2 数据大屏 / 开放 API =====
it('GET /api/stats 概览统计', async () => {
const res = await request(app.getHttpServer()).get('/api/stats').expect(200);
expect(res.body.totals.models).toBe(2);
expect(res.body.byCategory.length).toBeGreaterThan(0);
expect(Array.isArray(res.body.speedByDecade)).toBe(true);
});
it('GET /api/export/models.json 开放数据', async () => {
const res = await request(app.getHttpServer())
.get('/api/export/models.json')
.expect(200);
expect(res.body.length).toBe(2);
expect(res.body[0].model_code).toBeTruthy();
});
it('GET /api/export/models.csv 导出 CSV', async () => {
const res = await request(app.getHttpServer())
.get('/api/export/models.csv')
.expect(200);
expect(res.headers['content-type']).toContain('text/csv');
expect(res.text.split('\n')[0]).toContain('model_code');
});
// ===== 共享图库(管理员上传,所有人可看)=====
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);
let photoId = 0;
it('普通用户上传照片 403', async () => {
await request(app.getHttpServer())
.post('/api/models/1/photos')
.set('Authorization', `Bearer ${token}`)
.attach('file', png, 'demo.png')
.expect(403);
});
it('管理员上传照片 → 进入公共图库', async () => {
const res = await request(app.getHttpServer())
.post('/api/models/1/photos')
.set('Authorization', `Bearer ${adminToken}`)
.field('caption', '管理员示例照片')
.attach('file', png, 'demo.png')
.expect(201);
expect(res.body.url).toMatch(/^\/uploads\/.+\.png$/);
expect(res.body.caption).toBe('管理员示例照片');
photoId = res.body.id;
const list = await request(app.getHttpServer())
.get('/api/models/1/photos')
.expect(200);
expect(list.body.some((p: any) => p.id === photoId)).toBe(true);
expect(list.body[0].uploaderName).toBe('管理员');
});
it('非图片被拒 400', async () => {
await request(app.getHttpServer())
.post('/api/models/1/photos')
.set('Authorization', `Bearer ${adminToken}`)
.attach('file', Buffer.from('hello'), 'note.txt')
.expect(400);
});
it('管理员删除照片', async () => {
await request(app.getHttpServer())
.delete(`/api/photos/${photoId}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const list = await request(app.getHttpServer()).get('/api/models/1/photos').expect(200);
expect(list.body.some((p: any) => p.id === photoId)).toBe(false);
});
it('候选照片确认流程', async () => {
const up = await request(app.getHttpServer())
.post('/api/models/3/photos')
.set('Authorization', `Bearer ${adminToken}`)
.attach('file', png, 'demo.png')
.expect(201);
const pid = up.body.id;
expect(up.body.status).toBe('confirmed');
// 直接置为候选(模拟取图脚本入库的 candidate)
const db = new Database(process.env.APP_DB_PATH as string);
db.prepare("UPDATE photos SET status='candidate' WHERE id=?").run(pid);
db.close();
let list = await request(app.getHttpServer()).get('/api/models/3/photos').expect(200);
expect(list.body.find((p: any) => p.id === pid).status).toBe('candidate');
await request(app.getHttpServer())
.post(`/api/photos/${pid}/confirm`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
list = await request(app.getHttpServer()).get('/api/models/3/photos').expect(200);
expect(list.body.find((p: any) => p.id === pid).status).toBe('confirmed');
});
// ===== 封面优先(featured=====
it('设为封面:cover_url 优先 featured,其次最小 id;权限受控', async () => {
// 上传两张确认照片到模型 2
const a = await request(app.getHttpServer())
.post('/api/models/2/photos')
.set('Authorization', `Bearer ${adminToken}`)
.attach('file', png, 'a.png')
.expect(201);
const b = await request(app.getHttpServer())
.post('/api/models/2/photos')
.set('Authorization', `Bearer ${adminToken}`)
.attach('file', png, 'b.png')
.expect(201);
// 默认封面 = 最早一张(a
const findCover = async () => {
const res = await request(app.getHttpServer())
.get('/api/models?pageSize=50')
.expect(200);
return res.body.items.find((m: any) => m.id === 2)?.cover_url as string | null;
};
const aFile = a.body.url.replace('/uploads/', '');
const bFile = b.body.url.replace('/uploads/', '');
expect(await findCover()).toBe(`/uploads/${aFile}`);
// 普通用户设为封面 403
await request(app.getHttpServer())
.post(`/api/photos/${b.body.id}/feature`)
.set('Authorization', `Bearer ${token}`)
.expect(403);
// 管理员把 b 设为封面 → cover_url 切到 b
const feat = await request(app.getHttpServer())
.post(`/api/photos/${b.body.id}/feature`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
expect(feat.body.featured).toBe(true);
expect(feat.body.status).toBe('confirmed');
expect(await findCover()).toBe(`/uploads/${bFile}`);
// 改 featured 到 a → 旧 featured 清除,cover 回到 a
await request(app.getHttpServer())
.post(`/api/photos/${a.body.id}/feature`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
const photos = await request(app.getHttpServer()).get('/api/models/2/photos').expect(200);
expect(photos.body.find((p: any) => p.id === a.body.id).featured).toBe(true);
expect(photos.body.find((p: any) => p.id === b.body.id).featured).toBe(false);
expect(await findCover()).toBe(`/uploads/${aFile}`);
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": ["ts-jest", { "tsconfig": "<rootDir>/../tsconfig.json" }]
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
}
}