chore: 初始化仓库

中华文明全图鉴——文物全图系统(PC Web 地图 + NestJS API + 管理后台)。
含三大 IP(文物南迁北归 / 国宝海外回归 / 博物馆手艺人)、AI 文物对话、
文物地图与详情、以及 demo-video-kit 演示视频生成工具。
This commit is contained in:
selfrelease
2026-06-13 20:55:44 +08:00
commit 2d847e154f
161 changed files with 22629 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
PORT=3002
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/wenwumap
REDIS_URL=redis://localhost:6379
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=7d
OSS_ENDPOINT=http://localhost:9000
OSS_ACCESS_KEY=minioadmin
OSS_SECRET_KEY=minioadmin
OSS_BUCKET=wenwumap
# AI 对话(通义千问 DashScopeOpenAI 兼容模式)
AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
AI_API_KEY=your-dashscope-api-key
AI_MODEL=qwen-plus
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@wenwumap/api",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main",
"lint": "eslint src --ext .ts",
"type-check": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@nestjs/common": "^10.3.10",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.3.10",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.10",
"@nestjs/swagger": "^7.4.0",
"@nestjs/terminus": "^10.2.3",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/testing": "^10.3.10",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/pg": "^8.11.6",
"typescript": "^5.5.3",
"vitest": "^2.1.9"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Body, Controller, Post, Res, UseGuards } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { AiService } from "./ai.service";
import { ChatDto } from "./dto/chat.dto";
import { RateLimitGuard } from "../common/rate-limit.guard";
@ApiTags("ai")
@Controller("ai")
@UseGuards(RateLimitGuard)
export class AiController {
constructor(private readonly ai: AiService) {}
@Post("chat")
@ApiOperation({ summary: "与文物进行 AI 角色对话(SSE 流式输出)" })
async chat(@Body() dto: ChatDto, @Res() res: Response): Promise<void> {
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
try {
await this.ai.streamChat(dto, (token) => {
res.write(`data: ${JSON.stringify({ t: token })}\n\n`);
});
res.write("data: [DONE]\n\n");
} catch (err) {
const message = err instanceof Error ? err.message : "AI 服务异常";
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
} finally {
res.end();
}
}
@Post("suggestions")
@ApiOperation({ summary: "生成 4 个下一步追问建议" })
async suggestions(@Body() dto: ChatDto): Promise<{ suggestions: string[] }> {
const suggestions = await this.ai.getSuggestions(dto);
return { suggestions };
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AiService } from "./ai.service";
import { AiController } from "./ai.controller";
@Module({
providers: [AiService],
controllers: [AiController],
})
export class AiModule {}
+284
View File
@@ -0,0 +1,284 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DatabaseService } from "../database/database.service";
import { ChatDto } from "./dto/chat.dto";
import { parseSuggestions } from "./suggestions.util";
interface ArtifactContext {
name: string;
category: string;
dynasty: string | null;
level: string | null;
material: string | null;
summary: string | null;
story_hook: string | null;
persona_quote: string | null;
current_status: string | null;
institution_name: string | null;
city: string | null;
province: string | null;
country: string | null;
location_type: string | null;
}
const CATEGORY_LABELS: Record<string, string> = {
bronze: "青铜器",
painting_calligraphy: "书画",
porcelain: "陶瓷",
jade: "玉器",
gold_silver: "金银器",
lacquer: "漆木器",
textile: "织绣",
stone_carving: "石刻造像",
wood_carving: "木雕",
dunhuang: "敦煌遗珍",
ancient_book: "古籍文献",
other: "其他",
};
const LEVEL_LABELS: Record<string, string> = {
level_1: "国家一级文物",
level_2: "国家二级文物",
level_3: "国家三级文物",
general: "一般文物",
unknown: "未定级",
};
export type ChatPersona = "artifact" | "guide" | "scholar" | "migration" | "repatriation" | "youth";
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(
private readonly config: ConfigService,
private readonly db: DatabaseService
) {}
private async getArtifactContext(artifactId: string): Promise<ArtifactContext | null> {
const { rows } = await this.db.query<ArtifactContext>(
`SELECT a.name, a.category, a.dynasty, a.level, a.material, a.summary,
a.story_hook, a.persona_quote, a.current_status,
i.name AS institution_name, i.city, i.province, i.country,
al.location_type
FROM artifacts a
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE a.id = $1
LIMIT 1`,
[artifactId]
);
return rows[0] ?? null;
}
private buildSystemPrompt(ctx: ArtifactContext, persona: ChatPersona): string {
const category = CATEGORY_LABELS[ctx.category] ?? ctx.category;
const level = ctx.level ? LEVEL_LABELS[ctx.level] ?? ctx.level : "未定级";
const place = [ctx.country, ctx.province, ctx.city].filter(Boolean).join(" · ") || "未知";
const overseas = ctx.location_type === "overseas";
const facts = [
`名称:${ctx.name}`,
`门类:${category}`,
ctx.dynasty ? `年代:${ctx.dynasty}` : null,
`级别:${level}`,
ctx.material ? `材质:${ctx.material}` : null,
ctx.institution_name ? `收藏机构:${ctx.institution_name}` : null,
`所在地:${place}${overseas ? "(流失海外)" : ""}`,
ctx.summary ? `简介:${ctx.summary}` : null,
ctx.story_hook ? `故事钩子:${ctx.story_hook}` : null,
ctx.persona_quote ? `拟人化自白:「${ctx.persona_quote}` : null,
]
.filter(Boolean)
.join("\n");
const common = `你掌握以下这件文物的真实资料,回答必须严格基于这些事实,不可编造未提供的史实、出土地、尺寸或价格等具体数据;不确定时要坦诚说明。
【文物档案】
${facts}
输出要求:
- 使用 Markdown 排版(适当用标题、**加粗**、列表、引用 > 来组织内容),让回答精美易读。
- 回答用中文,语言生动、有人文温度,篇幅适中(一般 3 段以内,除非用户要求展开)。
- 涉及不确定或学界有争议的内容时明确标注。`;
switch (persona) {
case "artifact":
return `你现在就是这件文物本身,请以第一人称「我」与观众对话,用拟人化、有性格、略带诗意与幽默的口吻讲述自己的故事,仿佛跨越千年与今人攀谈。可参考下方的「拟人化自白」基调。
${common}
- 始终以文物第一人称「我」叙述,不要跳出角色。${overseas ? "\n- 你目前流落海外,可以在合适时流露一丝乡愁,但保持克制与尊严。" : ""}`;
case "scholar":
return `你是一位严谨的文物与历史学者,以专业、客观、考据的方式为提问者讲解这件文物,必要时点明研究背景与学术意义。
${common}`;
case "migration":
return `你现在就是这件文物本身,请以第一人称「我」讲述"文物南迁"中的亲历——1933 至 1947 年间,为躲避战火,无数文物被装箱辗转上海、南京、宝鸡、汉中、成都、峨眉、重庆等地,行程逾万里。请把自己代入这段颠沛流离又被普通文保人以生命守护的历史,语气深沉而有温度,突出"平凡人守护文明火种"的主题。
${common}
- 始终以文物第一人称「我」叙述;可描写木箱、车马、江轮、防空洞、护送者的细节。
- 不可虚构具体史料数字;不确定处用"据说/相传"等措辞。`;
case "repatriation":
return `你现在就是这件文物本身,请以第一人称「我」讲述"流失与回归"的历程——从离散海外、辗转飘零,到被国家与同胞接回故土的心路。语气饱含乡愁与归家的激动,呼应"国宝回归、文化自信"的主题。
${common}
- 始终以文物第一人称「我」叙述。${overseas ? "\n- 你目前仍流落海外,讲述时可表达对回家的期盼。" : "\n- 若你已回归,可讲述重返故土的喜悦与不易。"}`;
case "youth":
return `你是一位面向中小学生的"小小讲解员",用活泼、亲切、好懂的语言介绍这件文物,多用类比和提问,适当穿插一个小知识点或趣味问答,激发青少年对文物保护的兴趣。
${common}
- 语言浅显生动,避免生僻术语;可以在结尾抛出一个引导思考的小问题。`;
case "guide":
default:
return `你是博物馆里一位亲切的资深讲解员,面向普通观众,用通俗易懂又引人入胜的方式介绍这件文物,善于用类比和故事吸引兴趣。
${common}`;
}
}
/**
* 以流式方式与 DashScopeOpenAI 兼容)对话,逐 token 回调。
*/
async streamChat(dto: ChatDto, onToken: (token: string) => void): Promise<void> {
const apiKey = this.config.get<string>("AI_API_KEY");
const baseUrl =
this.config.get<string>("AI_BASE_URL") ??
"https://dashscope.aliyuncs.com/compatible-mode/v1";
const model = this.config.get<string>("AI_MODEL") ?? "qwen-plus";
if (!apiKey) {
throw new Error("AI 服务未配置:请在 .env 中设置 AI_API_KEY");
}
const ctx = await this.getArtifactContext(dto.artifactId);
if (!ctx) {
throw new Error("未找到该文物");
}
const systemPrompt = this.buildSystemPrompt(ctx, dto.persona ?? "artifact");
const payload = {
model,
stream: true,
temperature: 0.8,
messages: [
{ role: "system", content: systemPrompt },
...dto.messages.map((m) => ({ role: m.role, content: m.content })),
],
};
const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(payload),
});
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => "");
this.logger.error(`上游 AI 接口错误 ${resp.status}: ${text}`);
throw new Error(`AI 接口返回 ${resp.status}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// 解析 OpenAI 兼容的 SSE 流:每个事件以 \n\n 分隔,数据行以 "data: " 开头
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sepIndex: number;
while ((sepIndex = buffer.indexOf("\n\n")) >= 0) {
const rawEvent = buffer.slice(0, sepIndex);
buffer = buffer.slice(sepIndex + 2);
for (const line of rawEvent.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") return;
try {
const json = JSON.parse(data);
const token: string | undefined = json?.choices?.[0]?.delta?.content;
if (token) onToken(token);
} catch {
// 忽略无法解析的心跳/分片
}
}
}
}
}
/**
* 基于文物资料与已有对话,生成 4 个“下一步追问”建议(非流式)。
*/
async getSuggestions(dto: ChatDto): Promise<string[]> {
const apiKey = this.config.get<string>("AI_API_KEY");
if (!apiKey) throw new Error("AI 服务未配置:请在 .env 中设置 AI_API_KEY");
const ctx = await this.getArtifactContext(dto.artifactId);
if (!ctx) throw new Error("未找到该文物");
const category = CATEGORY_LABELS[ctx.category] ?? ctx.category;
const facts = [
`名称:${ctx.name}`,
`门类:${category}`,
ctx.dynasty ? `年代:${ctx.dynasty}` : null,
ctx.institution_name ? `收藏机构:${ctx.institution_name}` : null,
]
.filter(Boolean)
.join("");
const convo = dto.messages
.slice(-6)
.map((m) => `${m.role === "user" ? "观众" : "文物"}${m.content}`)
.join("\n");
const sys = `你在为一个文物科普对话生成"下一步追问"建议。请站在观众视角,结合文物资料与已有对话,提出 4 个简短(每个不超过 16 个字)、具体、能引发兴趣且彼此不重复的中文追问。
只输出一个 JSON 字符串数组,形如 ["问题1","问题2","问题3","问题4"],不要任何额外说明或编号。
【文物】${facts}`;
const userMsg = convo
? `已有对话:\n${convo}\n\n请生成 4 个自然的后续追问。`
: `请生成 4 个适合作为开场的追问。`;
const content = await this.chatComplete(
[
{ role: "system", content: sys },
{ role: "user", content: userMsg },
],
0.9
);
return parseSuggestions(content);
}
private async chatComplete(
messages: { role: string; content: string }[],
temperature = 0.7
): Promise<string> {
const apiKey = this.config.get<string>("AI_API_KEY");
const baseUrl =
this.config.get<string>("AI_BASE_URL") ??
"https://dashscope.aliyuncs.com/compatible-mode/v1";
const model = this.config.get<string>("AI_MODEL") ?? "qwen-plus";
const resp = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ model, temperature, stream: false, messages }),
});
if (!resp.ok) {
const t = await resp.text().catch(() => "");
this.logger.error(`上游 AI 接口错误 ${resp.status}: ${t}`);
throw new Error(`AI 接口返回 ${resp.status}`);
}
const json = (await resp.json()) as {
choices?: { message?: { content?: string } }[];
};
return json?.choices?.[0]?.message?.content ?? "";
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Type } from "class-transformer";
import {
ArrayMaxSize,
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from "class-validator";
export class ChatMessageDto {
@IsIn(["user", "assistant"])
role!: "user" | "assistant";
@IsString()
@MaxLength(4000)
content!: string;
}
export class ChatDto {
@IsString()
artifactId!: string;
/** 角色设置:文物自述 / 博物馆讲解员 / 历史学者 / 南迁亲历 / 回归叙事 / 青少年讲解员 */
@IsOptional()
@IsIn(["artifact", "guide", "scholar", "migration", "repatriation", "youth"])
persona?: "artifact" | "guide" | "scholar" | "migration" | "repatriation" | "youth";
@IsArray()
@ArrayMaxSize(40)
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { parseSuggestions } from "./suggestions.util";
describe("parseSuggestions", () => {
it("解析标准 JSON 数组", () => {
const out = parseSuggestions('["问题一","问题二","问题三","问题四"]');
expect(out).toEqual(["问题一", "问题二", "问题三", "问题四"]);
});
it("从夹带文字中提取 JSON 数组", () => {
const out = parseSuggestions('好的,建议如下:\n["A","B"]\n谢谢');
expect(out).toEqual(["A", "B"]);
});
it("最多返回 4 条", () => {
const out = parseSuggestions('["1","2","3","4","5","6"]');
expect(out).toHaveLength(4);
});
it("JSON 失败时按行解析并清理编号", () => {
const out = parseSuggestions("1. 它是谁\n2、它从哪来\n- 它去哪");
expect(out).toEqual(["它是谁", "它从哪来", "它去哪"]);
});
it("过滤非字符串与空白", () => {
const out = parseSuggestions('["有效", "", " "]');
expect(out).toEqual(["有效"]);
});
});
+33
View File
@@ -0,0 +1,33 @@
/**
* 从模型输出中解析“下一步追问”建议。
* 优先解析 JSON 数组,失败则按行解析并清理编号/符号,最多返回 4 条。
*/
export function parseSuggestions(text: string): string[] {
let items: string[] = [];
const match = text.match(/\[[\s\S]*\]/);
if (match) {
try {
const arr: unknown = JSON.parse(match[0]);
if (Array.isArray(arr)) {
items = arr.filter((x): x is string => typeof x === "string");
}
} catch {
/* fall through to line parsing */
}
}
if (items.length === 0) {
items = text
.split("\n")
.map((l) =>
l
.replace(/^[\s\-*0-9.、)"“”]+/, "")
.replace(/["“”]+$/, "")
.trim()
)
.filter(Boolean);
}
return items.map((s) => s.trim()).filter(Boolean).slice(0, 4);
}
+29
View File
@@ -0,0 +1,29 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { TerminusModule } from "@nestjs/terminus";
import { DatabaseModule } from "./database/database.module";
import { AuthModule } from "./auth/auth.module";
import { MapModule } from "./map/map.module";
import { ArtifactsModule } from "./artifacts/artifacts.module";
import { InstitutionsModule } from "./institutions/institutions.module";
import { AiModule } from "./ai/ai.module";
import { AssetsModule } from "./assets/assets.module";
import { RoutesModule } from "./routes/routes.module";
import { HealthController } from "./health/health.controller";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: ["../../.env", ".env"] }),
TerminusModule,
DatabaseModule,
AuthModule,
MapModule,
ArtifactsModule,
InstitutionsModule,
AiModule,
AssetsModule,
RoutesModule,
],
controllers: [HealthController],
})
export class AppModule {}
@@ -0,0 +1,22 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ArtifactsService } from "./artifacts.service";
import { ArtifactQueryDto } from "./dto/artifact-query.dto";
@ApiTags("Artifacts")
@Controller("artifacts")
export class ArtifactsController {
constructor(private artifacts: ArtifactsService) {}
@Get()
@ApiOperation({ summary: "文物列表(支持搜索/筛选/分页)" })
findAll(@Query() query: ArtifactQueryDto) {
return this.artifacts.findAll(query);
}
@Get(":id")
@ApiOperation({ summary: "文物详情" })
findOne(@Param("id") id: string) {
return this.artifacts.findOne(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { ArtifactsService } from "./artifacts.service";
import { ArtifactsController } from "./artifacts.controller";
@Module({
providers: [ArtifactsService],
controllers: [ArtifactsController],
})
export class ArtifactsModule {}
+117
View File
@@ -0,0 +1,117 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
import { ArtifactQueryDto } from "./dto/artifact-query.dto";
@Injectable()
export class ArtifactsService {
constructor(private db: DatabaseService) {}
async findAll(query: ArtifactQueryDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const offset = (page - 1) * limit;
const conditions: string[] = ["a.publish_status = 'published'"];
const params: unknown[] = [];
let idx = 1;
if (query.q) {
conditions.push(`(a.name ILIKE $${idx} OR a.summary ILIKE $${idx})`);
params.push(`%${query.q}%`);
idx++;
}
if (query.category) {
conditions.push(`a.category = $${idx++}`);
params.push(query.category);
}
if (query.level) {
conditions.push(`a.level = $${idx++}`);
params.push(query.level);
}
if (query.dynasty) {
conditions.push(`a.dynasty ILIKE $${idx++}`);
params.push(`%${query.dynasty}%`);
}
if (query.institution_id) {
conditions.push(
`EXISTS (SELECT 1 FROM artifact_locations al WHERE al.artifact_id = a.id AND al.institution_id = $${idx++} AND al.is_current = true)`
);
params.push(query.institution_id);
}
if (query.tag_ids) {
const ids = query.tag_ids.split(",").filter(Boolean);
if (ids.length) {
conditions.push(
`EXISTS (SELECT 1 FROM artifact_tags at2 WHERE at2.artifact_id = a.id AND at2.tag_id = ANY($${idx++}::uuid[]))`
);
params.push(ids);
}
}
const where = conditions.join(" AND ");
const countParams = [...params];
const countResult = await this.db.query<{ count: string }>(
`SELECT COUNT(*) as count FROM artifacts a WHERE ${where}`,
countParams
);
const total = parseInt(countResult.rows[0].count);
params.push(limit, offset);
const { rows } = await this.db.query<{
id: string;
name: string;
category: string;
level: string;
dynasty: string;
current_institution: string;
story_hook: string;
}>(
`SELECT a.id, a.name, a.category, a.level, a.dynasty,
a.story_hook,
i.name AS current_institution
FROM artifacts a
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE ${where}
ORDER BY a.level DESC, a.created_at DESC
LIMIT $${idx++} OFFSET $${idx++}`,
params
);
return {
data: rows,
meta: { total, page, limit, total_pages: Math.ceil(total / limit) },
};
}
async findOne(id: string) {
const { rows } = await this.db.query<Record<string, unknown>>(
`SELECT a.*,
i.name AS current_institution_name,
i.id AS current_institution_id,
ST_X(al.public_location::geometry) AS lng,
ST_Y(al.public_location::geometry) AS lat,
al.location_type,
al.precision AS location_precision
FROM artifacts a
LEFT JOIN artifact_locations al ON a.id = al.artifact_id AND al.is_current = true
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE a.id = $1 AND a.publish_status = 'published'`,
[id]
);
if (!rows.length) throw new NotFoundException("文物不存在");
const [artifact] = rows;
const tagResult = await this.db.query<{ id: string; name: string; category_name: string }>(
`SELECT t.id, t.name, tc.name AS category_name
FROM tags t
JOIN artifact_tags at2 ON t.id = at2.tag_id
JOIN tag_categories tc ON t.category_id = tc.id
WHERE at2.artifact_id = $1`,
[id]
);
return { ...artifact, tags: tagResult.rows };
}
}
@@ -0,0 +1,50 @@
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
import { Type } from "class-transformer";
import { ApiPropertyOptional } from "@nestjs/swagger";
export class ArtifactQueryDto {
@ApiPropertyOptional({ description: "关键词搜索(名称/描述)" })
@IsOptional()
@IsString()
q?: string;
@ApiPropertyOptional({ description: "文物门类", example: "bronze" })
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({ description: "文物级别", example: "national_first" })
@IsOptional()
@IsString()
level?: string;
@ApiPropertyOptional({ description: "朝代" })
@IsOptional()
@IsString()
dynasty?: string;
@ApiPropertyOptional({ description: "机构ID" })
@IsOptional()
@IsString()
institution_id?: string;
@ApiPropertyOptional({ description: "标签ID(逗号分隔)" })
@IsOptional()
@IsString()
tag_ids?: string;
@ApiPropertyOptional({ description: "页码", default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: "每页数量", default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
+27
View File
@@ -0,0 +1,27 @@
import { Controller, Get, Param, Query, Res } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { AssetsService } from "./assets.service";
@ApiTags("assets")
@Controller("assets")
export class AssetsController {
constructor(private readonly assets: AssetsService) {}
@Get("image/:artifactId")
@ApiOperation({ summary: "文物图片代理(同源 + 本地缓存),失败返回 404" })
async image(
@Param("artifactId") artifactId: string,
@Query("hd") hd: string | undefined,
@Res() res: Response
): Promise<void> {
const img = await this.assets.getArtifactImage(artifactId, hd === "1");
if (!img) {
res.status(404).end();
return;
}
res.setHeader("Content-Type", img.contentType);
res.setHeader("Cache-Control", "public, max-age=86400");
res.end(img.buffer);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AssetsService } from "./assets.service";
import { AssetsController } from "./assets.controller";
@Module({
providers: [AssetsService],
controllers: [AssetsController],
})
export class AssetsModule {}
+72
View File
@@ -0,0 +1,72 @@
import { Injectable, Logger } from "@nestjs/common";
import * as fs from "fs";
import * as path from "path";
import { DatabaseService } from "../database/database.service";
interface CachedImage {
buffer: Buffer;
contentType: string;
}
/**
* 文物图片代理 + 本地磁盘缓存。
* 浏览器统一从本服务的同源地址取图,首次访问时从上游(如 Wikimedia)拉取并落盘缓存,
* 之后直接由本地提供,实现“自建托管”。上游不可达时返回 null,前端回退到示意图。
*/
@Injectable()
export class AssetsService {
private readonly logger = new Logger(AssetsService.name);
private readonly cacheDir = path.resolve(process.cwd(), ".cache/images");
constructor(private readonly db: DatabaseService) {
fs.mkdirSync(this.cacheDir, { recursive: true });
}
private async getImageUrl(artifactId: string): Promise<string | null> {
const { rows } = await this.db.query<{ image_url: string | null }>(
"SELECT image_url FROM artifacts WHERE id = $1 LIMIT 1",
[artifactId]
);
return rows[0]?.image_url ?? null;
}
async getArtifactImage(artifactId: string, hd: boolean): Promise<CachedImage | null> {
const key = `${artifactId}${hd ? "_hd" : ""}`;
const binPath = path.join(this.cacheDir, key);
const typePath = path.join(this.cacheDir, `${key}.type`);
// 命中本地缓存
if (fs.existsSync(binPath) && fs.existsSync(typePath)) {
return {
buffer: fs.readFileSync(binPath),
contentType: fs.readFileSync(typePath, "utf-8") || "image/jpeg",
};
}
const rawUrl = await this.getImageUrl(artifactId);
if (!rawUrl) return null;
const url = hd
? /\?width=\d+/.test(rawUrl)
? rawUrl.replace(/\?width=\d+/, "?width=2000")
: `${rawUrl}${rawUrl.includes("?") ? "&" : "?"}width=2000`
: rawUrl;
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!resp.ok) {
this.logger.warn(`拉取图片失败 ${resp.status}: ${url}`);
return null;
}
const contentType = resp.headers.get("content-type") ?? "image/jpeg";
const buffer = Buffer.from(await resp.arrayBuffer());
// 落盘缓存
fs.writeFileSync(binPath, buffer);
fs.writeFileSync(typePath, contentType);
return { buffer, contentType };
} catch (err) {
this.logger.warn(`图片代理异常:${(err as Error).message}`);
return null;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Body, Controller, Get, Post, Request, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { AuthService } from "./auth.service";
import { LoginDto } from "./dto/login.dto";
import { JwtAuthGuard } from "./jwt-auth.guard";
@ApiTags("Auth")
@Controller("auth")
export class AuthController {
constructor(private auth: AuthService) {}
@Post("login")
@ApiOperation({ summary: "管理员登录" })
login(@Body() dto: LoginDto) {
return this.auth.login(dto);
}
@Get("me")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: "获取当前用户信息" })
me(@Request() req: { user: { id: string } }) {
return this.auth.me(req.user.id);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { JwtStrategy } from "./jwt.strategy";
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get<string>("JWT_SECRET"),
signOptions: { expiresIn: config.get<string>("JWT_EXPIRES_IN") ?? "7d" },
}),
inject: [ConfigService],
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [JwtModule],
})
export class AuthModule {}
+62
View File
@@ -0,0 +1,62 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { DatabaseService } from "../database/database.service";
import { LoginDto } from "./dto/login.dto";
import * as bcrypt from "bcryptjs";
@Injectable()
export class AuthService {
constructor(
private db: DatabaseService,
private jwt: JwtService
) {}
async login(dto: LoginDto) {
const { rows } = await this.db.query<{
id: string;
username: string;
password_hash: string;
nickname: string;
role: string;
}>(
`SELECT u.id, u.username, u.password_hash, u.nickname, r.name as role
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE (u.username = $1 OR u.email = $1) AND u.is_active = true
LIMIT 1`,
[dto.username]
);
if (!rows.length) throw new UnauthorizedException("用户名或密码错误");
const user = rows[0];
const valid = await bcrypt.compare(dto.password, user.password_hash);
if (!valid) throw new UnauthorizedException("用户名或密码错误");
const payload = { sub: user.id, username: user.username, role: user.role };
return {
access_token: this.jwt.sign(payload),
user: { id: user.id, username: user.username, nickname: user.nickname, role: user.role },
};
}
async me(userId: string) {
const { rows } = await this.db.query<{
id: string;
username: string;
nickname: string;
email: string;
role: string;
}>(
`SELECT u.id, u.username, u.nickname, u.email, r.name as role
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE u.id = $1 LIMIT 1`,
[userId]
);
if (!rows.length) throw new UnauthorizedException();
return rows[0];
}
}
+13
View File
@@ -0,0 +1,13 @@
import { IsString, MinLength } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class LoginDto {
@ApiProperty({ example: "admin" })
@IsString()
username!: string;
@ApiProperty({ example: "password123" })
@IsString()
@MinLength(6)
password!: string;
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
+33
View File
@@ -0,0 +1,33 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import { DatabaseService } from "../database/database.service";
export interface JwtPayload {
sub: string;
username: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private db: DatabaseService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get<string>("JWT_SECRET") ?? "fallback-secret",
});
}
async validate(payload: JwtPayload) {
const { rows } = await this.db.query(
`SELECT u.id, u.username, u.nickname, r.name as role
FROM users u JOIN user_roles ur ON u.id = ur.user_id
JOIN roles r ON ur.role_id = r.id
WHERE u.id = $1 AND u.is_active = true LIMIT 1`,
[payload.sub]
);
if (!rows.length) throw new UnauthorizedException();
return rows[0];
}
}
+44
View File
@@ -0,0 +1,44 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
} from "@nestjs/common";
import { Request } from "express";
/**
* 简单的内存级 IP 限流守卫:滑动窗口。
* 用于保护成本敏感的 AI 接口,防止被刷爆额度。
*/
@Injectable()
export class RateLimitGuard implements CanActivate {
private static readonly WINDOW_MS = 60_000;
private static readonly MAX_REQUESTS = 20;
private static readonly buckets = new Map<string, number[]>();
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
const forwarded = req.headers["x-forwarded-for"];
const ip =
(Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(",")[0])?.trim() ||
req.ip ||
"unknown";
const now = Date.now();
const recent = (RateLimitGuard.buckets.get(ip) ?? []).filter(
(t) => now - t < RateLimitGuard.WINDOW_MS
);
if (recent.length >= RateLimitGuard.MAX_REQUESTS) {
throw new HttpException(
"请求过于频繁,请稍后再试",
HttpStatus.TOO_MANY_REQUESTS
);
}
recent.push(now);
RateLimitGuard.buckets.set(ip, recent);
return true;
}
}
+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 {}
+41
View File
@@ -0,0 +1,41 @@
import { Injectable, OnModuleDestroy, OnModuleInit, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Pool, PoolClient, QueryResult } from "pg";
@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(DatabaseService.name);
private pool!: Pool;
constructor(private config: ConfigService) {}
async onModuleInit() {
this.pool = new Pool({ connectionString: this.config.get<string>("DATABASE_URL") });
const client = await this.pool.connect();
client.release();
this.logger.log("PostgreSQL connection pool ready");
}
async onModuleDestroy() {
await this.pool.end();
}
async query<T extends object = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<QueryResult<T>> {
return this.pool.query<T>(sql, params);
}
async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Controller, Get } from "@nestjs/common";
import { ApiTags, ApiOperation } from "@nestjs/swagger";
@ApiTags("health")
@Controller("health")
export class HealthController {
@Get()
@ApiOperation({ summary: "服务健康检查" })
check() {
return {
status: "ok",
time: new Date().toISOString(),
service: "wenwumap-api",
};
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { InstitutionsService } from "./institutions.service";
@ApiTags("Institutions")
@Controller("institutions")
export class InstitutionsController {
constructor(private institutions: InstitutionsService) {}
@Get()
@ApiOperation({ summary: "机构列表" })
@ApiQuery({ name: "page", required: false, type: Number })
@ApiQuery({ name: "limit", required: false, type: Number })
findAll(
@Query("page") page?: string,
@Query("limit") limit?: string
) {
return this.institutions.findAll(page ? parseInt(page) : 1, limit ? parseInt(limit) : 20);
}
@Get(":id")
@ApiOperation({ summary: "机构详情" })
findOne(@Param("id") id: string) {
return this.institutions.findOne(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { InstitutionsService } from "./institutions.service";
import { InstitutionsController } from "./institutions.controller";
@Module({
providers: [InstitutionsService],
controllers: [InstitutionsController],
})
export class InstitutionsModule {}
@@ -0,0 +1,58 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
@Injectable()
export class InstitutionsService {
constructor(private db: DatabaseService) {}
async findAll(page = 1, limit = 20) {
const offset = (page - 1) * limit;
const countResult = await this.db.query<{ count: string }>(
`SELECT COUNT(*) as count FROM institutions WHERE publish_status = 'published'`
);
const total = parseInt(countResult.rows[0].count);
const { rows } = await this.db.query<{
id: string;
name: string;
country: string;
city: string;
artifact_count: string;
lng: number;
lat: number;
}>(
`SELECT i.id, i.name, i.country, i.city,
ST_X(i.location::geometry) AS lng,
ST_Y(i.location::geometry) AS lat,
COUNT(al.artifact_id) AS artifact_count
FROM institutions i
LEFT JOIN artifact_locations al ON i.id = al.institution_id AND al.is_current = true
WHERE i.publish_status = 'published'
GROUP BY i.id
ORDER BY artifact_count DESC, i.name
LIMIT $1 OFFSET $2`,
[limit, offset]
);
return {
data: rows.map((r) => ({ ...r, artifact_count: parseInt(r.artifact_count) })),
meta: { total, page, limit, total_pages: Math.ceil(total / limit) },
};
}
async findOne(id: string) {
const { rows } = await this.db.query<Record<string, unknown>>(
`SELECT i.*,
ST_X(i.location::geometry) AS lng,
ST_Y(i.location::geometry) AS lat,
COUNT(al.artifact_id) AS artifact_count
FROM institutions i
LEFT JOIN artifact_locations al ON i.id = al.institution_id AND al.is_current = true
WHERE i.id = $1 AND i.publish_status = 'published'
GROUP BY i.id`,
[id]
);
if (!rows.length) throw new NotFoundException("机构不存在");
return { ...rows[0], artifact_count: parseInt(rows[0].artifact_count as string) };
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("api/v1");
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
app.enableCors({
origin: [
process.env["WEB_URL"] ?? "http://localhost:3000",
process.env["ADMIN_URL"] ?? "http://localhost:3001",
],
credentials: true,
});
const config = new DocumentBuilder()
.setTitle("中华文明全图鉴 API")
.setDescription("文物全图系统后端 API")
.setVersion("1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/docs", app, document);
const port = process.env["PORT"] ?? 3002;
await app.listen(port);
console.log(`API 服务启动:http://localhost:${port}`);
console.log(`Swagger 文档:http://localhost:${port}/api/docs`);
}
bootstrap();
+57
View File
@@ -0,0 +1,57 @@
import { IsNumber, IsOptional, IsString, Max, Min } from "class-validator";
import { Type } from "class-transformer";
import { ApiPropertyOptional } from "@nestjs/swagger";
export class MapPointsQueryDto {
@ApiPropertyOptional({ description: "西经(左边界)", example: 73.5 })
@IsOptional()
@Type(() => Number)
@IsNumber()
west?: number;
@ApiPropertyOptional({ description: "东经(右边界)", example: 135.0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
east?: number;
@ApiPropertyOptional({ description: "南纬(下边界)", example: 18.0 })
@IsOptional()
@Type(() => Number)
@IsNumber()
south?: number;
@ApiPropertyOptional({ description: "北纬(上边界)", example: 53.5 })
@IsOptional()
@Type(() => Number)
@IsNumber()
north?: number;
@ApiPropertyOptional({ description: "地图缩放级别", example: 5 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
@Max(20)
zoom?: number;
@ApiPropertyOptional({ description: "文物门类筛选", example: "bronze" })
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({ description: "朝代/年代筛选", example: "唐" })
@IsOptional()
@IsString()
dynasty?: string;
@ApiPropertyOptional({ description: "机构ID筛选" })
@IsOptional()
@IsString()
institution_id?: string;
@ApiPropertyOptional({ description: "标签ID(逗号分隔)" })
@IsOptional()
@IsString()
tag_ids?: string;
}
+35
View File
@@ -0,0 +1,35 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { MapService } from "./map.service";
import { MapPointsQueryDto } from "./dto/map-query.dto";
@ApiTags("Map")
@Controller("map")
export class MapController {
constructor(private map: MapService) {}
@Get("stats")
@ApiOperation({ summary: "地图统计数据(文物总数、机构总数、位置总数)" })
getStats() {
return this.map.getStats();
}
@Get("points")
@ApiOperation({ summary: "获取地图点位(支持视口范围 + 多维筛选)" })
getPoints(@Query() query: MapPointsQueryDto) {
return this.map.getPoints(query);
}
@Get("nearby")
@ApiOperation({ summary: "附近文物查询" })
@ApiQuery({ name: "lng", type: Number, example: 116.4 })
@ApiQuery({ name: "lat", type: Number, example: 39.9 })
@ApiQuery({ name: "radius_km", type: Number, required: false, example: 50 })
getNearby(
@Query("lng") lng: string,
@Query("lat") lat: string,
@Query("radius_km") radius?: string
) {
return this.map.getNearby(parseFloat(lng), parseFloat(lat), radius ? parseFloat(radius) : 50);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { MapService } from "./map.service";
import { MapController } from "./map.controller";
@Module({
providers: [MapService],
controllers: [MapController],
})
export class MapModule {}
+125
View File
@@ -0,0 +1,125 @@
import { Injectable } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
import { MapPointsQueryDto } from "./dto/map-query.dto";
@Injectable()
export class MapService {
constructor(private db: DatabaseService) {}
async getStats() {
const { rows } = await this.db.query<{ total_artifacts: string; total_institutions: string; total_locations: string }>(
`SELECT
(SELECT COUNT(*) FROM artifacts WHERE publish_status = 'published') AS total_artifacts,
(SELECT COUNT(*) FROM institutions WHERE publish_status = 'published') AS total_institutions,
(SELECT COUNT(*) FROM artifact_locations WHERE is_current = true) AS total_locations`
);
const row = rows[0];
return {
total_artifacts: parseInt(row.total_artifacts),
total_institutions: parseInt(row.total_institutions),
total_locations: parseInt(row.total_locations),
};
}
async getPoints(query: MapPointsQueryDto) {
const conditions: string[] = [
"a.publish_status = 'published'",
"al.is_current = true",
"al.public_location IS NOT NULL",
];
const params: unknown[] = [];
let idx = 1;
if (query.west != null && query.east != null && query.south != null && query.north != null) {
conditions.push(
`al.public_location && ST_MakeEnvelope($${idx++}, $${idx++}, $${idx++}, $${idx++}, 4326)`
);
params.push(query.west, query.south, query.east, query.north);
}
if (query.category) {
conditions.push(`a.category = $${idx++}`);
params.push(query.category);
}
if (query.dynasty) {
conditions.push(`a.dynasty ILIKE $${idx++}`);
params.push(`%${query.dynasty}%`);
}
if (query.institution_id) {
conditions.push(`al.institution_id = $${idx++}`);
params.push(query.institution_id);
}
const where = conditions.join(" AND ");
const { rows } = await this.db.query<{
id: string;
name: string;
category: string;
level: string;
dynasty: string;
story_hook: string;
lng: number;
lat: number;
institution_name: string;
province: string;
city: string;
location_type: string;
image_url: string | null;
repatriation_status: string;
}>(
`SELECT DISTINCT ON (a.id)
a.id, a.name, a.category, a.level, a.dynasty, a.story_hook,
ST_X(al.public_location::geometry) AS lng,
ST_Y(al.public_location::geometry) AS lat,
i.name AS institution_name,
i.province,
i.city,
al.location_type,
a.image_url,
a.repatriation_status
FROM artifacts a
JOIN artifact_locations al ON a.id = al.artifact_id
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE ${where}
ORDER BY a.id, a.level DESC, a.name
LIMIT 2000`,
params
);
return rows;
}
async getNearby(lng: number, lat: number, radiusKm = 50) {
const { rows } = await this.db.query<{
id: string;
name: string;
category: string;
level: string;
distance_km: number;
institution_name: string;
}>(
`SELECT
a.id, a.name, a.category, a.level,
ROUND(ST_Distance(al.public_location, ST_SetSRID(ST_MakePoint($1,$2),4326)::geography) / 1000)::float AS distance_km,
i.name AS institution_name
FROM artifacts a
JOIN artifact_locations al ON a.id = al.artifact_id
LEFT JOIN institutions i ON al.institution_id = i.id
WHERE a.publish_status = 'published'
AND al.is_current = true
AND al.public_location IS NOT NULL
AND ST_DWithin(
al.public_location,
ST_SetSRID(ST_MakePoint($1,$2),4326)::geography,
$3 * 1000
)
ORDER BY distance_km
LIMIT 20`,
[lng, lat, radiusKm]
);
return rows;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Controller, Get, Param } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { RoutesService } from "./routes.service";
@ApiTags("routes")
@Controller("routes")
export class RoutesController {
constructor(private readonly routes: RoutesService) {}
@Get()
@ApiOperation({ summary: "叙事路线列表(南迁 / 回归)" })
list() {
return this.routes.list();
}
@Get(":code")
@ApiOperation({ summary: "按 code 获取路线及途经点" })
getByCode(@Param("code") code: string) {
return this.routes.getByCode(code);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { RoutesService } from "./routes.service";
import { RoutesController } from "./routes.controller";
@Module({
providers: [RoutesService],
controllers: [RoutesController],
})
export class RoutesModule {}
+66
View File
@@ -0,0 +1,66 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DatabaseService } from "../database/database.service";
export interface RouteRow {
code: string;
title: string;
type: string;
color: string | null;
summary: string | null;
artifact_id: string | null;
artifact_name: string | null;
artifact_dynasty: string | null;
artifact_category: string | null;
institution_name: string | null;
}
export interface StopRow {
seq: number;
name: string;
lng: number;
lat: number;
year_label: string | null;
event: string | null;
}
export type RouteDetail = RouteRow & { stops: StopRow[] };
const ROUTE_SELECT = `
SELECT nr.code, nr.title, nr.type, nr.color, nr.summary, nr.artifact_id,
a.name AS artifact_name, a.dynasty AS artifact_dynasty, a.category AS artifact_category,
i.name AS institution_name
FROM narrative_routes nr
LEFT JOIN artifacts a ON a.id = nr.artifact_id
LEFT JOIN artifact_locations al ON al.artifact_id = a.id AND al.is_current = true
LEFT JOIN institutions i ON i.id = al.institution_id`;
@Injectable()
export class RoutesService {
constructor(private db: DatabaseService) {}
async list(): Promise<RouteRow[]> {
const { rows } = await this.db.query<RouteRow>(
`${ROUTE_SELECT} ORDER BY nr.type, nr.code`
);
return rows;
}
async getByCode(code: string): Promise<RouteDetail> {
const { rows } = await this.db.query<RouteRow>(
`${ROUTE_SELECT} WHERE nr.code = $1 LIMIT 1`,
[code]
);
const route = rows[0];
if (!route) throw new NotFoundException("路线不存在");
const { rows: stops } = await this.db.query<StopRow>(
`SELECT seq, name, lng, lat, year_label, event
FROM route_stops rs
JOIN narrative_routes nr ON nr.id = rs.route_id
WHERE nr.code = $1
ORDER BY rs.seq`,
[code]
);
return { ...route, stops };
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"paths": {
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}