commit c3dd9be674cd67084c490de80f0ab5b50e583b5b
Author: g1307092382 <1307092382@qq.com>
Date: Wed Aug 12 14:35:12 2026 +0800
feat: complete V1 phases 0-7
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..14c4d77
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,9 @@
+.git
+.github
+**/node_modules
+**/dist
+coverage
+.env
+*.log
+docs
+*.png
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e95cecf
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,43 @@
+APP_ENV=development
+API_PORT=3000
+DATABASE_URL=mysql://lehuo:change-me-local-only@localhost:3306/lehuo
+TEST_DATABASE_URL=mysql://lehuo:change-me-local-only@localhost:3307/lehuo_test
+REDIS_URL=redis://localhost:6379
+MYSQL_DATABASE=lehuo
+MYSQL_USER=lehuo
+MYSQL_PASSWORD=change-me-local-only
+MYSQL_ROOT_PASSWORD=change-root-password-local-only
+MINIO_ROOT_USER=lehuo-local
+MINIO_ROOT_PASSWORD=change-minio-password-local-only
+MINIO_ENDPOINT=http://localhost:9000
+MINIO_BUCKET=lehuo-local
+WECHAT_APP_ID=
+WECHAT_APP_SECRET=
+WECHAT_TEMPLATE_APPLICATION_SUBMITTED=
+WECHAT_TEMPLATE_APPLICATION_ACCEPTED=
+WECHAT_TEMPLATE_SERVICE_ARRANGED=
+WECHAT_TEMPLATE_SERVICE_COMPLETED=
+WECHAT_TEMPLATE_FOLLOW_UP_PENDING=
+WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT=
+NOTIFICATION_WORKER_ENABLED=false
+OBJECT_STORAGE_ENDPOINT=http://localhost:9000
+OBJECT_STORAGE_REGION=auto
+OBJECT_STORAGE_BUCKET=lehuo-private
+OBJECT_STORAGE_ACCESS_KEY=
+OBJECT_STORAGE_SECRET_KEY=
+OBJECT_STORAGE_FORCE_PATH_STYLE=true
+COS_BUCKET=
+COS_REGION=
+COS_SECRET_ID=
+COS_SECRET_KEY=
+SESSION_SECRET=
+SESSION_TOKEN_PEPPER=
+AUDIT_IP_HASH_KEY=
+FIELD_ENCRYPTION_KEY=
+ADMIN_WEB_ORIGIN=http://localhost:5173
+ADMIN_COOKIE_SECURE=false
+AUTH_SESSION_TTL_SECONDS=28800
+WECHAT_SESSION_TTL_SECONDS=7200
+BOOTSTRAP_ADMIN_USERNAME=
+BOOTSTRAP_ADMIN_PASSWORD=
+BOOTSTRAP_ADMIN_DISPLAY_NAME=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b42fdd1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,82 @@
+name: ci
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ quality:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ services:
+ mysql:
+ image: mysql:8.4.8
+ env:
+ MYSQL_DATABASE: lehuo_test
+ MYSQL_USER: lehuo
+ MYSQL_PASSWORD: ci-test-only
+ MYSQL_ROOT_PASSWORD: ci-root-test-only
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -h 127.0.0.1 -u root -pci-root-test-only --silent"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=10
+ env:
+ APP_ENV: test
+ DATABASE_URL: mysql://lehuo:ci-test-only@127.0.0.1:3306/lehuo_test
+ TEST_DATABASE_URL: mysql://lehuo:ci-test-only@127.0.0.1:3306/lehuo_test
+ SESSION_TOKEN_PEPPER: ci-test-session-token-pepper-2026
+ AUDIT_IP_HASH_KEY: ci-test-audit-ip-hash-key-2026
+ FIELD_ENCRYPTION_KEY: CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws=
+ ADMIN_WEB_ORIGIN: http://localhost:5173
+ ADMIN_COOKIE_SECURE: 'false'
+ WECHAT_APP_ID: ci-test-app-id
+ WECHAT_APP_SECRET: ci-test-app-secret
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 11.13.1
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Validate formatting
+ run: pnpm format:check
+
+ - name: Lint
+ run: pnpm lint
+
+ - name: Typecheck
+ run: pnpm typecheck
+
+ - name: Unit tests
+ run: pnpm test:unit
+
+ - name: Integration tests
+ run: pnpm test:integration
+
+ - name: Build
+ run: pnpm build
+
+ - name: Validate Docker Compose
+ run: docker compose --env-file .env.example config --quiet
+
+ - name: Dependency audit
+ run: pnpm audit --registry=https://registry.npmjs.org --audit-level high
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 0000000..16565ad
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,34 @@
+name: Security
+on:
+ push:
+ branches: [main]
+ pull_request:
+ schedule:
+ - cron: '23 2 * * 1'
+permissions:
+ contents: read
+jobs:
+ dependency-and-secret-scan:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with: { version: 11.13.1 }
+ - uses: actions/setup-node@v4
+ with: { node-version: 24, cache: pnpm }
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm audit --audit-level=high
+ - name: Reject committed private keys and common live secrets
+ shell: bash
+ run: |
+ if git grep -IEn 'BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|sk_live_[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}' -- ':!pnpm-lock.yaml'; then
+ echo 'Potential secret detected'; exit 1
+ fi
+ codeql:
+ runs-on: ubuntu-latest
+ permissions: { security-events: write, contents: read }
+ steps:
+ - uses: actions/checkout@v4
+ - uses: github/codeql-action/init@v3
+ with: { languages: javascript-typescript }
+ - uses: github/codeql-action/analyze@v3
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c2171e6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+node_modules/
+dist/
+coverage/
+.env
+.env.*
+!.env.example
+*.log
+.DS_Store
+.idea/
+.vscode/
+apps/miniprogram/miniprogram_npm/
+apps/miniprogram/.miniprogram/
+apps/api/src/generated/prisma/
+minio-data/
+mysql-data/
+redis-data/
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..7aaaea2
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,9 @@
+**/dist
+**/node_modules
+**/miniprogram_npm
+coverage
+pnpm-lock.yaml
+docs
+乐活公益小程序_Codex开发总文档_V1.0.md
+docs/ui_reference.png
+*.png
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..6fc6d66
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all",
+ "semi": true,
+ "printWidth": 100
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..af912ba
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,23 @@
+# AGENTS.md
+
+Before coding, read in order:
+
+1. `docs/00_README_FIRST.md`
+2. `docs/01_PROJECT_CONSTITUTION.md`
+3. `docs/10_TASK_STATE.md`
+4. `docs/11_DECISIONS.md`
+5. Task-related documents in `docs/`
+
+Rules:
+
+- Do not change the approved product scope, technology stack, database, or authentication approach without explicit approval.
+- Do not remove existing features to simplify implementation or use mocks to claim production functionality is complete.
+- Do not bypass authorization, resource policies, migrations, tests, audit requirements, or state-machine validation.
+- Do not log tokens, full mobile numbers, identity documents, detailed addresses, child records, or beneficiary service descriptions.
+- Do not expose sequential internal IDs; public resources use unpredictable public identifiers.
+- Every business status change must be auditable, and critical writes must be transactionally consistent.
+- Every schema change requires a migration; every API change must update shared contracts and API documentation.
+- Prefer adapters around external providers; business services must not directly depend on vendor SDKs.
+- Update `docs/10_TASK_STATE.md` after every task and preserve its history.
+- Update `docs/11_DECISIONS.md` for architecture or product decisions; do not overturn accepted decisions because another approach seems newer.
+- Run lint, typecheck, unit tests, integration tests, and build before marking work complete.
diff --git a/ChatGPT Image 2026年8月10日 19_36_20.png b/ChatGPT Image 2026年8月10日 19_36_20.png
new file mode 100644
index 0000000..0bd414a
Binary files /dev/null and b/ChatGPT Image 2026年8月10日 19_36_20.png differ
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2f41138
--- /dev/null
+++ b/README.md
@@ -0,0 +1,37 @@
+# 乐活公益·暖心服务平台
+
+V1.0 使用 TypeScript 全栈 monorepo,包含微信小程序、Web 管理后台、模块化单体 API 与共享契约。
+
+## 环境要求
+
+- Node.js 24 LTS
+- pnpm 11
+- Docker 29+ 与 Docker Compose
+
+## 初始化
+
+1. 复制 `.env.example` 为 `.env`,仅填写本地开发值,禁止提交真实密钥。
+2. 运行 `pnpm install`。
+3. 为 `SESSION_TOKEN_PEPPER`、`AUDIT_IP_HASH_KEY` 生成独立随机值,为 `FIELD_ENCRYPTION_KEY` 生成 32 字节随机值并以 Base64 保存;填写微信开发配置和精确的 `ADMIN_WEB_ORIGIN`。
+4. 运行 `docker compose up -d` 启动 MySQL、Redis、MinIO。
+5. 运行 `pnpm --filter @lehuo/api db:deploy` 执行 migration。
+6. 首次部署时通过环境变量提供 `BOOTSTRAP_ADMIN_USERNAME`、`BOOTSTRAP_ADMIN_PASSWORD`、`BOOTSTRAP_ADMIN_DISPLAY_NAME`,运行 `pnpm --filter @lehuo/api bootstrap:admin`;已有后台凭据时命令会拒绝重复初始化。
+7. 运行 `pnpm --filter @lehuo/api dev` 启动 API,健康检查地址为 `http://localhost:3000/health`,Swagger 地址为 `http://localhost:3000/api/docs`。
+8. 运行 `pnpm --filter @lehuo/admin-web dev` 启动管理后台。
+9. 配置小程序 API 地址后,使用微信开发者工具导入 `apps/miniprogram`。
+
+后台会话使用 HttpOnly Cookie,不存入 localStorage;生产环境必须启用 HTTPS 并设置 `ADMIN_COOKIE_SECURE=true`。密钥和首次管理员密码不得提交到仓库。
+
+## 质量命令
+
+```bash
+pnpm lint
+pnpm typecheck
+pnpm test:unit
+pnpm test:integration
+pnpm build
+```
+
+集成测试只允许连接数据库名为 `lehuo_test` 的 MySQL。可先运行 `docker compose --profile test up -d mysql-test`,再设置 `TEST_DATABASE_URL` 后执行测试。
+
+项目约束、当前阶段和决策分别见 `docs/01_PROJECT_CONSTITUTION.md`、`docs/10_TASK_STATE.md`、`docs/11_DECISIONS.md`。
diff --git a/apps/admin-web/.env.example b/apps/admin-web/.env.example
new file mode 100644
index 0000000..99cbcd5
--- /dev/null
+++ b/apps/admin-web/.env.example
@@ -0,0 +1 @@
+VITE_API_BASE_URL=http://localhost:3000
diff --git a/apps/admin-web/README.md b/apps/admin-web/README.md
new file mode 100644
index 0000000..9b646c1
--- /dev/null
+++ b/apps/admin-web/README.md
@@ -0,0 +1,5 @@
+# 管理后台
+
+Vue 3 + TypeScript + Vite。Phase 1 至 Phase 5 提供后台账号登录、服务申请与接单、服务对象、工单与记录、回访工作台,以及志愿者审核、公益项目、活动报名、签到和服务时长工作台。凭据由 HttpOnly Cookie 管理,不写入 localStorage;列表手机号默认脱敏,敏感详情查看记录审计。
+
+复制 `.env.example` 为 `.env.local` 并配置 `VITE_API_BASE_URL`,然后运行 `pnpm --filter @lehuo/admin-web dev`。该地址必须与 API 的 `ADMIN_WEB_ORIGIN` CORS 配置配套。
diff --git a/apps/admin-web/index.html b/apps/admin-web/index.html
new file mode 100644
index 0000000..f2ef9bc
--- /dev/null
+++ b/apps/admin-web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ 乐活公益管理后台
+
+
+
+
+
+
diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json
new file mode 100644
index 0000000..39715b8
--- /dev/null
+++ b/apps/admin-web/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@lehuo/admin-web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "pnpm --filter @lehuo/contracts build && vite build",
+ "dev": "pnpm --filter @lehuo/contracts build && vite",
+ "test:unit": "pnpm --filter @lehuo/contracts build && vitest run",
+ "typecheck": "vue-tsc --noEmit"
+ },
+ "dependencies": {
+ "@lehuo/contracts": "workspace:*",
+ "pinia": "^3.0.3",
+ "vue": "^3.5.20",
+ "vue-router": "^4.5.1"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.1",
+ "typescript": "^5.9.2",
+ "vite": "^7.1.3",
+ "vitest": "^3.2.4",
+ "vue-tsc": "^3.0.6"
+ }
+}
diff --git a/apps/admin-web/src/App.vue b/apps/admin-web/src/App.vue
new file mode 100644
index 0000000..36efd85
--- /dev/null
+++ b/apps/admin-web/src/App.vue
@@ -0,0 +1,245 @@
+
+
+
+
+
+ 乐活公益 · 暖心服务平台
+ {{ ADMIN_APP_TITLE }}
+ 正在确认登录状态,请稍候…
+
+
+
+
+
+ 乐活公益 · 暖心服务平台
+ {{ ADMIN_APP_TITLE }}
+ 仅限经授权的工作人员使用。登录操作将记录安全审计。
+
+
+
+
+
+
+
+
+
{{ message }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin-web/src/api/api-client.ts b/apps/admin-web/src/api/api-client.ts
new file mode 100644
index 0000000..985d6a4
--- /dev/null
+++ b/apps/admin-web/src/api/api-client.ts
@@ -0,0 +1,42 @@
+import type { ApiErrorResponse, ApiSuccessResponse } from '@lehuo/contracts';
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string | undefined;
+
+export class ApiClientError extends Error {
+ constructor(
+ message: string,
+ readonly code: string,
+ readonly requestId: string | null,
+ ) {
+ super(message);
+ }
+}
+
+export async function apiRequest(path: string, init: RequestInit = {}): Promise {
+ if (!API_BASE_URL) {
+ throw new ApiClientError('管理后台尚未配置服务地址', 'CONFIGURATION_ERROR', null);
+ }
+ let response: Response;
+ try {
+ response = await fetch(new URL(path, API_BASE_URL), {
+ ...init,
+ credentials: 'include',
+ headers: {
+ 'content-type': 'application/json',
+ ...init.headers,
+ },
+ });
+ } catch {
+ throw new ApiClientError('网络连接失败,请检查网络后重试', 'NETWORK_ERROR', null);
+ }
+ const payload = (await response.json()) as ApiSuccessResponse | ApiErrorResponse;
+ if (!response.ok || !payload.success) {
+ const error = payload as ApiErrorResponse;
+ throw new ApiClientError(
+ error.error?.message || '请求失败,请稍后重试',
+ error.error?.code || 'UNKNOWN_ERROR',
+ error.requestId || response.headers.get('x-request-id'),
+ );
+ }
+ return payload.data;
+}
diff --git a/apps/admin-web/src/api/case-management.ts b/apps/admin-web/src/api/case-management.ts
new file mode 100644
index 0000000..dd530c2
--- /dev/null
+++ b/apps/admin-web/src/api/case-management.ts
@@ -0,0 +1,172 @@
+import type {
+ AcceptServiceApplicationRequest,
+ AcceptServiceApplicationResponse,
+ AssigneeOption,
+ BeneficiaryDetailResponse,
+ BeneficiaryTagOption,
+ ConfirmUploadResponse,
+ CompleteServiceCaseRequest,
+ CompleteServiceCaseResponse,
+ CreateServiceRecordRequest,
+ CreateUploadIntentRequest,
+ CreateUploadIntentResponse,
+ DownloadUrlResponse,
+ PaginatedBeneficiariesResponse,
+ PaginatedServiceCasesResponse,
+ ReplaceCaseAssigneesRequest,
+ ServiceCaseDetailResponse,
+ ServiceCategory,
+ ServiceCaseStatus,
+ ServiceRecordResponse,
+ TransitionServiceApplicationRequest,
+ UpdateBeneficiaryRequest,
+ UpdateServiceRecordRequest,
+} from '@lehuo/contracts';
+
+import { apiRequest } from './api-client';
+
+function queryString(values: Record): string {
+ const params = new URLSearchParams();
+ Object.entries(values).forEach(([key, value]) => {
+ if (value !== undefined && value !== '') params.set(key, String(value));
+ });
+ return params.toString();
+}
+
+export function transitionServiceApplication(
+ publicId: string,
+ input: TransitionServiceApplicationRequest,
+): Promise<{ status: string }> {
+ return apiRequest(
+ `/api/v1/admin/service-applications/${encodeURIComponent(publicId)}/transition`,
+ {
+ method: 'POST',
+ body: JSON.stringify(input),
+ },
+ );
+}
+
+export function acceptServiceApplication(
+ publicId: string,
+ input: AcceptServiceApplicationRequest,
+): Promise {
+ return apiRequest(`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}/accept`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+}
+
+export function fetchBeneficiaryTags(): Promise {
+ return apiRequest('/api/v1/admin/beneficiaries/tags');
+}
+
+export function fetchBeneficiaries(query: {
+ page: number;
+ pageSize: number;
+ primaryCategory?: ServiceCategory;
+ status?: string;
+ keyCareFlag?: boolean;
+}): Promise {
+ return apiRequest(`/api/v1/admin/beneficiaries?${queryString(query)}`);
+}
+
+export function fetchBeneficiary(publicId: string): Promise {
+ return apiRequest(`/api/v1/admin/beneficiaries/${encodeURIComponent(publicId)}`);
+}
+
+export function updateBeneficiary(
+ publicId: string,
+ input: UpdateBeneficiaryRequest,
+): Promise {
+ return apiRequest(`/api/v1/admin/beneficiaries/${encodeURIComponent(publicId)}`, {
+ method: 'PUT',
+ body: JSON.stringify(input),
+ });
+}
+
+export function fetchAssigneeOptions(): Promise {
+ return apiRequest('/api/v1/admin/service-cases/assignee-options');
+}
+
+export function fetchServiceCases(query: {
+ page: number;
+ pageSize: number;
+ status?: ServiceCaseStatus;
+ category?: ServiceCategory;
+ caseNo?: string;
+}): Promise {
+ return apiRequest(`/api/v1/admin/service-cases?${queryString(query)}`);
+}
+
+export function fetchServiceCase(publicId: string): Promise {
+ return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}`);
+}
+
+export function startServiceCase(publicId: string): Promise {
+ return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/start`, {
+ method: 'POST',
+ body: '{}',
+ });
+}
+
+export function completeServiceCase(
+ publicId: string,
+ input: CompleteServiceCaseRequest,
+): Promise {
+ return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/complete`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+}
+
+export function replaceCaseAssignees(
+ publicId: string,
+ input: ReplaceCaseAssigneesRequest,
+): Promise {
+ return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/assignees`, {
+ method: 'PUT',
+ body: JSON.stringify(input),
+ });
+}
+
+export function createServiceRecord(
+ publicId: string,
+ input: CreateServiceRecordRequest,
+): Promise {
+ return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/records`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+}
+
+export function updateServiceRecord(
+ casePublicId: string,
+ recordPublicId: string,
+ input: UpdateServiceRecordRequest,
+): Promise {
+ return apiRequest(
+ `/api/v1/admin/service-cases/${encodeURIComponent(casePublicId)}/records/${encodeURIComponent(recordPublicId)}`,
+ { method: 'PATCH', body: JSON.stringify(input) },
+ );
+}
+
+export function createUploadIntent(
+ casePublicId: string,
+ input: CreateUploadIntentRequest,
+): Promise {
+ return apiRequest(
+ `/api/v1/service-cases/${encodeURIComponent(casePublicId)}/files/upload-intents`,
+ { method: 'POST', body: JSON.stringify(input) },
+ );
+}
+
+export function confirmUpload(publicId: string): Promise {
+ return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/confirm`, {
+ method: 'POST',
+ body: '{}',
+ });
+}
+
+export function fetchDownloadUrl(publicId: string): Promise {
+ return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/download-url`);
+}
diff --git a/apps/admin-web/src/api/content-notifications.ts b/apps/admin-web/src/api/content-notifications.ts
new file mode 100644
index 0000000..e55d920
--- /dev/null
+++ b/apps/admin-web/src/api/content-notifications.ts
@@ -0,0 +1,37 @@
+import type {
+ ContentPostDetailResponse,
+ CreateContentPostRequest,
+ PaginatedContentPostsResponse,
+ PaginatedNotificationDeliveriesResponse,
+ TransitionContentPostRequest,
+} from '@lehuo/contracts';
+import { apiRequest } from './api-client';
+const query = (v: Record) => {
+ const p = new URLSearchParams();
+ Object.entries(v).forEach(([k, x]) => x !== undefined && p.set(k, String(x)));
+ return p.toString();
+};
+export const fetchContent = (v: {
+ page: number;
+ pageSize: number;
+ status?: string;
+ type?: string;
+}) => apiRequest(`/api/v1/admin/content?${query(v)}`);
+export const createContent = (input: CreateContentPostRequest) =>
+ apiRequest('/api/v1/admin/content', {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+export const transitionContent = (id: string, input: TransitionContentPostRequest) =>
+ apiRequest(`/api/v1/admin/content/${id}/transition`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+export const fetchDeliveries = (v: { page: number; pageSize: number; status?: string }) =>
+ apiRequest(
+ `/api/v1/admin/notification-deliveries?${query(v)}`,
+ );
+export const retryDelivery = (id: string) =>
+ apiRequest<{ queued: true }>(`/api/v1/admin/notification-deliveries/${id}/retry`, {
+ method: 'POST',
+ });
diff --git a/apps/admin-web/src/api/follow-ups.ts b/apps/admin-web/src/api/follow-ups.ts
new file mode 100644
index 0000000..af3821a
--- /dev/null
+++ b/apps/admin-web/src/api/follow-ups.ts
@@ -0,0 +1,55 @@
+import type {
+ CompleteFollowUpRequest,
+ CreateFollowUpRequest,
+ FollowUpDetailResponse,
+ FollowUpMutationResponse,
+ FollowUpStatus,
+ FollowUpType,
+ FollowUpWorkbenchSummary,
+ PaginatedFollowUpsResponse,
+} from '@lehuo/contracts';
+
+import { apiRequest } from './api-client';
+
+function queryString(values: Record): string {
+ const params = new URLSearchParams();
+ Object.entries(values).forEach(([key, value]) => {
+ if (value !== undefined && value !== '') params.set(key, String(value));
+ });
+ return params.toString();
+}
+
+export function fetchFollowUpSummary(): Promise {
+ return apiRequest('/api/v1/admin/follow-ups/summary');
+}
+
+export function fetchFollowUps(query: {
+ page: number;
+ pageSize: number;
+ status?: FollowUpStatus;
+ followUpType?: FollowUpType;
+ overdue?: boolean;
+}): Promise {
+ return apiRequest(`/api/v1/admin/follow-ups?${queryString(query)}`);
+}
+
+export function fetchFollowUp(publicId: string): Promise {
+ return apiRequest(`/api/v1/admin/follow-ups/${encodeURIComponent(publicId)}`);
+}
+
+export function createFollowUp(input: CreateFollowUpRequest): Promise {
+ return apiRequest('/api/v1/admin/follow-ups', {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+}
+
+export function completeFollowUp(
+ publicId: string,
+ input: CompleteFollowUpRequest,
+): Promise {
+ return apiRequest(`/api/v1/admin/follow-ups/${encodeURIComponent(publicId)}/complete`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+}
diff --git a/apps/admin-web/src/api/participation.ts b/apps/admin-web/src/api/participation.ts
new file mode 100644
index 0000000..f261b9b
--- /dev/null
+++ b/apps/admin-web/src/api/participation.ts
@@ -0,0 +1,89 @@
+import type {
+ ActivityDetailResponse,
+ ActivityRegistrationResponse,
+ AssigneeOption,
+ CheckInActivityRegistrationRequest,
+ CreateActivityRequest,
+ CreateProjectRequest,
+ PaginatedActivitiesResponse,
+ PaginatedActivityRegistrationsResponse,
+ PaginatedProjectsResponse,
+ PaginatedVolunteersResponse,
+ ProjectDetailResponse,
+ RecordServiceMinutesRequest,
+ ReviewActivityRegistrationRequest,
+ ReviewVolunteerRequest,
+ TransitionActivityRequest,
+ TransitionProjectRequest,
+} from '@lehuo/contracts';
+
+import { apiRequest } from './api-client';
+
+const query = (values: Record) => {
+ const params = new URLSearchParams();
+ Object.entries(values).forEach(
+ ([key, value]) => value !== undefined && params.set(key, String(value)),
+ );
+ return params.toString();
+};
+
+export const fetchVolunteers = (values: { page: number; pageSize: number; status?: string }) =>
+ apiRequest(`/api/v1/admin/volunteers?${query(values)}`);
+export const reviewVolunteer = (publicId: string, input: ReviewVolunteerRequest) =>
+ apiRequest(`/api/v1/admin/volunteers/${publicId}/review`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+
+export const fetchProjects = (values: { page: number; pageSize: number; status?: string }) =>
+ apiRequest(`/api/v1/admin/projects?${query(values)}`);
+export const fetchProjectManagerOptions = () =>
+ apiRequest('/api/v1/admin/project-manager-options');
+export const createProject = (input: CreateProjectRequest) =>
+ apiRequest('/api/v1/admin/projects', {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+export const transitionProject = (publicId: string, input: TransitionProjectRequest) =>
+ apiRequest(`/api/v1/admin/projects/${publicId}/transition`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+
+export const fetchActivities = (values: { page: number; pageSize: number; status?: string }) =>
+ apiRequest(`/api/v1/admin/activities?${query(values)}`);
+export const createActivity = (input: CreateActivityRequest) =>
+ apiRequest('/api/v1/admin/activities', {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+export const transitionActivity = (publicId: string, input: TransitionActivityRequest) =>
+ apiRequest(`/api/v1/admin/activities/${publicId}/transition`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+
+export const fetchRegistrations = (values: {
+ page: number;
+ pageSize: number;
+ status?: string;
+ activityPublicId?: string;
+}) =>
+ apiRequest(
+ `/api/v1/admin/activity-registrations?${query(values)}`,
+ );
+export const reviewRegistration = (publicId: string, input: ReviewActivityRegistrationRequest) =>
+ apiRequest(
+ `/api/v1/admin/activity-registrations/${publicId}/review`,
+ { method: 'POST', body: JSON.stringify(input) },
+ );
+export const checkInRegistration = (publicId: string, input: CheckInActivityRegistrationRequest) =>
+ apiRequest(
+ `/api/v1/admin/activity-registrations/${publicId}/check-in`,
+ { method: 'POST', body: JSON.stringify(input) },
+ );
+export const recordServiceMinutes = (publicId: string, input: RecordServiceMinutesRequest) =>
+ apiRequest(
+ `/api/v1/admin/activity-registrations/${publicId}/service-minutes`,
+ { method: 'POST', body: JSON.stringify(input) },
+ );
diff --git a/apps/admin-web/src/api/service-applications.ts b/apps/admin-web/src/api/service-applications.ts
new file mode 100644
index 0000000..2bfe874
--- /dev/null
+++ b/apps/admin-web/src/api/service-applications.ts
@@ -0,0 +1,25 @@
+import type {
+ AdminServiceApplicationQuery,
+ PaginatedServiceApplicationsResponse,
+ ServiceApplicationDetailResponse,
+} from '@lehuo/contracts';
+
+import { apiRequest } from './api-client';
+
+export function fetchAdminServiceApplications(
+ query: AdminServiceApplicationQuery,
+): Promise {
+ const params = new URLSearchParams();
+ if (query.page) params.set('page', String(query.page));
+ if (query.pageSize) params.set('pageSize', String(query.pageSize));
+ if (query.applicationNo) params.set('applicationNo', query.applicationNo);
+ if (query.serviceCategory) params.set('serviceCategory', query.serviceCategory);
+ if (query.status) params.set('status', query.status);
+ return apiRequest(`/api/v1/admin/service-applications?${params.toString()}`);
+}
+
+export function fetchAdminServiceApplication(
+ publicId: string,
+): Promise {
+ return apiRequest(`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}`);
+}
diff --git a/apps/admin-web/src/api/statistics.ts b/apps/admin-web/src/api/statistics.ts
new file mode 100644
index 0000000..35ccc1d
--- /dev/null
+++ b/apps/admin-web/src/api/statistics.ts
@@ -0,0 +1,8 @@
+import type { StatisticsOverview } from '@lehuo/contracts';
+import { apiRequest } from './api-client';
+export const fetchStatistics = (startDate?: string, endDate?: string) => {
+ const p = new URLSearchParams();
+ if (startDate) p.set('startDate', startDate);
+ if (endDate) p.set('endDate', endDate);
+ return apiRequest(`/api/v1/admin/statistics/overview?${p.toString()}`);
+};
diff --git a/apps/admin-web/src/app-info.spec.ts b/apps/admin-web/src/app-info.spec.ts
new file mode 100644
index 0000000..0d80dcc
--- /dev/null
+++ b/apps/admin-web/src/app-info.spec.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+
+import { ADMIN_APP_TITLE } from './app-info';
+
+describe('admin app information', () => {
+ it('uses the approved public-service brand', () => {
+ expect(ADMIN_APP_TITLE).toContain('乐活公益');
+ });
+});
diff --git a/apps/admin-web/src/app-info.ts b/apps/admin-web/src/app-info.ts
new file mode 100644
index 0000000..a73e7dd
--- /dev/null
+++ b/apps/admin-web/src/app-info.ts
@@ -0,0 +1,2 @@
+export const ADMIN_APP_TITLE = '乐活公益管理后台';
+export const PHASE_ZERO_MESSAGE = '工程基线已就绪,业务功能将在后续阶段实现。';
diff --git a/apps/admin-web/src/application-format.spec.ts b/apps/admin-web/src/application-format.spec.ts
new file mode 100644
index 0000000..a82e75e
--- /dev/null
+++ b/apps/admin-web/src/application-format.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+
+import { formatChinaDateTime, statusTone } from './application-format';
+
+describe('application formatting', () => {
+ it('formats API timestamps for China display', () => {
+ expect(formatChinaDateTime('2026-08-11T02:30:00.000Z')).toContain('2026-08-11');
+ expect(formatChinaDateTime('invalid')).toBe('时间待确认');
+ });
+
+ it('uses text-compatible semantic status tones', () => {
+ expect(statusTone('SUBMITTED')).toBe('info');
+ expect(statusTone('CLOSED')).toBe('success');
+ expect(statusTone('REJECTED')).toBe('danger');
+ });
+});
diff --git a/apps/admin-web/src/application-format.ts b/apps/admin-web/src/application-format.ts
new file mode 100644
index 0000000..bfdbaef
--- /dev/null
+++ b/apps/admin-web/src/application-format.ts
@@ -0,0 +1,24 @@
+import type { ServiceApplicationStatus } from '@lehuo/contracts';
+
+export function formatChinaDateTime(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '时间待确认';
+ return new Intl.DateTimeFormat('zh-CN', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })
+ .format(date)
+ .replaceAll('/', '-');
+}
+
+export function statusTone(status: ServiceApplicationStatus): string {
+ if (['REJECTED', 'OUT_OF_SCOPE', 'CANCELLED'].includes(status)) return 'danger';
+ if (['CLOSED', 'SERVICE_COMPLETED'].includes(status)) return 'success';
+ if (['SUSPENDED', 'FOLLOW_UP_PENDING'].includes(status)) return 'warning';
+ return 'info';
+}
diff --git a/apps/admin-web/src/login-form.spec.ts b/apps/admin-web/src/login-form.spec.ts
new file mode 100644
index 0000000..99e464e
--- /dev/null
+++ b/apps/admin-web/src/login-form.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+
+import { validateLoginForm } from './login-form';
+
+describe('validateLoginForm', () => {
+ it('accepts a valid staff login form', () => {
+ expect(validateLoginForm('staff.admin', 'correct horse battery staple')).toEqual({});
+ });
+
+ it('returns field-level recovery guidance', () => {
+ expect(validateLoginForm('?', 'short')).toEqual({
+ username: '请输入有效的登录账号',
+ password: '密码长度应为 12 至 128 个字符',
+ });
+ });
+});
diff --git a/apps/admin-web/src/login-form.ts b/apps/admin-web/src/login-form.ts
new file mode 100644
index 0000000..f7a48c5
--- /dev/null
+++ b/apps/admin-web/src/login-form.ts
@@ -0,0 +1,15 @@
+export interface LoginFormErrors {
+ username?: string;
+ password?: string;
+}
+
+export function validateLoginForm(username: string, password: string): LoginFormErrors {
+ const errors: LoginFormErrors = {};
+ if (!/^[a-z0-9._-]{3,64}$/.test(username.trim().toLowerCase())) {
+ errors.username = '请输入有效的登录账号';
+ }
+ if (password.length < 12 || password.length > 128) {
+ errors.password = '密码长度应为 12 至 128 个字符';
+ }
+ return errors;
+}
diff --git a/apps/admin-web/src/main.ts b/apps/admin-web/src/main.ts
new file mode 100644
index 0000000..a6acc16
--- /dev/null
+++ b/apps/admin-web/src/main.ts
@@ -0,0 +1,7 @@
+import { createPinia } from 'pinia';
+import { createApp } from 'vue';
+
+import App from './App.vue';
+import './styles.css';
+
+createApp(App).use(createPinia()).mount('#app');
diff --git a/apps/admin-web/src/stores/auth.ts b/apps/admin-web/src/stores/auth.ts
new file mode 100644
index 0000000..36571ec
--- /dev/null
+++ b/apps/admin-web/src/stores/auth.ts
@@ -0,0 +1,39 @@
+import { defineStore } from 'pinia';
+import { computed, ref } from 'vue';
+
+import type { AdminLoginResponse, AuthUser, CurrentUserResponse } from '@lehuo/contracts';
+
+import { apiRequest } from '../api/api-client';
+
+export const useAuthStore = defineStore('auth', () => {
+ const user = ref(null);
+ const checking = ref(true);
+ const authenticated = computed(() => user.value !== null);
+
+ async function restore(): Promise {
+ checking.value = true;
+ try {
+ const result = await apiRequest('/api/v1/admin/auth/me');
+ user.value = result.user;
+ } catch {
+ user.value = null;
+ } finally {
+ checking.value = false;
+ }
+ }
+
+ async function login(username: string, password: string): Promise {
+ const result = await apiRequest('/api/v1/admin/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ username, password }),
+ });
+ user.value = result.user;
+ }
+
+ async function logout(): Promise {
+ await apiRequest('/api/v1/admin/auth/logout', { method: 'POST', body: '{}' });
+ user.value = null;
+ }
+
+ return { user, checking, authenticated, restore, login, logout };
+});
diff --git a/apps/admin-web/src/styles.css b/apps/admin-web/src/styles.css
new file mode 100644
index 0000000..e16ac5d
--- /dev/null
+++ b/apps/admin-web/src/styles.css
@@ -0,0 +1,803 @@
+:root {
+ --color-primary: #ff6b35;
+ --color-primary-hover: #e95a28;
+ --color-primary-soft: #fff1eb;
+ --color-text: #1f2329;
+ --color-text-secondary: #646a73;
+ --color-danger: #b42318;
+ --color-success: #237a2b;
+ --color-warning: #8a5a00;
+ --color-info: #145ea8;
+ --color-surface: #fff;
+ --color-background: #f5f5f5;
+ --color-border: #d9dce1;
+ --color-sidebar: #263342;
+ --focus-ring: 0 0 0 3px rgb(255 107 53 / 28%);
+ color: var(--color-text);
+ background: var(--color-background);
+ font-family:
+ -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei',
+ sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+}
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+button {
+ cursor: pointer;
+}
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+input:focus-visible,
+select:focus-visible,
+textarea:focus-visible,
+button:focus-visible {
+ outline: none;
+ box-shadow: var(--focus-ring);
+}
+
+.app-shell {
+ min-height: 100vh;
+ padding: 24px 16px;
+}
+.app-shell--centered {
+ display: grid;
+ place-items: center;
+}
+.welcome-card,
+.login-card {
+ width: min(560px, 100%);
+ padding: 32px;
+ border: 1px solid var(--color-border);
+ border-radius: 16px;
+ background: var(--color-surface);
+ box-shadow: 0 4px 16px rgb(0 0 0 / 6%);
+}
+.welcome-card h1,
+.login-card h1 {
+ margin: 8px 0 16px;
+ font-size: clamp(24px, 4vw, 32px);
+ line-height: 1.25;
+}
+.welcome-card p,
+.login-card p {
+ line-height: 1.7;
+}
+.eyebrow {
+ margin: 0;
+ color: var(--color-primary-hover);
+ font-weight: 600;
+}
+.intro {
+ margin-bottom: 24px;
+ color: var(--color-text-secondary);
+}
+.login-card form {
+ display: grid;
+ gap: 8px;
+}
+.login-card label {
+ margin-top: 8px;
+ font-weight: 600;
+}
+.login-card input {
+ width: 100%;
+ min-height: 48px;
+ padding: 10px 12px;
+ border: 1px solid var(--color-border);
+ border-radius: 12px;
+ color: var(--color-text);
+ background: var(--color-surface);
+}
+.login-card input:hover {
+ border-color: #aeb3bb;
+}
+input[aria-invalid='true'] {
+ border-color: var(--color-danger);
+}
+.password-field {
+ position: relative;
+}
+.password-field input {
+ padding-right: 72px;
+}
+.password-toggle {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ min-width: 64px;
+ min-height: 44px;
+ border: 0;
+ border-radius: 10px;
+ color: var(--color-primary-hover);
+ background: transparent;
+}
+.field-error,
+.form-message,
+.global-message {
+ margin: 0;
+ color: var(--color-danger);
+ font-size: 14px;
+}
+
+.primary-button,
+.secondary-button {
+ min-height: 48px;
+ margin-top: 16px;
+ padding: 10px 20px;
+ border-radius: 12px;
+ font-weight: 600;
+ transition:
+ background-color 180ms ease,
+ border-color 180ms ease;
+}
+.primary-button {
+ border: 1px solid var(--color-primary);
+ color: #fff;
+ background: var(--color-primary);
+}
+.primary-button:hover:not(:disabled) {
+ border-color: var(--color-primary-hover);
+ background: var(--color-primary-hover);
+}
+.secondary-button {
+ border: 1px solid var(--color-primary);
+ color: var(--color-primary-hover);
+ background: var(--color-primary-soft);
+}
+.compact-button {
+ min-height: 44px;
+ margin: 0;
+ padding: 8px 18px;
+}
+
+.admin-layout {
+ display: grid;
+ min-height: 100vh;
+ grid-template-columns: 240px minmax(0, 1fr);
+ background: var(--color-background);
+}
+.admin-sidebar {
+ position: sticky;
+ top: 0;
+ display: flex;
+ height: 100vh;
+ padding: 24px 16px;
+ flex-direction: column;
+ color: #fff;
+ background: var(--color-sidebar);
+}
+.brand-block {
+ display: flex;
+ min-height: 64px;
+ padding: 8px;
+ align-items: center;
+ gap: 12px;
+}
+.brand-block strong,
+.brand-block small {
+ display: block;
+}
+.brand-block strong {
+ font-size: 18px;
+}
+.brand-block small {
+ margin-top: 4px;
+ color: #bfc8d2;
+}
+.brand-mark {
+ width: 36px;
+ height: 36px;
+ border: 8px solid var(--color-primary);
+ border-top-color: #ffb13b;
+ border-radius: 50% 50% 50% 16px;
+ transform: rotate(-18deg);
+}
+.admin-sidebar nav {
+ margin-top: 28px;
+ overflow-y: auto;
+}
+.nav-item {
+ width: 100%;
+ min-height: 48px;
+ padding: 10px 14px;
+ border: 0;
+ border-radius: 10px;
+ color: #e8edf2;
+ background: transparent;
+ text-align: left;
+}
+.nav-item--active {
+ color: #fff;
+ background: rgb(255 107 53 / 22%);
+ box-shadow: inset 3px 0 var(--color-primary);
+}
+.admin-sidebar nav {
+ display: grid;
+ gap: 6px;
+}
+.inline-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.link-button--danger {
+ color: var(--color-danger);
+}
+.phase-note {
+ margin-top: auto;
+ padding: 14px;
+ border-radius: 10px;
+ color: #cbd3dc;
+ background: rgb(255 255 255 / 7%);
+ font-size: 13px;
+ line-height: 1.6;
+}
+.admin-main {
+ min-width: 0;
+}
+.admin-topbar {
+ display: flex;
+ min-height: 64px;
+ padding: 0 28px;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--color-border);
+ background: #fff;
+}
+.admin-topbar strong,
+.admin-topbar span {
+ display: block;
+}
+.admin-topbar span {
+ margin-top: 2px;
+ color: var(--color-text-secondary);
+ font-size: 13px;
+}
+.text-button,
+.back-button,
+.link-button {
+ min-height: 44px;
+ padding: 8px 12px;
+ border: 0;
+ border-radius: 8px;
+ color: var(--color-info);
+ background: transparent;
+ font-weight: 600;
+}
+.text-button:hover,
+.back-button:hover,
+.link-button:hover {
+ background: #eaf4ff;
+}
+.admin-main > .global-message {
+ margin: 12px 28px 0;
+}
+
+.content-area {
+ width: min(1440px, 100%);
+ margin: 0 auto;
+ padding: 28px;
+}
+.page-header {
+ display: flex;
+ margin-bottom: 22px;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+}
+.page-header h1 {
+ margin: 0;
+ font-size: 28px;
+ line-height: 1.3;
+}
+.page-header p {
+ margin: 8px 0 0;
+ color: var(--color-text-secondary);
+ line-height: 1.6;
+}
+.back-button {
+ margin: -8px 0 8px -12px;
+}
+.result-count {
+ padding: 8px 12px;
+ border-radius: 8px;
+ color: var(--color-text-secondary);
+ background: #fff;
+ font-variant-numeric: tabular-nums;
+}
+
+.filter-panel {
+ display: grid;
+ margin-bottom: 20px;
+ padding: 20px;
+ grid-template-columns: minmax(180px, 1.2fr) minmax(150px, 1fr) minmax(150px, 1fr) auto;
+ align-items: end;
+ gap: 16px;
+ border: 1px solid var(--color-border);
+ border-radius: 14px;
+ background: #fff;
+}
+.filter-field {
+ display: grid;
+ gap: 8px;
+}
+.filter-field label {
+ font-size: 14px;
+ font-weight: 600;
+}
+.filter-field input,
+.filter-field select {
+ width: 100%;
+ min-height: 44px;
+ padding: 8px 12px;
+ border: 1px solid var(--color-border);
+ border-radius: 9px;
+ color: var(--color-text);
+ background: #fff;
+}
+.filter-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.panel,
+.table-panel,
+.empty-panel {
+ border: 1px solid var(--color-border);
+ border-radius: 14px;
+ background: #fff;
+ box-shadow: 0 3px 12px rgb(0 0 0 / 4%);
+}
+.panel {
+ padding: 24px;
+}
+.panel h2 {
+ margin: 0 0 20px;
+ font-size: 19px;
+}
+.panel h3 {
+ margin: 24px 0 10px;
+ font-size: 16px;
+}
+.empty-panel {
+ display: grid;
+ min-height: 280px;
+ padding: 32px;
+ place-content: center;
+ justify-items: center;
+ text-align: center;
+}
+.empty-panel h2 {
+ margin: 0;
+ font-size: 21px;
+}
+.empty-panel p {
+ max-width: 520px;
+ margin: 12px 0;
+ color: var(--color-text-secondary);
+ line-height: 1.7;
+}
+.table-scroll {
+ overflow-x: auto;
+}
+table {
+ width: 100%;
+ min-width: 980px;
+ border-collapse: collapse;
+}
+th,
+td {
+ padding: 15px 16px;
+ border-bottom: 1px solid #ebecef;
+ text-align: left;
+ vertical-align: middle;
+}
+th {
+ color: #4e545d;
+ background: #fafafa;
+ font-size: 14px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+td {
+ font-size: 14px;
+}
+tbody tr:hover {
+ background: #fffaf7;
+}
+.tabular {
+ font-variant-numeric: tabular-nums;
+}
+.pagination {
+ display: flex;
+ min-height: 64px;
+ padding: 10px 16px;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 16px;
+}
+.pagination button {
+ min-height: 40px;
+ padding: 6px 14px;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ color: var(--color-text);
+ background: #fff;
+}
+.pagination span {
+ color: var(--color-text-secondary);
+ font-size: 14px;
+}
+
+.status-tag {
+ display: inline-flex;
+ min-height: 28px;
+ padding: 4px 9px;
+ align-items: center;
+ border-radius: 7px;
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+.status-tag--info {
+ color: var(--color-info);
+ background: #eaf4ff;
+}
+.status-tag--success {
+ color: var(--color-success);
+ background: #edf8ee;
+}
+.status-tag--warning {
+ color: var(--color-warning);
+ background: #fff6d8;
+}
+.status-tag--danger {
+ color: var(--color-danger);
+ background: #fff0ee;
+}
+
+.audit-notice {
+ margin-bottom: 20px;
+ padding: 14px 16px;
+ border-left: 4px solid #d18a00;
+ border-radius: 8px;
+ color: #604500;
+ background: #fff7de;
+ line-height: 1.6;
+}
+.detail-grid {
+ display: grid;
+ margin-bottom: 20px;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+}
+.detail-list {
+ margin: 0;
+}
+.detail-list > div {
+ display: grid;
+ min-height: 48px;
+ padding: 11px 0;
+ grid-template-columns: 120px 1fr;
+ border-top: 1px solid #ebecef;
+}
+.detail-list dt {
+ color: var(--color-text-secondary);
+}
+.detail-list dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+.sensitive-value {
+ font-weight: 600;
+}
+.description-text {
+ margin: 0;
+ padding: 16px;
+ border-radius: 9px;
+ background: #fafafa;
+ line-height: 1.75;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.timeline-panel {
+ margin-bottom: 28px;
+}
+.workflow-panel {
+ margin-bottom: 20px;
+}
+.workflow-panel > p,
+.consent-note {
+ color: var(--color-text-secondary);
+ line-height: 1.6;
+}
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px 20px;
+}
+.form-grid label {
+ display: grid;
+ align-content: start;
+ gap: 8px;
+ font-size: 14px;
+ font-weight: 600;
+}
+.form-grid input:not([type='checkbox']),
+.form-grid select,
+.form-grid textarea {
+ width: 100%;
+ min-height: 44px;
+ padding: 9px 12px;
+ border: 1px solid var(--color-border);
+ border-radius: 9px;
+ color: var(--color-text);
+ background: #fff;
+}
+.form-grid select[multiple] {
+ min-height: 132px;
+}
+.form-grid textarea {
+ min-height: 112px;
+ resize: vertical;
+ line-height: 1.6;
+}
+.form-span {
+ grid-column: 1 / -1;
+}
+.form-actions {
+ display: flex;
+ gap: 12px;
+}
+.checkbox-group {
+ display: flex;
+ padding: 14px;
+ flex-wrap: wrap;
+ gap: 12px 20px;
+ border: 1px solid var(--color-border);
+ border-radius: 10px;
+}
+.checkbox-group legend {
+ padding: 0 6px;
+ font-weight: 600;
+}
+.contact-summary {
+ padding: 16px;
+ border-radius: 10px;
+ background: #fafafa;
+}
+.contact-summary p {
+ margin: 8px 0 0;
+}
+.record-list {
+ margin-top: 20px;
+}
+.summary-grid {
+ display: grid;
+ margin-bottom: 20px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+}
+.summary-card {
+ display: grid;
+ min-height: 126px;
+ padding: 18px;
+ align-content: space-between;
+ border: 1px solid var(--color-border);
+ border-radius: 14px;
+ color: var(--color-text);
+ background: #fff;
+ text-align: left;
+ box-shadow: 0 3px 12px rgb(0 0 0 / 4%);
+}
+button.summary-card:hover {
+ border-color: var(--color-danger);
+ background: #fff8f7;
+}
+.summary-card strong {
+ font-size: 32px;
+ font-variant-numeric: tabular-nums;
+}
+.summary-card small,
+.block-note {
+ color: var(--color-text-secondary);
+}
+.summary-card--danger strong,
+.danger-text {
+ color: var(--color-danger);
+}
+.block-note {
+ display: block;
+ margin-top: 4px;
+ font-size: 12px;
+}
+.consent-check {
+ display: flex !important;
+ min-height: 44px;
+ align-items: center;
+ gap: 10px;
+ line-height: 1.5;
+}
+.record-card {
+ padding: 18px 0;
+ border-top: 1px solid #ebecef;
+}
+.record-card header {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+}
+.record-card time {
+ color: var(--color-text-secondary);
+ font-size: 13px;
+}
+.record-card p {
+ line-height: 1.65;
+ white-space: pre-wrap;
+}
+.admin-timeline {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.admin-timeline li {
+ position: relative;
+ display: grid;
+ min-height: 104px;
+ grid-template-columns: 24px 1fr;
+ gap: 14px;
+}
+.admin-timeline li:not(:last-child)::before {
+ position: absolute;
+ top: 18px;
+ bottom: -4px;
+ left: 7px;
+ width: 2px;
+ content: '';
+ background: #ffd3c1;
+}
+.timeline-marker {
+ position: relative;
+ z-index: 1;
+ width: 16px;
+ height: 16px;
+ margin-top: 3px;
+ border: 4px solid #ffe4d8;
+ border-radius: 50%;
+ background: var(--color-primary);
+}
+.admin-timeline strong {
+ font-size: 16px;
+}
+.admin-timeline p {
+ margin: 6px 0;
+ color: var(--color-text-secondary);
+}
+.admin-timeline time {
+ color: #767d87;
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+}
+.table-skeleton {
+ height: 50px;
+ margin-bottom: 10px;
+ border-radius: 8px;
+ background: linear-gradient(90deg, #f0f1f3 25%, #fafafa 50%, #f0f1f3 75%);
+ background-size: 200% 100%;
+ animation: skeleton 1.4s ease-in-out infinite;
+}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@keyframes skeleton {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+}
+
+@media (max-width: 1100px) {
+ .admin-layout {
+ grid-template-columns: 190px minmax(0, 1fr);
+ }
+ .filter-panel {
+ grid-template-columns: 1fr 1fr;
+ }
+ .filter-actions {
+ align-self: end;
+ }
+ .detail-grid {
+ grid-template-columns: 1fr;
+ }
+ .form-grid {
+ grid-template-columns: 1fr;
+ }
+ .form-span {
+ grid-column: auto;
+ }
+ .summary-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (max-width: 760px) {
+ .admin-layout {
+ display: block;
+ }
+ .admin-sidebar {
+ position: static;
+ width: 100%;
+ height: auto;
+ padding: 12px 16px;
+ }
+ .brand-block {
+ min-height: 52px;
+ }
+ .admin-sidebar nav,
+ .phase-note {
+ display: none;
+ }
+ .admin-topbar {
+ padding: 0 16px;
+ }
+ .content-area {
+ padding: 20px 16px;
+ }
+ .page-header {
+ flex-direction: column;
+ }
+ .filter-panel {
+ grid-template-columns: 1fr;
+ }
+ .filter-actions {
+ width: 100%;
+ }
+ .filter-actions button {
+ flex: 1;
+ }
+ .detail-list > div {
+ grid-template-columns: 100px 1fr;
+ }
+ .welcome-card,
+ .login-card {
+ padding: 24px 20px;
+ }
+ .summary-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/apps/admin-web/src/views/ActivitiesView.vue b/apps/admin-web/src/views/ActivitiesView.vue
new file mode 100644
index 0000000..51fed17
--- /dev/null
+++ b/apps/admin-web/src/views/ActivitiesView.vue
@@ -0,0 +1,370 @@
+
+
+
+
+
+
+ {{ message }}
+
+
+
+
+
diff --git a/apps/admin-web/src/views/BeneficiariesView.vue b/apps/admin-web/src/views/BeneficiariesView.vue
new file mode 100644
index 0000000..fd67684
--- /dev/null
+++ b/apps/admin-web/src/views/BeneficiariesView.vue
@@ -0,0 +1,267 @@
+
+
+
+
+
+
+ 档案含敏感信息,本次查看和修改均会记录安全审计。
+
+
+
+
+
+
+
+ 服务对象加载失败
+ {{ message }}
+
+
+
+ 暂无符合条件的服务对象
+ 档案将在申请通过评估后建立。
+
+
+
+
+
diff --git a/apps/admin-web/src/views/ContentView.vue b/apps/admin-web/src/views/ContentView.vue
new file mode 100644
index 0000000..0254fca
--- /dev/null
+++ b/apps/admin-web/src/views/ContentView.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+ {{ message }}
+
+
+ 暂无内容
+ 可先创建公益动态或机构介绍草稿。
+
+
+
+
diff --git a/apps/admin-web/src/views/FollowUpsView.vue b/apps/admin-web/src/views/FollowUpsView.vue
new file mode 100644
index 0000000..bfd0f68
--- /dev/null
+++ b/apps/admin-web/src/views/FollowUpsView.vue
@@ -0,0 +1,469 @@
+
+
+
+
+
+
+
+ 回访原因与结论属于敏感服务信息,仅限履职使用;查看与提交均会留痕。
+
+ {{ message }}
+
+
+ 回访安排
+
+
+
- 服务对象
+ - {{ selected.beneficiaryDisplayName }}
+
+
+
- 服务类型
+ - {{ SERVICE_CATEGORY_LABELS[selected.category] }}
+
+
+
- 计划时间
+ - {{ formatChinaDateTime(selected.dueAt) }}
+
+
+
- 关联工单
+ - {{ selected.serviceCaseNo ?? '独立回访' }}
+
+
+ 回访原因
+ {{ selected.reason }}
+
+
+ 回访结果
+
+
+ {{ selected.resultLabel }}
+
+ {{ selected.resultNote }}
+
+ 已创建继续帮扶申请:{{ selected.continuedApplicationNo }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 今日到期{{ summary.dueToday }}按时联系
+
+
+ 未来 7 日{{ summary.dueThisWeek }}提前安排
+
+
+ 重点关爱待办{{ summary.keyCarePending }}持续关注
+
+
+ {{ message }}
+
+
+
+
+ 暂无符合条件的回访
+ 服务完成或工作人员新建回访后,任务会显示在这里。
+
+
+
+
+
diff --git a/apps/admin-web/src/views/NotificationDeliveriesView.vue b/apps/admin-web/src/views/NotificationDeliveriesView.vue
new file mode 100644
index 0000000..91f47a0
--- /dev/null
+++ b/apps/admin-web/src/views/NotificationDeliveriesView.vue
@@ -0,0 +1,74 @@
+
+
+
+
+ {{ message }}
+
+
+ 暂无微信投递
+ 用户授权订阅且模板配置完成后会生成投递任务。
+
+
+
+
diff --git a/apps/admin-web/src/views/ProjectsView.vue b/apps/admin-web/src/views/ProjectsView.vue
new file mode 100644
index 0000000..8ad48ab
--- /dev/null
+++ b/apps/admin-web/src/views/ProjectsView.vue
@@ -0,0 +1,235 @@
+
+
+
+
+
+
+ {{ message }}
+
+
+
+
+
diff --git a/apps/admin-web/src/views/ServiceApplicationsView.vue b/apps/admin-web/src/views/ServiceApplicationsView.vue
new file mode 100644
index 0000000..f9e31b7
--- /dev/null
+++ b/apps/admin-web/src/views/ServiceApplicationsView.vue
@@ -0,0 +1,572 @@
+
+
+
+
+
+
+
+
+ 此页面包含服务联系所需的敏感信息,本次查看已记录安全审计。请勿复制或用于服务以外的目的。
+
+
+
+ 受理进度
+ 状态必须按顺序推进;系统会在同一事务中写入状态历史和审计记录。
+
+
+
+
+
+
+ {{ message }}
+
+
+
+ 申请概况
+
+
+
- 服务编号
+ - {{ selected.applicationNo }}
+
+
+
- 服务类型
+ - {{ selected.serviceCategoryLabel }}
+
+
+
- 申请关系
+ - {{ selected.applyModeLabel }}
+
+
+
- 服务对象
+ - {{ selected.beneficiaryName }}
+
+
+
- 所在地区
+ - {{ selected.regionCode }}
+
+
+
- 联系电话
+ - {{ selected.contactMobile }}
+
+
+
- 期望联系
+ - {{ selected.preferredContactTime ?? '未填写' }}
+
+
+
- 提交时间
+ - {{ formatChinaDateTime(selected.submittedAt) }}
+
+
+
+
+
+ 补充情况
+
+
+
- 希望上门
+ - {{ selected.needsHomeVisit ? '是' : '否或未说明' }}
+
+
+
- 行动不便
+ - {{ selected.mobilityDifficulty ? '是' : '否或未说明' }}
+
+
+ 需求说明
+ {{ selected.description }}
+
+
+
+
+ 状态历史
+
+ -
+
+
+
{{ history.statusLabel }}
+
{{ history.reasonLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 没有符合条件的服务申请
+ 可以调整筛选条件后重新查询。
+
+
+
+
+ 您暂无权限查看服务申请
+ {{ message }}
+
+
+
+ 服务申请加载失败
+ {{ message }}
+
+
+
+
+ {{ message }}
+
+
+
diff --git a/apps/admin-web/src/views/ServiceCasesView.vue b/apps/admin-web/src/views/ServiceCasesView.vue
new file mode 100644
index 0000000..d044b35
--- /dev/null
+++ b/apps/admin-web/src/views/ServiceCasesView.vue
@@ -0,0 +1,590 @@
+
+
+
+
+
+
+
+ 仅可将以下信息用于已分配的公益服务;查看、指派和记录操作均会留痕。
+
+ {{ message }}
+
+
+ 服务计划
+
+
+
- 地区
+ - {{ selected.regionCode }}
+
+
+
- 联系电话
+ - {{ selected.beneficiaryMobile ?? '未留存' }}
+
+
+
- 服务地点
+ - {{ selected.serviceLocation ?? '未填写' }}
+
+
+
- 计划说明
+ - {{ selected.summary }}
+
+
+
- 服务总结
+ - {{ selected.resultSummary }}
+
+
+
- 回访计划
+ - {{ selected.followUpPlan }}
+
+
+
+
+
+ 负责人和协作人员
+
+ {{ item.role === 'OWNER' ? '负责人' : '协作' }} · {{ item.displayName }}({{
+ item.type === 'STAFF' ? '工作人员' : '志愿者'
+ }})
+
+
+
+
+
+ 状态历史
+
+ -
+
+
+
{{ item.statusLabel }}
+
{{ item.reasonLabel }}
+
+
+
+
+
+
+
+ 服务记录
+ 工单开始服务后,可在上方添加记录。
+
+
+ {{ record.serviceTypeLabel }}
+
+ {{ record.content }}
+ 结果:{{ record.result }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 工单加载失败
+ {{ message }}
+
+
+
+ 暂无符合条件的工单
+ 申请完成评估与接单后将在此生成工单。
+
+
+
+
+
diff --git a/apps/admin-web/src/views/StatisticsView.vue b/apps/admin-web/src/views/StatisticsView.vue
new file mode 100644
index 0000000..077d110
--- /dev/null
+++ b/apps/admin-web/src/views/StatisticsView.vue
@@ -0,0 +1,77 @@
+
+
+
+
+ {{ message }}
+
+
+
+ 申请数量{{ data.applicationCount }}
+
+
+ 已受理{{ data.acceptedCount }}
+
+
+ 完成服务{{ data.completedServiceCount }}
+
+
+ 服务人次{{ data.servicePersonTimes }}
+
+
+ 服务对象{{ data.uniqueBeneficiaryCount }}
+
+
+ 志愿者{{ data.volunteerCount }}
+
+
+ 活动{{ data.activityCount }}
+
+
+ 志愿分钟{{ data.volunteerMinutes }}
+
+
+
+ 按服务类别
+
+ {{ SERVICE_CATEGORY_LABELS[item.category] }}{{ item.count }}
+
+ 统计区间:{{ data.period.startDate }} 至 {{ data.period.endDate }}
+
+
+
diff --git a/apps/admin-web/src/views/VolunteersView.vue b/apps/admin-web/src/views/VolunteersView.vue
new file mode 100644
index 0000000..5f5c202
--- /dev/null
+++ b/apps/admin-web/src/views/VolunteersView.vue
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+ {{ message }}
+
+
+ 暂无符合条件的志愿者
+ 新的申请会显示在这里。
+
+
+
+
diff --git a/apps/admin-web/src/vue-shim.d.ts b/apps/admin-web/src/vue-shim.d.ts
new file mode 100644
index 0000000..8afcdfb
--- /dev/null
+++ b/apps/admin-web/src/vue-shim.d.ts
@@ -0,0 +1,6 @@
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue';
+
+ const component: DefineComponent;
+ export default component;
+}
diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json
new file mode 100644
index 0000000..ce2e2a4
--- /dev/null
+++ b/apps/admin-web/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "noEmit": true,
+ "types": ["vite/client", "vitest/globals"]
+ },
+ "include": ["src/**/*.ts", "vite.config.ts"]
+}
diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts
new file mode 100644
index 0000000..7b6cf7e
--- /dev/null
+++ b/apps/admin-web/vite.config.ts
@@ -0,0 +1,6 @@
+import vue from '@vitejs/plugin-vue';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [vue()],
+});
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
new file mode 100644
index 0000000..9da4a3e
--- /dev/null
+++ b/apps/api/Dockerfile
@@ -0,0 +1,25 @@
+FROM node:24.13.0-alpine AS build
+WORKDIR /app
+RUN corepack enable
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
+COPY apps/api/package.json apps/api/package.json
+COPY packages/contracts/package.json packages/contracts/package.json
+RUN pnpm install --frozen-lockfile
+COPY apps/api apps/api
+COPY packages/contracts packages/contracts
+RUN DATABASE_URL=mysql://build:build@127.0.0.1:3306/build pnpm --filter @lehuo/api... build
+
+FROM node:24.13.0-alpine AS runtime
+ENV NODE_ENV=production
+WORKDIR /app
+RUN corepack enable
+COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
+COPY --from=build /app/node_modules ./node_modules
+COPY --from=build /app/apps/api/package.json ./apps/api/package.json
+COPY --from=build /app/apps/api/node_modules ./apps/api/node_modules
+COPY --from=build /app/apps/api/dist ./apps/api/dist
+COPY --from=build /app/packages/contracts/package.json ./packages/contracts/package.json
+COPY --from=build /app/packages/contracts/dist ./packages/contracts/dist
+USER node
+EXPOSE 3000
+CMD ["node", "apps/api/dist/main.js"]
diff --git a/apps/api/README.md b/apps/api/README.md
new file mode 100644
index 0000000..b19620e
--- /dev/null
+++ b/apps/api/README.md
@@ -0,0 +1,7 @@
+# API
+
+NestJS 模块化单体 API。已完成 Phase 0 至 Phase 5:健康检查、认证/RBAC/审计、服务申请、服务对象与工单、服务记录与私有附件、回访闭环、公益项目、活动报名、志愿者审核、签到和服务时长。关键状态变更事务一致,敏感档案/记录加密,修改保留 revision 或不可变事实历史。
+
+开发启动:`pnpm --filter @lehuo/api dev`。
+
+附件和项目/活动封面使用 S3 兼容私有对象存储 Adapter。必须配置 `.env.example` 中的 `OBJECT_STORAGE_*` 才能签发上传/下载 URL;未配置时 API 明确返回服务不可用,不使用本地磁盘或模拟存储冒充生产能力。内容与通知属于 Phase 6,尚未开放。
diff --git a/apps/api/package.json b/apps/api/package.json
new file mode 100644
index 0000000..bdd5e41
--- /dev/null
+++ b/apps/api/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@lehuo/api",
+ "version": "0.1.0",
+ "private": true,
+ "type": "commonjs",
+ "scripts": {
+ "bootstrap:admin": "tsx src/scripts/bootstrap-admin.ts",
+ "build": "pnpm --filter @lehuo/contracts build && prisma generate && tsc -p tsconfig.build.json",
+ "db:deploy": "prisma migrate deploy",
+ "db:generate": "prisma generate",
+ "dev": "pnpm --filter @lehuo/contracts build && tsx watch src/main.ts",
+ "start": "node dist/main.js",
+ "test:integration": "pnpm --filter @lehuo/contracts build && tsx test/run-integration.ts",
+ "test:unit": "pnpm --filter @lehuo/contracts build && vitest run --config vitest.config.ts",
+ "typecheck": "prisma generate && tsc --noEmit"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.1106.0",
+ "@aws-sdk/s3-request-presigner": "^3.1106.0",
+ "@lehuo/contracts": "workspace:*",
+ "@nestjs/common": "^11.1.6",
+ "@nestjs/core": "^11.1.6",
+ "@nestjs/platform-express": "^11.1.6",
+ "@nestjs/swagger": "^11.4.6",
+ "@nestjs/throttler": "^6.5.0",
+ "@prisma/adapter-mariadb": "^7.9.1",
+ "@prisma/client": "^7.9.1",
+ "argon2": "^0.45.1",
+ "class-transformer": "^0.5.1",
+ "class-validator": "^0.15.1",
+ "cookie-parser": "^1.4.7",
+ "mariadb": "^3.5.3",
+ "reflect-metadata": "^0.2.2",
+ "rxjs": "^7.8.2"
+ },
+ "devDependencies": {
+ "@nestjs/testing": "^11.1.6",
+ "@types/cookie-parser": "^1.4.10",
+ "@types/express": "^5.0.6",
+ "@types/node": "^24.3.0",
+ "@types/supertest": "^6.0.3",
+ "dotenv": "^17.4.2",
+ "prisma": "^7.9.1",
+ "supertest": "^7.1.4",
+ "tsx": "^4.20.5",
+ "typescript": "^5.9.2",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/apps/api/prisma.config.ts b/apps/api/prisma.config.ts
new file mode 100644
index 0000000..a0d2fbc
--- /dev/null
+++ b/apps/api/prisma.config.ts
@@ -0,0 +1,13 @@
+import 'dotenv/config';
+
+import { defineConfig, env } from 'prisma/config';
+
+export default defineConfig({
+ schema: 'prisma/schema.prisma',
+ migrations: {
+ path: 'prisma/migrations',
+ },
+ datasource: {
+ url: env('DATABASE_URL'),
+ },
+});
diff --git a/apps/api/prisma/migrations/20260810130000_phase1_auth_baseline/migration.sql b/apps/api/prisma/migrations/20260810130000_phase1_auth_baseline/migration.sql
new file mode 100644
index 0000000..a3e7490
--- /dev/null
+++ b/apps/api/prisma/migrations/20260810130000_phase1_auth_baseline/migration.sql
@@ -0,0 +1,184 @@
+-- Phase 1 authentication and authorization baseline.
+-- Internal BIGINT identifiers are never exposed through public APIs.
+
+CREATE TABLE `users` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `status` ENUM('ACTIVE', 'SUSPENDED', 'DISABLED') NOT NULL DEFAULT 'ACTIVE',
+ `user_type` ENUM('PUBLIC', 'STAFF') NOT NULL,
+ `mobile_encrypted` VARCHAR(512) NULL,
+ `mobile_hash` CHAR(64) NULL,
+ `nickname` VARCHAR(100) NULL,
+ `avatar_url` VARCHAR(512) NULL,
+ `last_login_at` DATETIME(3) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `users_public_id_key`(`public_id`),
+ UNIQUE INDEX `users_mobile_hash_key`(`mobile_hash`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `wechat_identities` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `appid` VARCHAR(64) NOT NULL,
+ `openid_ciphertext` VARCHAR(512) NOT NULL,
+ `openid_hash` CHAR(64) NOT NULL,
+ `unionid_ciphertext` VARCHAR(512) NULL,
+ `unionid_hash` CHAR(64) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ INDEX `wechat_identities_user_id_idx`(`user_id`),
+ UNIQUE INDEX `wechat_identities_appid_openid_hash_key`(`appid`, `openid_hash`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `staff_profiles` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `employee_no` VARCHAR(50) NULL,
+ `display_name` VARCHAR(100) NOT NULL,
+ `department` VARCHAR(100) NULL,
+ `status` ENUM('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE',
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `staff_profiles_user_id_key`(`user_id`),
+ UNIQUE INDEX `staff_profiles_employee_no_key`(`employee_no`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `admin_credentials` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `username` VARCHAR(64) NOT NULL,
+ `password_hash` VARCHAR(255) NOT NULL,
+ `failed_login_count` INTEGER UNSIGNED NOT NULL DEFAULT 0,
+ `locked_until` DATETIME(3) NULL,
+ `password_changed_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `admin_credentials_user_id_key`(`user_id`),
+ UNIQUE INDEX `admin_credentials_username_key`(`username`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `auth_sessions` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `channel` ENUM('WECHAT', 'ADMIN') NOT NULL,
+ `token_hash` CHAR(64) NOT NULL,
+ `expires_at` DATETIME(3) NOT NULL,
+ `last_used_at` DATETIME(3) NULL,
+ `revoked_at` DATETIME(3) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE INDEX `auth_sessions_public_id_key`(`public_id`),
+ UNIQUE INDEX `auth_sessions_token_hash_key`(`token_hash`),
+ INDEX `auth_sessions_user_channel_status_idx`(`user_id`, `channel`, `revoked_at`, `expires_at`),
+ INDEX `auth_sessions_expires_at_idx`(`expires_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `roles` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `code` VARCHAR(64) NOT NULL,
+ `name` VARCHAR(100) NOT NULL,
+ `description` VARCHAR(255) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `roles_public_id_key`(`public_id`),
+ UNIQUE INDEX `roles_code_key`(`code`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `permissions` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `code` VARCHAR(100) NOT NULL,
+ `name` VARCHAR(100) NOT NULL,
+ `description` VARCHAR(255) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE INDEX `permissions_code_key`(`code`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `user_roles` (
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `role_id` BIGINT UNSIGNED NOT NULL,
+ `assigned_by` BIGINT UNSIGNED NULL,
+ `assigned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ INDEX `user_roles_role_id_idx`(`role_id`),
+ INDEX `user_roles_assigned_by_idx`(`assigned_by`),
+ PRIMARY KEY (`user_id`, `role_id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `role_permissions` (
+ `role_id` BIGINT UNSIGNED NOT NULL,
+ `permission_id` BIGINT UNSIGNED NOT NULL,
+ INDEX `role_permissions_permission_id_idx`(`permission_id`),
+ PRIMARY KEY (`role_id`, `permission_id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `audit_logs` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `request_id` CHAR(36) NOT NULL,
+ `actor_user_id` BIGINT UNSIGNED NULL,
+ `actor_role` VARCHAR(64) NULL,
+ `action` VARCHAR(100) NOT NULL,
+ `resource_type` VARCHAR(100) NULL,
+ `resource_public_id` VARCHAR(100) NULL,
+ `result` ENUM('SUCCESS', 'FAILURE') NOT NULL,
+ `metadata_json` JSON NULL,
+ `ip_hash` CHAR(64) NULL,
+ `user_agent_summary` VARCHAR(255) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ INDEX `audit_logs_actor_created_at_idx`(`actor_user_id`, `created_at`),
+ INDEX `audit_logs_resource_created_at_idx`(`resource_type`, `resource_public_id`, `created_at`),
+ INDEX `audit_logs_action_created_at_idx`(`action`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+ALTER TABLE `wechat_identities` ADD CONSTRAINT `wechat_identities_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `staff_profiles` ADD CONSTRAINT `staff_profiles_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `admin_credentials` ADD CONSTRAINT `admin_credentials_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `auth_sessions` ADD CONSTRAINT `auth_sessions_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_role_id_fkey` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `user_roles` ADD CONSTRAINT `user_roles_assigned_by_fkey` FOREIGN KEY (`assigned_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_role_id_fkey` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `role_permissions` ADD CONSTRAINT `role_permissions_permission_id_fkey` FOREIGN KEY (`permission_id`) REFERENCES `permissions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `audit_logs` ADD CONSTRAINT `audit_logs_actor_user_id_fkey` FOREIGN KEY (`actor_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+INSERT INTO `roles` (`public_id`, `code`, `name`, `description`, `updated_at`) VALUES
+('9fc73dca-5e75-4ce6-b8ad-d270bd020001', 'VOLUNTEER', '志愿者', '仅访问本人被分配的最小必要任务信息', CURRENT_TIMESTAMP(3)),
+('9fc73dca-5e75-4ce6-b8ad-d270bd020002', 'STAFF', '工作人员', '按负责范围处理公益服务', CURRENT_TIMESTAMP(3)),
+('9fc73dca-5e75-4ce6-b8ad-d270bd020003', 'PROJECT_MANAGER', '项目负责人', '按项目范围管理服务', CURRENT_TIMESTAMP(3)),
+('9fc73dca-5e75-4ce6-b8ad-d270bd020004', 'ADMIN', '管理员', '跨项目业务管理', CURRENT_TIMESTAMP(3)),
+('9fc73dca-5e75-4ce6-b8ad-d270bd020005', 'SUPER_ADMIN', '超级管理员', '系统级配置维护,日常不使用', CURRENT_TIMESTAMP(3));
+
+INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
+('admin:access', '访问管理后台', '允许进入管理后台'),
+('users:read', '查看用户', '按授权范围查看用户基础信息'),
+('users:manage', '管理用户', '管理用户状态与基础信息'),
+('roles:manage', '管理角色权限', '分配角色和维护权限'),
+('audit:read', '查看审计日志', '按权限查看审计记录'),
+('system:manage', '管理系统配置', '维护系统级配置'),
+('sensitive:read', '查看敏感信息', '经审计查看必要敏感信息'),
+('sensitive:export', '导出敏感信息', '受控导出敏感信息'),
+('resources:access_all', '跨范围访问资源', '按角色范围访问非本人资源');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
+WHERE r.code = 'STAFF' AND p.code IN ('admin:access', 'users:read');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
+WHERE r.code = 'PROJECT_MANAGER' AND p.code IN ('admin:access', 'users:read', 'resources:access_all');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
+WHERE r.code = 'ADMIN' AND p.code IN ('admin:access', 'users:read', 'users:manage', 'roles:manage', 'audit:read', 'sensitive:read', 'resources:access_all');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
+WHERE r.code = 'SUPER_ADMIN';
diff --git a/apps/api/prisma/migrations/20260811100000_phase2_service_applications/migration.sql b/apps/api/prisma/migrations/20260811100000_phase2_service_applications/migration.sql
new file mode 100644
index 0000000..d5b7c18
--- /dev/null
+++ b/apps/api/prisma/migrations/20260811100000_phase2_service_applications/migration.sql
@@ -0,0 +1,81 @@
+-- Phase 2 service application baseline.
+-- L3 fields are encrypted by the application before persistence.
+
+CREATE TABLE `consents` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `consent_type` ENUM('SERVICE_APPLICATION') NOT NULL,
+ `version` VARCHAR(32) NOT NULL,
+ `granted_by` ENUM('SELF', 'APPLICANT') NOT NULL,
+ `granted_at` DATETIME(3) NOT NULL,
+ `revoked_at` DATETIME(3) NULL,
+ `evidence_json` JSON NULL,
+ `ip_hash` CHAR(64) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE INDEX `consents_public_id_key`(`public_id`),
+ INDEX `consents_user_type_granted_at_idx`(`user_id`, `consent_type`, `granted_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `service_applications` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `application_no` VARCHAR(32) NOT NULL,
+ `applicant_user_id` BIGINT UNSIGNED NOT NULL,
+ `consent_id` BIGINT UNSIGNED NOT NULL,
+ `client_request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `apply_mode` ENUM('SELF', 'FAMILY', 'COMMUNITY', 'VOLUNTEER', 'OTHER') NOT NULL,
+ `service_category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL,
+ `beneficiary_name_encrypted` VARCHAR(512) NOT NULL,
+ `contact_mobile_encrypted` VARCHAR(512) NOT NULL,
+ `contact_mobile_hash` CHAR(64) NOT NULL,
+ `region_code` VARCHAR(100) NOT NULL,
+ `description_encrypted` TEXT NOT NULL,
+ `preferred_contact_time` VARCHAR(100) NULL,
+ `needs_home_visit` BOOLEAN NULL,
+ `mobility_difficulty` BOOLEAN NULL,
+ `status` ENUM('SUBMITTED', 'PENDING_CONTACT', 'ASSESSING', 'REJECTED', 'OUT_OF_SCOPE', 'REFERRED', 'ACCEPTED', 'SERVICE_PLANNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'FOLLOW_UP_PENDING', 'CLOSED', 'CANCELLED', 'SUSPENDED') NOT NULL DEFAULT 'SUBMITTED',
+ `source` ENUM('WECHAT_MINIPROGRAM', 'ADMIN') NOT NULL DEFAULT 'WECHAT_MINIPROGRAM',
+ `submitted_at` DATETIME(3) NOT NULL,
+ `accepted_at` DATETIME(3) NULL,
+ `closed_at` DATETIME(3) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `service_applications_public_id_key`(`public_id`),
+ UNIQUE INDEX `service_applications_application_no_key`(`application_no`),
+ UNIQUE INDEX `service_applications_consent_id_key`(`consent_id`),
+ UNIQUE INDEX `service_applications_applicant_client_request_key`(`applicant_user_id`, `client_request_id`),
+ INDEX `service_applications_applicant_submitted_at_idx`(`applicant_user_id`, `submitted_at`),
+ INDEX `service_applications_status_submitted_at_idx`(`status`, `submitted_at`),
+ INDEX `service_applications_category_submitted_at_idx`(`service_category`, `submitted_at`),
+ INDEX `service_applications_contact_mobile_hash_idx`(`contact_mobile_hash`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `service_application_status_history` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `application_id` BIGINT UNSIGNED NOT NULL,
+ `from_status` ENUM('SUBMITTED', 'PENDING_CONTACT', 'ASSESSING', 'REJECTED', 'OUT_OF_SCOPE', 'REFERRED', 'ACCEPTED', 'SERVICE_PLANNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'FOLLOW_UP_PENDING', 'CLOSED', 'CANCELLED', 'SUSPENDED') NULL,
+ `to_status` ENUM('SUBMITTED', 'PENDING_CONTACT', 'ASSESSING', 'REJECTED', 'OUT_OF_SCOPE', 'REFERRED', 'ACCEPTED', 'SERVICE_PLANNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'FOLLOW_UP_PENDING', 'CLOSED', 'CANCELLED', 'SUSPENDED') NOT NULL,
+ `operator_user_id` BIGINT UNSIGNED NOT NULL,
+ `reason_code` VARCHAR(100) NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ INDEX `application_status_history_application_created_at_idx`(`application_id`, `created_at`),
+ INDEX `application_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+ALTER TABLE `consents` ADD CONSTRAINT `consents_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_applicant_user_id_fkey` FOREIGN KEY (`applicant_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_consent_id_fkey` FOREIGN KEY (`consent_id`) REFERENCES `consents`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `service_application_status_history` ADD CONSTRAINT `service_application_status_history_application_id_fkey` FOREIGN KEY (`application_id`) REFERENCES `service_applications`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `service_application_status_history` ADD CONSTRAINT `service_application_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
+('applications:read', '查看服务申请', '按授权范围查看服务申请列表和详情');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
+WHERE r.code IN ('STAFF', 'PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN') AND p.code = 'applications:read';
diff --git a/apps/api/prisma/migrations/20260811180000_phase3_case_management/migration.sql b/apps/api/prisma/migrations/20260811180000_phase3_case_management/migration.sql
new file mode 100644
index 0000000..f1e9f4a
--- /dev/null
+++ b/apps/api/prisma/migrations/20260811180000_phase3_case_management/migration.sql
@@ -0,0 +1,359 @@
+-- AlterTable
+ALTER TABLE `consents` ADD COLUMN `beneficiary_id` BIGINT UNSIGNED NULL,
+ MODIFY `user_id` BIGINT UNSIGNED NULL,
+ MODIFY `consent_type` ENUM('SERVICE_APPLICATION', 'BENEFICIARY_GUARDIAN') NOT NULL,
+ MODIFY `granted_by` ENUM('SELF', 'APPLICANT', 'GUARDIAN') NOT NULL;
+
+-- AlterTable
+ALTER TABLE `service_applications` ADD COLUMN `beneficiary_id` BIGINT UNSIGNED NULL;
+
+-- CreateTable
+CREATE TABLE `beneficiary_profiles` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `display_name_encrypted` VARCHAR(512) NOT NULL,
+ `display_name_hash` CHAR(64) NOT NULL,
+ `gender` ENUM('FEMALE', 'MALE', 'OTHER', 'UNDISCLOSED') NULL,
+ `birth_year` SMALLINT UNSIGNED NULL,
+ `mobile_encrypted` VARCHAR(512) NULL,
+ `mobile_hash` CHAR(64) NULL,
+ `region_code` VARCHAR(100) NOT NULL,
+ `address_detail_encrypted` VARCHAR(1024) NULL,
+ `primary_category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL,
+ `key_care_flag` BOOLEAN NOT NULL DEFAULT false,
+ `is_minor` BOOLEAN NOT NULL DEFAULT false,
+ `status` ENUM('ACTIVE', 'INACTIVE', 'ARCHIVED') NOT NULL DEFAULT 'ACTIVE',
+ `notes_encrypted` TEXT NULL,
+ `created_by` BIGINT UNSIGNED NOT NULL,
+ `updated_by` BIGINT UNSIGNED NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+
+ UNIQUE INDEX `beneficiary_profiles_public_id_key`(`public_id`),
+ INDEX `beneficiary_profiles_display_name_hash_idx`(`display_name_hash`),
+ INDEX `beneficiary_profiles_mobile_hash_idx`(`mobile_hash`),
+ INDEX `beneficiary_profiles_category_status_created_at_idx`(`primary_category`, `status`, `created_at`),
+ INDEX `beneficiary_profiles_key_care_status_created_at_idx`(`key_care_flag`, `status`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `beneficiary_contacts` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `beneficiary_id` BIGINT UNSIGNED NOT NULL,
+ `name_encrypted` VARCHAR(512) NOT NULL,
+ `relation` VARCHAR(50) NOT NULL,
+ `mobile_encrypted` VARCHAR(512) NOT NULL,
+ `mobile_hash` CHAR(64) NOT NULL,
+ `is_guardian` BOOLEAN NOT NULL DEFAULT false,
+ `is_emergency_contact` BOOLEAN NOT NULL DEFAULT false,
+ `consent_record_id` BIGINT UNSIGNED NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ `removed_at` DATETIME(3) NULL,
+
+ UNIQUE INDEX `beneficiary_contacts_public_id_key`(`public_id`),
+ UNIQUE INDEX `beneficiary_contacts_consent_record_id_key`(`consent_record_id`),
+ INDEX `beneficiary_contacts_beneficiary_active_guardian_idx`(`beneficiary_id`, `removed_at`, `is_guardian`),
+ INDEX `beneficiary_contacts_mobile_hash_idx`(`mobile_hash`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `tags` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `code` VARCHAR(64) NOT NULL,
+ `name` VARCHAR(50) NOT NULL,
+ `description` VARCHAR(255) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ UNIQUE INDEX `tags_public_id_key`(`public_id`),
+ UNIQUE INDEX `tags_code_key`(`code`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `beneficiary_tags` (
+ `beneficiary_id` BIGINT UNSIGNED NOT NULL,
+ `tag_id` BIGINT UNSIGNED NOT NULL,
+ `assigned_by` BIGINT UNSIGNED NOT NULL,
+ `assigned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ INDEX `beneficiary_tags_tag_id_idx`(`tag_id`),
+ PRIMARY KEY (`beneficiary_id`, `tag_id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `service_cases` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `case_no` VARCHAR(32) NOT NULL,
+ `application_id` BIGINT UNSIGNED NOT NULL,
+ `accept_request_id` CHAR(36) NOT NULL,
+ `accept_request_fingerprint` CHAR(64) NOT NULL,
+ `beneficiary_id` BIGINT UNSIGNED NOT NULL,
+ `owner_staff_id` BIGINT UNSIGNED NOT NULL,
+ `category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL,
+ `priority` ENUM('LOW', 'NORMAL', 'HIGH', 'URGENT') NOT NULL DEFAULT 'NORMAL',
+ `status` ENUM('ASSIGNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'SUSPENDED', 'CANCELLED') NOT NULL DEFAULT 'ASSIGNED',
+ `planned_start_at` DATETIME(3) NULL,
+ `planned_end_at` DATETIME(3) NULL,
+ `service_location_encrypted` VARCHAR(1024) NULL,
+ `summary_encrypted` TEXT NOT NULL,
+ `result_summary_encrypted` TEXT NULL,
+ `follow_up_plan_encrypted` TEXT NULL,
+ `completed_at` DATETIME(3) NULL,
+ `created_by` BIGINT UNSIGNED NOT NULL,
+ `updated_by` BIGINT UNSIGNED NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+
+ UNIQUE INDEX `service_cases_public_id_key`(`public_id`),
+ UNIQUE INDEX `service_cases_case_no_key`(`case_no`),
+ UNIQUE INDEX `service_cases_application_id_key`(`application_id`),
+ INDEX `service_cases_owner_status_planned_idx`(`owner_staff_id`, `status`, `planned_start_at`),
+ INDEX `service_cases_beneficiary_created_at_idx`(`beneficiary_id`, `created_at`),
+ INDEX `service_cases_status_created_at_idx`(`status`, `created_at`),
+ UNIQUE INDEX `service_cases_creator_accept_request_key`(`created_by`, `accept_request_id`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `case_assignees` (
+ `case_id` BIGINT UNSIGNED NOT NULL,
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `assignee_type` ENUM('STAFF', 'VOLUNTEER') NOT NULL,
+ `role` ENUM('OWNER', 'COLLABORATOR') NOT NULL,
+ `assigned_by` BIGINT UNSIGNED NOT NULL,
+ `assigned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `removed_at` DATETIME(3) NULL,
+
+ INDEX `case_assignees_user_active_assigned_idx`(`user_id`, `removed_at`, `assigned_at`),
+ PRIMARY KEY (`case_id`, `user_id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `service_case_status_history` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `case_id` BIGINT UNSIGNED NOT NULL,
+ `from_status` ENUM('ASSIGNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'SUSPENDED', 'CANCELLED') NULL,
+ `to_status` ENUM('ASSIGNED', 'IN_SERVICE', 'SERVICE_COMPLETED', 'SUSPENDED', 'CANCELLED') NOT NULL,
+ `operator_user_id` BIGINT UNSIGNED NOT NULL,
+ `reason_code` VARCHAR(100) NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ INDEX `case_status_history_case_created_at_idx`(`case_id`, `created_at`),
+ INDEX `case_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `service_records` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `case_id` BIGINT UNSIGNED NOT NULL,
+ `beneficiary_id` BIGINT UNSIGNED NOT NULL,
+ `client_request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `service_start_at` DATETIME(3) NOT NULL,
+ `service_end_at` DATETIME(3) NULL,
+ `service_type` ENUM('HOME_VISIT', 'PHONE_CONTACT', 'ESCORT', 'MATERIAL_SUPPORT', 'ACTIVITY', 'OTHER') NOT NULL,
+ `content_encrypted` TEXT NOT NULL,
+ `result_encrypted` TEXT NOT NULL,
+ `follow_up_suggestion_encrypted` TEXT NULL,
+ `beneficiary_confirm_status` ENUM('NOT_REQUESTED', 'CONFIRMED', 'PARTIALLY_CONFIRMED', 'DISPUTED') NOT NULL DEFAULT 'NOT_REQUESTED',
+ `created_by` BIGINT UNSIGNED NOT NULL,
+ `updated_by` BIGINT UNSIGNED NOT NULL,
+ `revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+
+ UNIQUE INDEX `service_records_public_id_key`(`public_id`),
+ INDEX `service_records_case_start_at_idx`(`case_id`, `service_start_at`),
+ INDEX `service_records_beneficiary_start_at_idx`(`beneficiary_id`, `service_start_at`),
+ UNIQUE INDEX `service_records_creator_client_request_key`(`created_by`, `client_request_id`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `service_record_revisions` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `service_record_id` BIGINT UNSIGNED NOT NULL,
+ `revision` INTEGER UNSIGNED NOT NULL,
+ `service_start_at` DATETIME(3) NOT NULL,
+ `service_end_at` DATETIME(3) NULL,
+ `service_type` ENUM('HOME_VISIT', 'PHONE_CONTACT', 'ESCORT', 'MATERIAL_SUPPORT', 'ACTIVITY', 'OTHER') NOT NULL,
+ `content_encrypted` TEXT NOT NULL,
+ `result_encrypted` TEXT NOT NULL,
+ `follow_up_suggestion_encrypted` TEXT NULL,
+ `beneficiary_confirm_status` ENUM('NOT_REQUESTED', 'CONFIRMED', 'PARTIALLY_CONFIRMED', 'DISPUTED') NOT NULL,
+ `revised_by` BIGINT UNSIGNED NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ INDEX `service_record_revisions_reviser_created_at_idx`(`revised_by`, `created_at`),
+ UNIQUE INDEX `service_record_revisions_record_revision_key`(`service_record_id`, `revision`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `files` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `case_id` BIGINT UNSIGNED NOT NULL,
+ `client_request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `storage_provider` VARCHAR(50) NOT NULL,
+ `bucket` VARCHAR(255) NOT NULL,
+ `object_key` VARCHAR(512) NOT NULL,
+ `original_filename_encrypted` VARCHAR(1024) NOT NULL,
+ `mime_type` VARCHAR(100) NOT NULL,
+ `size` BIGINT UNSIGNED NOT NULL,
+ `sha256` CHAR(64) NULL,
+ `business_type` ENUM('SERVICE_RECORD') NOT NULL,
+ `uploader_user_id` BIGINT UNSIGNED NOT NULL,
+ `status` ENUM('UPLOAD_PENDING', 'AVAILABLE', 'QUARANTINED', 'DELETED') NOT NULL DEFAULT 'UPLOAD_PENDING',
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+
+ UNIQUE INDEX `files_public_id_key`(`public_id`),
+ UNIQUE INDEX `files_object_key_key`(`object_key`),
+ INDEX `files_case_status_created_at_idx`(`case_id`, `status`, `created_at`),
+ INDEX `files_uploader_status_created_at_idx`(`uploader_user_id`, `status`, `created_at`),
+ UNIQUE INDEX `files_uploader_client_request_key`(`uploader_user_id`, `client_request_id`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `service_record_files` (
+ `service_record_id` BIGINT UNSIGNED NOT NULL,
+ `file_id` BIGINT UNSIGNED NOT NULL,
+ `attached_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ INDEX `service_record_files_file_id_idx`(`file_id`),
+ PRIMARY KEY (`service_record_id`, `file_id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateIndex
+CREATE INDEX `consents_beneficiary_type_granted_at_idx` ON `consents`(`beneficiary_id`, `consent_type`, `granted_at`);
+
+-- CreateIndex
+CREATE INDEX `service_applications_beneficiary_submitted_at_idx` ON `service_applications`(`beneficiary_id`, `submitted_at`);
+
+-- AddForeignKey
+ALTER TABLE `consents` ADD CONSTRAINT `consents_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `beneficiary_profiles` ADD CONSTRAINT `beneficiary_profiles_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `beneficiary_profiles` ADD CONSTRAINT `beneficiary_profiles_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `beneficiary_contacts` ADD CONSTRAINT `beneficiary_contacts_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `beneficiary_contacts` ADD CONSTRAINT `beneficiary_contacts_consent_record_id_fkey` FOREIGN KEY (`consent_record_id`) REFERENCES `consents`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `beneficiary_tags` ADD CONSTRAINT `beneficiary_tags_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `beneficiary_tags` ADD CONSTRAINT `beneficiary_tags_tag_id_fkey` FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `beneficiary_tags` ADD CONSTRAINT `beneficiary_tags_assigned_by_fkey` FOREIGN KEY (`assigned_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_application_id_fkey` FOREIGN KEY (`application_id`) REFERENCES `service_applications`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_owner_staff_id_fkey` FOREIGN KEY (`owner_staff_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `case_assignees` ADD CONSTRAINT `case_assignees_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `case_assignees` ADD CONSTRAINT `case_assignees_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `case_assignees` ADD CONSTRAINT `case_assignees_assigned_by_fkey` FOREIGN KEY (`assigned_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_case_status_history` ADD CONSTRAINT `service_case_status_history_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_case_status_history` ADD CONSTRAINT `service_case_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_records` ADD CONSTRAINT `service_records_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_records` ADD CONSTRAINT `service_records_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_records` ADD CONSTRAINT `service_records_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_records` ADD CONSTRAINT `service_records_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_record_revisions` ADD CONSTRAINT `service_record_revisions_service_record_id_fkey` FOREIGN KEY (`service_record_id`) REFERENCES `service_records`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_record_revisions` ADD CONSTRAINT `service_record_revisions_revised_by_fkey` FOREIGN KEY (`revised_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `files` ADD CONSTRAINT `files_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `files` ADD CONSTRAINT `files_uploader_user_id_fkey` FOREIGN KEY (`uploader_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_record_files` ADD CONSTRAINT `service_record_files_service_record_id_fkey` FOREIGN KEY (`service_record_id`) REFERENCES `service_records`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_record_files` ADD CONSTRAINT `service_record_files_file_id_fkey` FOREIGN KEY (`file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- Phase 3 fixed service tags. Product operations may request additions in a later reviewed migration.
+INSERT INTO `tags` (`public_id`, `code`, `name`, `description`) VALUES
+('4ed0570a-6051-4a29-9fd2-3c3d45dc52a7', 'ALONE_LIVING', '独居', '独居服务对象'),
+('044792f8-2982-4890-a8d4-cc6d01ac78c4', 'ADVANCED_AGE', '高龄', '高龄关爱对象'),
+('dc02d25f-5a4c-4f31-9ec8-c5a5f3904e6a', 'MOBILITY_LIMITED', '行动不便', '日常行动存在困难'),
+('75292f5f-72e0-4a19-9e9b-5fd7b455956a', 'REGULAR_VISIT', '定期探访', '需要定期探访关爱'),
+('94e041fe-0745-4f50-a3c6-b0cad8e286b5', 'CHILD_IN_DIFFICULTY', '困境儿童', '需要加强保护的儿童服务对象'),
+('76e280f4-8f6f-4e06-9570-ce998c8c38c4', 'KEY_CARE', '重点关爱', '由工作人员评估的重点关爱对象');
+
+INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
+('applications:manage', '处理服务申请', '按状态机联系、评估并受理服务申请'),
+('beneficiaries:read', '查看服务对象', '按授权范围查看服务对象档案'),
+('beneficiaries:manage', '管理服务对象', '建档并维护服务对象、联系人和标签'),
+('cases:read', '查看服务工单', '按负责或管理范围查看服务工单'),
+('cases:manage', '管理服务工单', '按状态机推进负责的服务工单'),
+('cases:assign', '分配服务工单', '分配负责人和协作人员'),
+('assigned_cases:read', '查看已分配工单', '查看本人被分配工单的最小必要信息'),
+('service_records:write', '填写服务记录', '为本人被分配工单新增或修订服务记录'),
+('files:write', '管理服务附件', '上传、确认和读取授权范围内的私有服务附件');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
+WHERE r.code IN ('STAFF', 'PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN')
+ AND p.code IN ('applications:manage', 'beneficiaries:read', 'beneficiaries:manage', 'cases:read', 'cases:manage', 'cases:assign', 'assigned_cases:read', 'service_records:write', 'files:write');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
+WHERE r.code = 'VOLUNTEER'
+ AND p.code IN ('assigned_cases:read', 'service_records:write', 'files:write');
diff --git a/apps/api/prisma/migrations/20260811220000_phase4_follow_up_closure/migration.sql b/apps/api/prisma/migrations/20260811220000_phase4_follow_up_closure/migration.sql
new file mode 100644
index 0000000..9d86e83
--- /dev/null
+++ b/apps/api/prisma/migrations/20260811220000_phase4_follow_up_closure/migration.sql
@@ -0,0 +1,105 @@
+-- AlterTable
+ALTER TABLE `service_applications` ADD COLUMN `parent_application_id` BIGINT UNSIGNED NULL,
+ ADD COLUMN `source_follow_up_task_id` BIGINT UNSIGNED NULL,
+ MODIFY `source` ENUM('WECHAT_MINIPROGRAM', 'ADMIN', 'FOLLOW_UP') NOT NULL DEFAULT 'WECHAT_MINIPROGRAM';
+
+-- AlterTable
+ALTER TABLE `service_cases` ADD COLUMN `complete_request_fingerprint` CHAR(64) NULL,
+ ADD COLUMN `complete_request_id` CHAR(36) NULL;
+
+-- CreateTable
+CREATE TABLE `follow_up_tasks` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `follow_up_no` VARCHAR(32) NOT NULL,
+ `beneficiary_id` BIGINT UNSIGNED NOT NULL,
+ `case_id` BIGINT UNSIGNED NULL,
+ `owner_staff_id` BIGINT UNSIGNED NOT NULL,
+ `due_at` DATETIME(3) NOT NULL,
+ `follow_up_type` ENUM('SERVICE_COMPLETION', 'KEY_CARE', 'MANUAL') NOT NULL,
+ `status` ENUM('PENDING', 'COMPLETED', 'CANCELLED') NOT NULL DEFAULT 'PENDING',
+ `source` ENUM('AUTO_SERVICE_COMPLETION', 'STAFF_MANUAL', 'KEY_CARE_PLAN') NOT NULL,
+ `reason_encrypted` TEXT NOT NULL,
+ `result_code` ENUM('SATISFIED', 'GENERALLY_SATISFIED', 'UNSATISFIED', 'NEED_MORE_HELP', 'UNREACHABLE', 'OTHER') NULL,
+ `result_note_encrypted` TEXT NULL,
+ `create_request_id` CHAR(36) NOT NULL,
+ `create_request_fingerprint` CHAR(64) NOT NULL,
+ `complete_request_id` CHAR(36) NULL,
+ `complete_request_fingerprint` CHAR(64) NULL,
+ `completed_at` DATETIME(3) NULL,
+ `created_by` BIGINT UNSIGNED NOT NULL,
+ `updated_by` BIGINT UNSIGNED NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+
+ UNIQUE INDEX `follow_up_tasks_public_id_key`(`public_id`),
+ UNIQUE INDEX `follow_up_tasks_follow_up_no_key`(`follow_up_no`),
+ INDEX `follow_up_tasks_owner_status_due_at_idx`(`owner_staff_id`, `status`, `due_at`),
+ INDEX `follow_up_tasks_status_due_at_idx`(`status`, `due_at`),
+ INDEX `follow_up_tasks_beneficiary_due_at_idx`(`beneficiary_id`, `due_at`),
+ INDEX `follow_up_tasks_case_created_at_idx`(`case_id`, `created_at`),
+ UNIQUE INDEX `follow_up_tasks_creator_request_key`(`created_by`, `create_request_id`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `follow_up_status_history` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `follow_up_task_id` BIGINT UNSIGNED NOT NULL,
+ `from_status` ENUM('PENDING', 'COMPLETED', 'CANCELLED') NULL,
+ `to_status` ENUM('PENDING', 'COMPLETED', 'CANCELLED') NOT NULL,
+ `operator_user_id` BIGINT UNSIGNED NOT NULL,
+ `reason_code` VARCHAR(100) NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+ INDEX `follow_up_status_history_task_created_at_idx`(`follow_up_task_id`, `created_at`),
+ INDEX `follow_up_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateIndex
+CREATE UNIQUE INDEX `service_applications_source_follow_up_task_id_key` ON `service_applications`(`source_follow_up_task_id`);
+
+-- CreateIndex
+CREATE INDEX `service_applications_parent_submitted_at_idx` ON `service_applications`(`parent_application_id`, `submitted_at`);
+
+-- CreateIndex
+CREATE UNIQUE INDEX `service_cases_complete_request_id_key` ON `service_cases`(`complete_request_id`);
+
+-- AddForeignKey
+ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_source_follow_up_task_id_fkey` FOREIGN KEY (`source_follow_up_task_id`) REFERENCES `follow_up_tasks`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `service_applications` ADD CONSTRAINT `service_applications_parent_application_id_fkey` FOREIGN KEY (`parent_application_id`) REFERENCES `service_applications`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_beneficiary_id_fkey` FOREIGN KEY (`beneficiary_id`) REFERENCES `beneficiary_profiles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_case_id_fkey` FOREIGN KEY (`case_id`) REFERENCES `service_cases`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_owner_staff_id_fkey` FOREIGN KEY (`owner_staff_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `follow_up_tasks` ADD CONSTRAINT `follow_up_tasks_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `follow_up_status_history` ADD CONSTRAINT `follow_up_status_history_follow_up_task_id_fkey` FOREIGN KEY (`follow_up_task_id`) REFERENCES `follow_up_tasks`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `follow_up_status_history` ADD CONSTRAINT `follow_up_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- Phase 4 follow-up permissions. Volunteers do not receive access to beneficiary follow-up results.
+INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
+('follow_ups:read', '查看回访任务', '按负责人或管理范围查看回访工作台和任务'),
+('follow_ups:manage', '创建回访任务', '创建人工或重点关爱回访并在服务完成时生成回访'),
+('follow_ups:complete', '完成回访任务', '由任务负责人记录回访结论和继续帮扶授权');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
+WHERE r.code IN ('STAFF', 'PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN')
+ AND p.code IN ('follow_ups:read', 'follow_ups:manage', 'follow_ups:complete');
diff --git a/apps/api/prisma/migrations/20260812010000_phase5_participation/migration.sql b/apps/api/prisma/migrations/20260812010000_phase5_participation/migration.sql
new file mode 100644
index 0000000..24eb258
--- /dev/null
+++ b/apps/api/prisma/migrations/20260812010000_phase5_participation/migration.sql
@@ -0,0 +1,267 @@
+-- AlterTable
+ALTER TABLE `consents` MODIFY `consent_type` ENUM('SERVICE_APPLICATION', 'BENEFICIARY_GUARDIAN', 'VOLUNTEER_APPLICATION') NOT NULL;
+
+-- AlterTable
+ALTER TABLE `files` MODIFY `case_id` BIGINT UNSIGNED NULL,
+ MODIFY `business_type` ENUM('SERVICE_RECORD', 'PROJECT_COVER', 'ACTIVITY_COVER') NOT NULL;
+
+-- AlterTable
+ALTER TABLE `service_cases` ADD COLUMN `project_id` BIGINT UNSIGNED NULL;
+
+-- CreateTable
+CREATE TABLE `volunteers` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `consent_id` BIGINT UNSIGNED NOT NULL,
+ `real_name_encrypted` VARCHAR(512) NOT NULL,
+ `real_name_hash` CHAR(64) NOT NULL,
+ `mobile_encrypted` VARCHAR(512) NOT NULL,
+ `mobile_hash` CHAR(64) NOT NULL,
+ `region_code` VARCHAR(100) NOT NULL,
+ `availability_note_encrypted` TEXT NOT NULL,
+ `status` ENUM('APPLIED', 'APPROVED', 'REJECTED', 'SUSPENDED', 'INACTIVE') NOT NULL DEFAULT 'APPLIED',
+ `approved_by` BIGINT UNSIGNED NULL,
+ `approved_at` DATETIME(3) NULL,
+ `revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
+ `created_by` BIGINT UNSIGNED NOT NULL,
+ `updated_by` BIGINT UNSIGNED NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `volunteers_public_id_key`(`public_id`),
+ UNIQUE INDEX `volunteers_user_id_key`(`user_id`),
+ UNIQUE INDEX `volunteers_consent_id_key`(`consent_id`),
+ INDEX `volunteers_status_created_at_idx`(`status`, `created_at`),
+ INDEX `volunteers_mobile_hash_idx`(`mobile_hash`),
+ INDEX `volunteers_region_status_idx`(`region_code`, `status`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `volunteer_skills` (
+ `volunteer_id` BIGINT UNSIGNED NOT NULL,
+ `skill_code` VARCHAR(64) NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ INDEX `volunteer_skills_code_idx`(`skill_code`),
+ PRIMARY KEY (`volunteer_id`, `skill_code`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `volunteer_interests` (
+ `volunteer_id` BIGINT UNSIGNED NOT NULL,
+ `category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ INDEX `volunteer_interests_category_idx`(`category`),
+ PRIMARY KEY (`volunteer_id`, `category`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `volunteer_status_history` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `volunteer_id` BIGINT UNSIGNED NOT NULL,
+ `request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `from_status` ENUM('APPLIED', 'APPROVED', 'REJECTED', 'SUSPENDED', 'INACTIVE') NULL,
+ `to_status` ENUM('APPLIED', 'APPROVED', 'REJECTED', 'SUSPENDED', 'INACTIVE') NOT NULL,
+ `operator_user_id` BIGINT UNSIGNED NOT NULL,
+ `reason_code` VARCHAR(100) NOT NULL,
+ `note_encrypted` TEXT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE INDEX `volunteer_status_history_request_id_key`(`request_id`),
+ INDEX `volunteer_status_history_volunteer_created_at_idx`(`volunteer_id`, `created_at`),
+ INDEX `volunteer_status_history_operator_created_at_idx`(`operator_user_id`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `projects` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `project_no` VARCHAR(32) NOT NULL,
+ `client_request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `name` VARCHAR(200) NOT NULL,
+ `category` ENUM('OLD_AGE', 'WOMEN', 'CHILD', 'DISABILITY', 'LIFE_DIFFICULTY', 'OTHER') NOT NULL,
+ `summary` VARCHAR(500) NOT NULL,
+ `content` TEXT NOT NULL,
+ `cover_file_id` BIGINT UNSIGNED NULL,
+ `manager_user_id` BIGINT UNSIGNED NOT NULL,
+ `start_date` DATE NOT NULL,
+ `end_date` DATE NOT NULL,
+ `target_people` INTEGER UNSIGNED NULL,
+ `status` ENUM('DRAFT', 'PUBLISHED', 'COMPLETED') NOT NULL DEFAULT 'DRAFT',
+ `is_public` BOOLEAN NOT NULL DEFAULT false,
+ `sort_order` INTEGER NOT NULL DEFAULT 0,
+ `revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
+ `created_by` BIGINT UNSIGNED NOT NULL,
+ `updated_by` BIGINT UNSIGNED NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `projects_public_id_key`(`public_id`),
+ UNIQUE INDEX `projects_project_no_key`(`project_no`),
+ UNIQUE INDEX `projects_cover_file_id_key`(`cover_file_id`),
+ INDEX `projects_public_status_sort_idx`(`status`, `is_public`, `sort_order`, `created_at`),
+ INDEX `projects_manager_status_idx`(`manager_user_id`, `status`, `created_at`),
+ UNIQUE INDEX `projects_creator_request_key`(`created_by`, `client_request_id`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `project_status_history` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `project_id` BIGINT UNSIGNED NOT NULL,
+ `request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `from_status` ENUM('DRAFT', 'PUBLISHED', 'COMPLETED') NULL,
+ `to_status` ENUM('DRAFT', 'PUBLISHED', 'COMPLETED') NOT NULL,
+ `operator_user_id` BIGINT UNSIGNED NOT NULL,
+ `reason_code` VARCHAR(100) NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE INDEX `project_status_history_request_id_key`(`request_id`),
+ INDEX `project_status_history_project_created_at_idx`(`project_id`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `activities` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `activity_no` VARCHAR(32) NOT NULL,
+ `project_id` BIGINT UNSIGNED NULL,
+ `client_request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `title` VARCHAR(200) NOT NULL,
+ `summary` VARCHAR(500) NOT NULL,
+ `content` TEXT NOT NULL,
+ `cover_file_id` BIGINT UNSIGNED NULL,
+ `start_at` DATETIME(3) NOT NULL,
+ `end_at` DATETIME(3) NOT NULL,
+ `registration_deadline` DATETIME(3) NOT NULL,
+ `location_text` VARCHAR(300) NOT NULL,
+ `capacity` INTEGER UNSIGNED NULL,
+ `status` ENUM('DRAFT', 'PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
+ `published_at` DATETIME(3) NULL,
+ `revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
+ `created_by` BIGINT UNSIGNED NOT NULL,
+ `updated_by` BIGINT UNSIGNED NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `activities_public_id_key`(`public_id`),
+ UNIQUE INDEX `activities_activity_no_key`(`activity_no`),
+ UNIQUE INDEX `activities_cover_file_id_key`(`cover_file_id`),
+ INDEX `activities_status_start_at_idx`(`status`, `start_at`),
+ INDEX `activities_project_start_at_idx`(`project_id`, `start_at`),
+ UNIQUE INDEX `activities_creator_request_key`(`created_by`, `client_request_id`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `activity_status_history` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `activity_id` BIGINT UNSIGNED NOT NULL,
+ `request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `from_status` ENUM('DRAFT', 'PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NULL,
+ `to_status` ENUM('DRAFT', 'PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED') NOT NULL,
+ `operator_user_id` BIGINT UNSIGNED NOT NULL,
+ `reason_code` VARCHAR(100) NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE INDEX `activity_status_history_request_id_key`(`request_id`),
+ INDEX `activity_status_history_activity_created_at_idx`(`activity_id`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `activity_registrations` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `activity_id` BIGINT UNSIGNED NOT NULL,
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `volunteer_id` BIGINT UNSIGNED NOT NULL,
+ `client_request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') NOT NULL DEFAULT 'PENDING',
+ `check_in_status` ENUM('NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT') NOT NULL DEFAULT 'NOT_CHECKED_IN',
+ `registered_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `reviewed_by` BIGINT UNSIGNED NULL,
+ `reviewed_at` DATETIME(3) NULL,
+ `review_note_encrypted` TEXT NULL,
+ `checked_in_at` DATETIME(3) NULL,
+ `checked_in_by` BIGINT UNSIGNED NULL,
+ `service_minutes` INTEGER UNSIGNED NULL,
+ `service_minutes_recorded_at` DATETIME(3) NULL,
+ `service_minutes_recorded_by` BIGINT UNSIGNED NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `activity_registrations_public_id_key`(`public_id`),
+ INDEX `activity_registrations_activity_status_idx`(`activity_id`, `status`, `registered_at`),
+ INDEX `activity_registrations_volunteer_registered_idx`(`volunteer_id`, `registered_at`),
+ UNIQUE INDEX `activity_registrations_activity_user_key`(`activity_id`, `user_id`),
+ UNIQUE INDEX `activity_registrations_user_request_key`(`user_id`, `client_request_id`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `activity_registration_history` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `registration_id` BIGINT UNSIGNED NOT NULL,
+ `request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `from_status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') NULL,
+ `to_status` ENUM('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED') NULL,
+ `from_check_in_status` ENUM('NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT') NULL,
+ `to_check_in_status` ENUM('NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT') NULL,
+ `service_minutes_before` INTEGER UNSIGNED NULL,
+ `service_minutes_after` INTEGER UNSIGNED NULL,
+ `operator_user_id` BIGINT UNSIGNED NOT NULL,
+ `action_code` VARCHAR(100) NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE INDEX `activity_registration_history_request_id_key`(`request_id`),
+ INDEX `registration_history_registration_created_at_idx`(`registration_id`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE INDEX `service_cases_project_created_at_idx` ON `service_cases`(`project_id`, `created_at`);
+
+ALTER TABLE `service_cases` ADD CONSTRAINT `service_cases_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_consent_id_fkey` FOREIGN KEY (`consent_id`) REFERENCES `consents`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_approved_by_fkey` FOREIGN KEY (`approved_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `volunteers` ADD CONSTRAINT `volunteers_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `volunteer_skills` ADD CONSTRAINT `volunteer_skills_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `volunteer_interests` ADD CONSTRAINT `volunteer_interests_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `volunteer_status_history` ADD CONSTRAINT `volunteer_status_history_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `volunteer_status_history` ADD CONSTRAINT `volunteer_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `projects` ADD CONSTRAINT `projects_cover_file_id_fkey` FOREIGN KEY (`cover_file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `projects` ADD CONSTRAINT `projects_manager_user_id_fkey` FOREIGN KEY (`manager_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `projects` ADD CONSTRAINT `projects_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `projects` ADD CONSTRAINT `projects_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `project_status_history` ADD CONSTRAINT `project_status_history_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `project_status_history` ADD CONSTRAINT `project_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activities` ADD CONSTRAINT `activities_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activities` ADD CONSTRAINT `activities_cover_file_id_fkey` FOREIGN KEY (`cover_file_id`) REFERENCES `files`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activities` ADD CONSTRAINT `activities_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activities` ADD CONSTRAINT `activities_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activity_status_history` ADD CONSTRAINT `activity_status_history_activity_id_fkey` FOREIGN KEY (`activity_id`) REFERENCES `activities`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activity_status_history` ADD CONSTRAINT `activity_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_activity_id_fkey` FOREIGN KEY (`activity_id`) REFERENCES `activities`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_volunteer_id_fkey` FOREIGN KEY (`volunteer_id`) REFERENCES `volunteers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_reviewed_by_fkey` FOREIGN KEY (`reviewed_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_checked_in_by_fkey` FOREIGN KEY (`checked_in_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activity_registrations` ADD CONSTRAINT `activity_registrations_service_minutes_recorded_by_fkey` FOREIGN KEY (`service_minutes_recorded_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activity_registration_history` ADD CONSTRAINT `activity_registration_history_registration_id_fkey` FOREIGN KEY (`registration_id`) REFERENCES `activity_registrations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `activity_registration_history` ADD CONSTRAINT `activity_registration_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
+('volunteers:read', '查看志愿者', '按授权范围查看志愿者脱敏列表与详情'),
+('volunteers:review', '审核志愿者', '审核、暂停和恢复志愿者资格'),
+('projects:read', '查看公益项目', '按授权范围查看公益项目'),
+('projects:manage', '管理公益项目', '创建和维护公益项目及关联工单'),
+('projects:publish', '发布公益项目', '执行公益项目发布和完成状态迁移'),
+('activities:read', '查看公益活动', '按授权范围查看活动和报名'),
+('activities:manage', '管理公益活动', '创建和维护公益活动'),
+('activities:publish', '发布公益活动', '执行活动发布及状态迁移'),
+('registrations:manage', '管理活动报名', '审核报名、签到并记录服务时长');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
+WHERE r.code IN ('PROJECT_MANAGER', 'ADMIN', 'SUPER_ADMIN')
+ AND p.code IN ('volunteers:read', 'volunteers:review', 'projects:read', 'projects:manage', 'projects:publish', 'activities:read', 'activities:manage', 'activities:publish', 'registrations:manage');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
+WHERE r.code = 'STAFF'
+ AND p.code IN ('volunteers:read', 'projects:read', 'activities:read', 'registrations:manage');
diff --git a/apps/api/prisma/migrations/20260812140000_phase6_content_notifications/migration.sql b/apps/api/prisma/migrations/20260812140000_phase6_content_notifications/migration.sql
new file mode 100644
index 0000000..d494f74
--- /dev/null
+++ b/apps/api/prisma/migrations/20260812140000_phase6_content_notifications/migration.sql
@@ -0,0 +1,120 @@
+-- Phase 6: reviewed public content and durable user notifications.
+ALTER TABLE `files` MODIFY `business_type` ENUM('SERVICE_RECORD', 'PROJECT_COVER', 'ACTIVITY_COVER', 'CONTENT_COVER') NOT NULL;
+
+CREATE TABLE `content_posts` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `client_request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `type` ENUM('ORGANIZATION_INTRO','PUBLIC_WELFARE_NEWS','SERVICE_STORY','NOTICE','FAQ') NOT NULL,
+ `title` VARCHAR(200) NOT NULL,
+ `summary` VARCHAR(500) NOT NULL,
+ `content` TEXT NOT NULL,
+ `cover_file_id` BIGINT UNSIGNED NULL,
+ `status` ENUM('DRAFT','PENDING_REVIEW','PUBLISHED','ARCHIVED') NOT NULL DEFAULT 'DRAFT',
+ `revision` INTEGER UNSIGNED NOT NULL DEFAULT 1,
+ `privacy_reviewed_at` DATETIME(3) NULL,
+ `reviewed_by` BIGINT UNSIGNED NULL,
+ `reviewed_at` DATETIME(3) NULL,
+ `published_at` DATETIME(3) NULL,
+ `created_by` BIGINT UNSIGNED NOT NULL,
+ `updated_by` BIGINT UNSIGNED NOT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `content_posts_public_id_key` (`public_id`),
+ UNIQUE INDEX `content_posts_cover_file_id_key` (`cover_file_id`),
+ UNIQUE INDEX `content_posts_creator_request_key` (`created_by`, `client_request_id`),
+ INDEX `content_posts_status_type_published_idx` (`status`, `type`, `published_at`),
+ INDEX `content_posts_creator_status_updated_idx` (`created_by`, `status`, `updated_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `content_post_status_history` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `content_post_id` BIGINT UNSIGNED NOT NULL,
+ `request_id` CHAR(36) NOT NULL,
+ `request_fingerprint` CHAR(64) NOT NULL,
+ `from_status` ENUM('DRAFT','PENDING_REVIEW','PUBLISHED','ARCHIVED') NULL,
+ `to_status` ENUM('DRAFT','PENDING_REVIEW','PUBLISHED','ARCHIVED') NOT NULL,
+ `operator_user_id` BIGINT UNSIGNED NOT NULL,
+ `reason_code` VARCHAR(100) NOT NULL,
+ `note_encrypted` TEXT NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE INDEX `content_post_status_history_request_id_key` (`request_id`),
+ INDEX `content_status_history_post_created_idx` (`content_post_id`, `created_at`),
+ INDEX `content_status_history_operator_created_idx` (`operator_user_id`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `notifications` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `event_key` VARCHAR(160) NOT NULL,
+ `type` ENUM('APPLICATION_SUBMITTED','APPLICATION_ACCEPTED','SERVICE_ARRANGED','SERVICE_COMPLETED','FOLLOW_UP_PENDING','ACTIVITY_REGISTRATION_RESULT') NOT NULL,
+ `title` VARCHAR(120) NOT NULL,
+ `body` VARCHAR(500) NOT NULL,
+ `resource_type` VARCHAR(80) NULL,
+ `resource_public_id` CHAR(36) NULL,
+ `read_at` DATETIME(3) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ UNIQUE INDEX `notifications_public_id_key` (`public_id`),
+ UNIQUE INDEX `notifications_user_event_key` (`user_id`, `event_key`),
+ INDEX `notifications_user_read_created_idx` (`user_id`, `read_at`, `created_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `notification_preferences` (
+ `user_id` BIGINT UNSIGNED NOT NULL,
+ `type` ENUM('APPLICATION_SUBMITTED','APPLICATION_ACCEPTED','SERVICE_ARRANGED','SERVICE_COMPLETED','FOLLOW_UP_PENDING','ACTIVITY_REGISTRATION_RESULT') NOT NULL,
+ `in_app_enabled` BOOLEAN NOT NULL DEFAULT true,
+ `wechat_enabled` BOOLEAN NOT NULL DEFAULT false,
+ `authorized_at` DATETIME(3) NULL,
+ `updated_at` DATETIME(3) NOT NULL,
+ INDEX `notification_preferences_type_wechat_idx` (`type`, `wechat_enabled`),
+ PRIMARY KEY (`user_id`, `type`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+CREATE TABLE `notification_deliveries` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `public_id` CHAR(36) NOT NULL,
+ `notification_id` BIGINT UNSIGNED NOT NULL,
+ `channel` ENUM('WECHAT_SUBSCRIPTION') NOT NULL,
+ `status` ENUM('PENDING','PROCESSING','SENT','FAILED','DEAD') NOT NULL DEFAULT 'PENDING',
+ `attempt_count` INTEGER UNSIGNED NOT NULL DEFAULT 0,
+ `next_attempt_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `last_attempt_at` DATETIME(3) NULL,
+ `provider_message_id` VARCHAR(160) NULL,
+ `error_code` VARCHAR(100) NULL,
+ `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ `updated_at` DATETIME(3) NOT NULL,
+ UNIQUE INDEX `notification_deliveries_public_id_key` (`public_id`),
+ UNIQUE INDEX `notification_deliveries_notification_channel_key` (`notification_id`, `channel`),
+ INDEX `notification_deliveries_status_next_idx` (`status`, `next_attempt_at`),
+ PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_cover_file_id_fkey` FOREIGN KEY (`cover_file_id`) REFERENCES `files` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_updated_by_fkey` FOREIGN KEY (`updated_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `content_posts` ADD CONSTRAINT `content_posts_reviewed_by_fkey` FOREIGN KEY (`reviewed_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `content_post_status_history` ADD CONSTRAINT `content_status_history_post_fkey` FOREIGN KEY (`content_post_id`) REFERENCES `content_posts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `content_post_status_history` ADD CONSTRAINT `content_status_history_operator_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `notifications` ADD CONSTRAINT `notifications_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `notification_preferences` ADD CONSTRAINT `notification_preferences_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+ALTER TABLE `notification_deliveries` ADD CONSTRAINT `notification_deliveries_notification_id_fkey` FOREIGN KEY (`notification_id`) REFERENCES `notifications` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
+('content:read', '查看内容', '查看内容草稿、审核状态及历史'),
+('content:manage', '管理内容', '创建和维护内容草稿'),
+('content:review', '审核内容', '审核、发布、退回和归档内容'),
+('notifications:manage', '管理通知', '查看通知投递状态并安全重试');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
+WHERE r.code IN ('ADMIN','SUPER_ADMIN')
+ AND p.code IN ('content:read','content:manage','content:review','notifications:manage');
+
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
+WHERE r.code IN ('STAFF','PROJECT_MANAGER') AND p.code = 'content:read';
diff --git a/apps/api/prisma/migrations/20260812170000_phase7_statistics_security/migration.sql b/apps/api/prisma/migrations/20260812170000_phase7_statistics_security/migration.sql
new file mode 100644
index 0000000..5b692de
--- /dev/null
+++ b/apps/api/prisma/migrations/20260812170000_phase7_statistics_security/migration.sql
@@ -0,0 +1,9 @@
+INSERT INTO `permissions` (`code`, `name`, `description`) VALUES
+('statistics:read', '查看统计', '查看基于真实业务数据生成的统计指标'),
+('exports:create', '创建导出', '创建并下载默认脱敏的受控统计导出');
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
+WHERE r.code IN ('ADMIN','SUPER_ADMIN') AND p.code IN ('statistics:read','exports:create');
+INSERT INTO `role_permissions` (`role_id`, `permission_id`)
+SELECT r.id, p.id FROM `roles` r CROSS JOIN `permissions` p
+WHERE r.code = 'PROJECT_MANAGER' AND p.code = 'statistics:read';
diff --git a/apps/api/prisma/migrations/20260812173000_phase7_fk_names/migration.sql b/apps/api/prisma/migrations/20260812173000_phase7_fk_names/migration.sql
new file mode 100644
index 0000000..744f87b
--- /dev/null
+++ b/apps/api/prisma/migrations/20260812173000_phase7_fk_names/migration.sql
@@ -0,0 +1,5 @@
+ALTER TABLE `content_post_status_history`
+ DROP FOREIGN KEY `content_status_history_post_fkey`,
+ DROP FOREIGN KEY `content_status_history_operator_fkey`,
+ ADD CONSTRAINT `content_post_status_history_content_post_id_fkey` FOREIGN KEY (`content_post_id`) REFERENCES `content_posts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
+ ADD CONSTRAINT `content_post_status_history_operator_user_id_fkey` FOREIGN KEY (`operator_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..7dec767
--- /dev/null
+++ b/apps/api/prisma/migrations/migration_lock.toml
@@ -0,0 +1 @@
+provider = "mysql"
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
new file mode 100644
index 0000000..2a04b80
--- /dev/null
+++ b/apps/api/prisma/schema.prisma
@@ -0,0 +1,1203 @@
+generator client {
+ provider = "prisma-client"
+ output = "../src/generated/prisma"
+ moduleFormat = "cjs"
+ importFileExtension = ""
+}
+
+datasource db {
+ provider = "mysql"
+}
+
+enum UserStatus {
+ ACTIVE
+ SUSPENDED
+ DISABLED
+}
+
+enum UserType {
+ PUBLIC
+ STAFF
+}
+
+enum StaffStatus {
+ ACTIVE
+ INACTIVE
+}
+
+enum SessionChannel {
+ WECHAT
+ ADMIN
+}
+
+enum AuditResult {
+ SUCCESS
+ FAILURE
+}
+
+enum ServiceCategory {
+ OLD_AGE
+ WOMEN
+ CHILD
+ DISABILITY
+ LIFE_DIFFICULTY
+ OTHER
+}
+
+enum ApplicationApplyMode {
+ SELF
+ FAMILY
+ COMMUNITY
+ VOLUNTEER
+ OTHER
+}
+
+enum ServiceApplicationStatus {
+ SUBMITTED
+ PENDING_CONTACT
+ ASSESSING
+ REJECTED
+ OUT_OF_SCOPE
+ REFERRED
+ ACCEPTED
+ SERVICE_PLANNED
+ IN_SERVICE
+ SERVICE_COMPLETED
+ FOLLOW_UP_PENDING
+ CLOSED
+ CANCELLED
+ SUSPENDED
+}
+
+enum ApplicationSource {
+ WECHAT_MINIPROGRAM
+ ADMIN
+ FOLLOW_UP
+}
+
+enum ConsentType {
+ SERVICE_APPLICATION
+ BENEFICIARY_GUARDIAN
+ VOLUNTEER_APPLICATION
+}
+
+enum ConsentGrantedBy {
+ SELF
+ APPLICANT
+ GUARDIAN
+}
+
+enum BeneficiaryGender {
+ FEMALE
+ MALE
+ OTHER
+ UNDISCLOSED
+}
+
+enum BeneficiaryStatus {
+ ACTIVE
+ INACTIVE
+ ARCHIVED
+}
+
+enum ServiceCasePriority {
+ LOW
+ NORMAL
+ HIGH
+ URGENT
+}
+
+enum ServiceCaseStatus {
+ ASSIGNED
+ IN_SERVICE
+ SERVICE_COMPLETED
+ SUSPENDED
+ CANCELLED
+}
+
+enum CaseAssigneeType {
+ STAFF
+ VOLUNTEER
+}
+
+enum CaseAssigneeRole {
+ OWNER
+ COLLABORATOR
+}
+
+enum ServiceRecordType {
+ HOME_VISIT
+ PHONE_CONTACT
+ ESCORT
+ MATERIAL_SUPPORT
+ ACTIVITY
+ OTHER
+}
+
+enum BeneficiaryConfirmStatus {
+ NOT_REQUESTED
+ CONFIRMED
+ PARTIALLY_CONFIRMED
+ DISPUTED
+}
+
+enum FileStatus {
+ UPLOAD_PENDING
+ AVAILABLE
+ QUARANTINED
+ DELETED
+}
+
+enum FileBusinessType {
+ SERVICE_RECORD
+ PROJECT_COVER
+ ACTIVITY_COVER
+ CONTENT_COVER
+}
+
+enum ContentPostType {
+ ORGANIZATION_INTRO
+ PUBLIC_WELFARE_NEWS
+ SERVICE_STORY
+ NOTICE
+ FAQ
+}
+
+enum ContentPostStatus {
+ DRAFT
+ PENDING_REVIEW
+ PUBLISHED
+ ARCHIVED
+}
+
+enum NotificationType {
+ APPLICATION_SUBMITTED
+ APPLICATION_ACCEPTED
+ SERVICE_ARRANGED
+ SERVICE_COMPLETED
+ FOLLOW_UP_PENDING
+ ACTIVITY_REGISTRATION_RESULT
+}
+
+enum NotificationChannel {
+ WECHAT_SUBSCRIPTION
+}
+
+enum NotificationDeliveryStatus {
+ PENDING
+ PROCESSING
+ SENT
+ FAILED
+ DEAD
+}
+
+enum VolunteerStatus {
+ APPLIED
+ APPROVED
+ REJECTED
+ SUSPENDED
+ INACTIVE
+}
+
+enum ProjectStatus {
+ DRAFT
+ PUBLISHED
+ COMPLETED
+}
+
+enum ActivityStatus {
+ DRAFT
+ PUBLISHED
+ REGISTRATION_CLOSED
+ IN_PROGRESS
+ COMPLETED
+ CANCELLED
+}
+
+enum ActivityRegistrationStatus {
+ PENDING
+ APPROVED
+ REJECTED
+ CANCELLED
+}
+
+enum ActivityCheckInStatus {
+ NOT_CHECKED_IN
+ CHECKED_IN
+ ABSENT
+}
+
+enum FollowUpType {
+ SERVICE_COMPLETION
+ KEY_CARE
+ MANUAL
+}
+
+enum FollowUpStatus {
+ PENDING
+ COMPLETED
+ CANCELLED
+}
+
+enum FollowUpResultCode {
+ SATISFIED
+ GENERALLY_SATISFIED
+ UNSATISFIED
+ NEED_MORE_HELP
+ UNREACHABLE
+ OTHER
+}
+
+enum FollowUpSource {
+ AUTO_SERVICE_COMPLETION
+ STAFF_MANUAL
+ KEY_CARE_PLAN
+}
+
+model User {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ status UserStatus @default(ACTIVE)
+ userType UserType @map("user_type")
+ mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
+ mobileHash String? @unique @map("mobile_hash") @db.Char(64)
+ nickname String? @db.VarChar(100)
+ avatarUrl String? @map("avatar_url") @db.VarChar(512)
+ lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ wechatIdentities WechatIdentity[]
+ staffProfile StaffProfile?
+ adminCredential AdminCredential?
+ sessions AuthSession[]
+ userRoles UserRole[]
+ auditLogs AuditLog[] @relation("AuditActor")
+ assignedRoles UserRole[] @relation("UserRoleAssigner")
+ serviceApplications ServiceApplication[] @relation("ApplicationApplicant")
+ applicationStatusChanges ServiceApplicationStatusHistory[] @relation("ApplicationStatusOperator")
+ consents Consent[]
+ createdBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryCreator")
+ updatedBeneficiaries BeneficiaryProfile[] @relation("BeneficiaryUpdater")
+ ownedServiceCases ServiceCase[] @relation("CaseOwner")
+ createdServiceCases ServiceCase[] @relation("CaseCreator")
+ updatedServiceCases ServiceCase[] @relation("CaseUpdater")
+ caseAssignments CaseAssignee[]
+ caseStatusChanges ServiceCaseStatusHistory[] @relation("CaseStatusOperator")
+ createdServiceRecords ServiceRecord[] @relation("ServiceRecordCreator")
+ updatedServiceRecords ServiceRecord[] @relation("ServiceRecordUpdater")
+ uploadedFiles File[] @relation("FileUploader")
+ assignedBeneficiaryTags BeneficiaryTag[] @relation("BeneficiaryTagAssigner")
+ assignedCaseMembers CaseAssignee[] @relation("CaseAssigneeAssigner")
+ revisedServiceRecords ServiceRecordRevision[] @relation("ServiceRecordReviser")
+ ownedFollowUpTasks FollowUpTask[] @relation("FollowUpOwner")
+ createdFollowUpTasks FollowUpTask[] @relation("FollowUpCreator")
+ updatedFollowUpTasks FollowUpTask[] @relation("FollowUpUpdater")
+ followUpStatusChanges FollowUpStatusHistory[] @relation("FollowUpStatusOperator")
+ volunteerProfile Volunteer?
+ reviewedVolunteers Volunteer[] @relation("VolunteerReviewer")
+ createdVolunteers Volunteer[] @relation("VolunteerCreator")
+ updatedVolunteers Volunteer[] @relation("VolunteerUpdater")
+ volunteerStatusChanges VolunteerStatusHistory[] @relation("VolunteerStatusOperator")
+ managedProjects Project[] @relation("ProjectManager")
+ createdProjects Project[] @relation("ProjectCreator")
+ updatedProjects Project[] @relation("ProjectUpdater")
+ projectStatusChanges ProjectStatusHistory[] @relation("ProjectStatusOperator")
+ createdActivities Activity[] @relation("ActivityCreator")
+ updatedActivities Activity[] @relation("ActivityUpdater")
+ activityStatusChanges ActivityStatusHistory[] @relation("ActivityStatusOperator")
+ activityRegistrations ActivityRegistration[] @relation("ActivityRegistrationUser")
+ reviewedRegistrations ActivityRegistration[] @relation("RegistrationReviewer")
+ checkedInRegistrations ActivityRegistration[] @relation("RegistrationCheckInOperator")
+ recordedServiceMinutes ActivityRegistration[] @relation("RegistrationMinutesOperator")
+ registrationChanges ActivityRegistrationHistory[] @relation("RegistrationHistoryOperator")
+ createdContentPosts ContentPost[] @relation("ContentCreator")
+ updatedContentPosts ContentPost[] @relation("ContentUpdater")
+ reviewedContentPosts ContentPost[] @relation("ContentReviewer")
+ contentStatusChanges ContentPostStatusHistory[] @relation("ContentStatusOperator")
+ notifications Notification[]
+ notificationPreferences NotificationPreference[]
+
+ @@map("users")
+}
+
+model WechatIdentity {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ appid String @db.VarChar(64)
+ openidCiphertext String @map("openid_ciphertext") @db.VarChar(512)
+ openidHash String @map("openid_hash") @db.Char(64)
+ unionidCiphertext String? @map("unionid_ciphertext") @db.VarChar(512)
+ unionidHash String? @map("unionid_hash") @db.Char(64)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+
+ @@unique([appid, openidHash], map: "wechat_identities_appid_openid_hash_key")
+ @@index([userId], map: "wechat_identities_user_id_idx")
+ @@map("wechat_identities")
+}
+
+model StaffProfile {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @unique @map("user_id") @db.UnsignedBigInt
+ employeeNo String? @unique @map("employee_no") @db.VarChar(50)
+ displayName String @map("display_name") @db.VarChar(100)
+ department String? @db.VarChar(100)
+ status StaffStatus @default(ACTIVE)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+
+ @@map("staff_profiles")
+}
+
+model AdminCredential {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ userId BigInt @unique @map("user_id") @db.UnsignedBigInt
+ username String @unique @db.VarChar(64)
+ passwordHash String @map("password_hash") @db.VarChar(255)
+ failedLoginCount Int @default(0) @map("failed_login_count") @db.UnsignedInt
+ lockedUntil DateTime? @map("locked_until") @db.DateTime(3)
+ passwordChangedAt DateTime @default(now()) @map("password_changed_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+
+ @@map("admin_credentials")
+}
+
+model AuthSession {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ channel SessionChannel
+ tokenHash String @unique @map("token_hash") @db.Char(64)
+ expiresAt DateTime @map("expires_at") @db.DateTime(3)
+ lastUsedAt DateTime? @map("last_used_at") @db.DateTime(3)
+ revokedAt DateTime? @map("revoked_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+
+ @@index([userId, channel, revokedAt, expiresAt], map: "auth_sessions_user_channel_status_idx")
+ @@index([expiresAt], map: "auth_sessions_expires_at_idx")
+ @@map("auth_sessions")
+}
+
+model Role {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ code String @unique @db.VarChar(64)
+ name String @db.VarChar(100)
+ description String? @db.VarChar(255)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ userRoles UserRole[]
+ rolePermissions RolePermission[]
+
+ @@map("roles")
+}
+
+model Permission {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ code String @unique @db.VarChar(100)
+ name String @db.VarChar(100)
+ description String? @db.VarChar(255)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ rolePermissions RolePermission[]
+
+ @@map("permissions")
+}
+
+model UserRole {
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ roleId BigInt @map("role_id") @db.UnsignedBigInt
+ assignedBy BigInt? @map("assigned_by") @db.UnsignedBigInt
+ assignedAt DateTime @default(now()) @map("assigned_at") @db.DateTime(3)
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ role Role @relation(fields: [roleId], references: [id], onDelete: Restrict)
+ assigner User? @relation("UserRoleAssigner", fields: [assignedBy], references: [id], onDelete: Restrict)
+
+ @@id([userId, roleId])
+ @@index([roleId], map: "user_roles_role_id_idx")
+ @@index([assignedBy], map: "user_roles_assigned_by_idx")
+ @@map("user_roles")
+}
+
+model RolePermission {
+ roleId BigInt @map("role_id") @db.UnsignedBigInt
+ permissionId BigInt @map("permission_id") @db.UnsignedBigInt
+ role Role @relation(fields: [roleId], references: [id], onDelete: Restrict)
+ permission Permission @relation(fields: [permissionId], references: [id], onDelete: Restrict)
+
+ @@id([roleId, permissionId])
+ @@index([permissionId], map: "role_permissions_permission_id_idx")
+ @@map("role_permissions")
+}
+
+model AuditLog {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ requestId String @map("request_id") @db.Char(36)
+ actorUserId BigInt? @map("actor_user_id") @db.UnsignedBigInt
+ actorRole String? @map("actor_role") @db.VarChar(64)
+ action String @db.VarChar(100)
+ resourceType String? @map("resource_type") @db.VarChar(100)
+ resourcePublicId String? @map("resource_public_id") @db.VarChar(100)
+ result AuditResult
+ metadataJson Json? @map("metadata_json")
+ ipHash String? @map("ip_hash") @db.Char(64)
+ userAgentSummary String? @map("user_agent_summary") @db.VarChar(255)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ actor User? @relation("AuditActor", fields: [actorUserId], references: [id], onDelete: Restrict)
+
+ @@index([actorUserId, createdAt], map: "audit_logs_actor_created_at_idx")
+ @@index([resourceType, resourcePublicId, createdAt], map: "audit_logs_resource_created_at_idx")
+ @@index([action, createdAt], map: "audit_logs_action_created_at_idx")
+ @@map("audit_logs")
+}
+
+model Consent {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ userId BigInt? @map("user_id") @db.UnsignedBigInt
+ beneficiaryId BigInt? @map("beneficiary_id") @db.UnsignedBigInt
+ consentType ConsentType @map("consent_type")
+ version String @db.VarChar(32)
+ grantedBy ConsentGrantedBy @map("granted_by")
+ grantedAt DateTime @map("granted_at") @db.DateTime(3)
+ revokedAt DateTime? @map("revoked_at") @db.DateTime(3)
+ evidenceJson Json? @map("evidence_json")
+ ipHash String? @map("ip_hash") @db.Char(64)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ user User? @relation(fields: [userId], references: [id], onDelete: Restrict)
+ beneficiary BeneficiaryProfile? @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict)
+ application ServiceApplication?
+ beneficiaryContact BeneficiaryContact?
+ volunteer Volunteer?
+
+ @@index([userId, consentType, grantedAt], map: "consents_user_type_granted_at_idx")
+ @@index([beneficiaryId, consentType, grantedAt], map: "consents_beneficiary_type_granted_at_idx")
+ @@map("consents")
+}
+
+model ServiceApplication {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ applicationNo String @unique @map("application_no") @db.VarChar(32)
+ applicantUserId BigInt @map("applicant_user_id") @db.UnsignedBigInt
+ beneficiaryId BigInt? @map("beneficiary_id") @db.UnsignedBigInt
+ consentId BigInt @unique @map("consent_id") @db.UnsignedBigInt
+ sourceFollowUpTaskId BigInt? @unique @map("source_follow_up_task_id") @db.UnsignedBigInt
+ parentApplicationId BigInt? @map("parent_application_id") @db.UnsignedBigInt
+ clientRequestId String @map("client_request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ applyMode ApplicationApplyMode @map("apply_mode")
+ serviceCategory ServiceCategory @map("service_category")
+ beneficiaryNameEncrypted String @map("beneficiary_name_encrypted") @db.VarChar(512)
+ contactMobileEncrypted String @map("contact_mobile_encrypted") @db.VarChar(512)
+ contactMobileHash String @map("contact_mobile_hash") @db.Char(64)
+ regionCode String @map("region_code") @db.VarChar(100)
+ descriptionEncrypted String @map("description_encrypted") @db.Text
+ preferredContactTime String? @map("preferred_contact_time") @db.VarChar(100)
+ needsHomeVisit Boolean? @map("needs_home_visit")
+ mobilityDifficulty Boolean? @map("mobility_difficulty")
+ status ServiceApplicationStatus @default(SUBMITTED)
+ source ApplicationSource @default(WECHAT_MINIPROGRAM)
+ submittedAt DateTime @map("submitted_at") @db.DateTime(3)
+ acceptedAt DateTime? @map("accepted_at") @db.DateTime(3)
+ closedAt DateTime? @map("closed_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ applicant User @relation("ApplicationApplicant", fields: [applicantUserId], references: [id], onDelete: Restrict)
+ beneficiary BeneficiaryProfile? @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict)
+ consent Consent @relation(fields: [consentId], references: [id], onDelete: Restrict)
+ statusHistory ServiceApplicationStatusHistory[]
+ serviceCase ServiceCase?
+ sourceFollowUpTask FollowUpTask? @relation("FollowUpContinuedApplication", fields: [sourceFollowUpTaskId], references: [id], onDelete: Restrict)
+ parentApplication ServiceApplication? @relation("ContinuedApplications", fields: [parentApplicationId], references: [id], onDelete: Restrict)
+ continuedApplications ServiceApplication[] @relation("ContinuedApplications")
+
+ @@unique([applicantUserId, clientRequestId], map: "service_applications_applicant_client_request_key")
+ @@index([applicantUserId, submittedAt], map: "service_applications_applicant_submitted_at_idx")
+ @@index([status, submittedAt], map: "service_applications_status_submitted_at_idx")
+ @@index([serviceCategory, submittedAt], map: "service_applications_category_submitted_at_idx")
+ @@index([contactMobileHash], map: "service_applications_contact_mobile_hash_idx")
+ @@index([beneficiaryId, submittedAt], map: "service_applications_beneficiary_submitted_at_idx")
+ @@index([parentApplicationId, submittedAt], map: "service_applications_parent_submitted_at_idx")
+ @@map("service_applications")
+}
+
+model ServiceApplicationStatusHistory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ applicationId BigInt @map("application_id") @db.UnsignedBigInt
+ fromStatus ServiceApplicationStatus? @map("from_status")
+ toStatus ServiceApplicationStatus @map("to_status")
+ operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
+ reasonCode String @map("reason_code") @db.VarChar(100)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ application ServiceApplication @relation(fields: [applicationId], references: [id], onDelete: Restrict)
+ operator User @relation("ApplicationStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
+
+ @@index([applicationId, createdAt], map: "application_status_history_application_created_at_idx")
+ @@index([operatorUserId, createdAt], map: "application_status_history_operator_created_at_idx")
+ @@map("service_application_status_history")
+}
+
+model BeneficiaryProfile {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ displayNameEncrypted String @map("display_name_encrypted") @db.VarChar(512)
+ displayNameHash String @map("display_name_hash") @db.Char(64)
+ gender BeneficiaryGender?
+ birthYear Int? @map("birth_year") @db.UnsignedSmallInt
+ mobileEncrypted String? @map("mobile_encrypted") @db.VarChar(512)
+ mobileHash String? @map("mobile_hash") @db.Char(64)
+ regionCode String @map("region_code") @db.VarChar(100)
+ addressDetailEncrypted String? @map("address_detail_encrypted") @db.VarChar(1024)
+ primaryCategory ServiceCategory @map("primary_category")
+ keyCareFlag Boolean @default(false) @map("key_care_flag")
+ isMinor Boolean @default(false) @map("is_minor")
+ status BeneficiaryStatus @default(ACTIVE)
+ notesEncrypted String? @map("notes_encrypted") @db.Text
+ createdById BigInt @map("created_by") @db.UnsignedBigInt
+ updatedById BigInt @map("updated_by") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ creator User @relation("BeneficiaryCreator", fields: [createdById], references: [id], onDelete: Restrict)
+ updater User @relation("BeneficiaryUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
+ contacts BeneficiaryContact[]
+ beneficiaryTags BeneficiaryTag[]
+ applications ServiceApplication[]
+ serviceCases ServiceCase[]
+ serviceRecords ServiceRecord[]
+ consents Consent[]
+ followUpTasks FollowUpTask[]
+
+ @@index([displayNameHash], map: "beneficiary_profiles_display_name_hash_idx")
+ @@index([mobileHash], map: "beneficiary_profiles_mobile_hash_idx")
+ @@index([primaryCategory, status, createdAt], map: "beneficiary_profiles_category_status_created_at_idx")
+ @@index([keyCareFlag, status, createdAt], map: "beneficiary_profiles_key_care_status_created_at_idx")
+ @@map("beneficiary_profiles")
+}
+
+model BeneficiaryContact {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ beneficiaryId BigInt @map("beneficiary_id") @db.UnsignedBigInt
+ nameEncrypted String @map("name_encrypted") @db.VarChar(512)
+ relation String @db.VarChar(50)
+ mobileEncrypted String @map("mobile_encrypted") @db.VarChar(512)
+ mobileHash String @map("mobile_hash") @db.Char(64)
+ isGuardian Boolean @default(false) @map("is_guardian")
+ isEmergencyContact Boolean @default(false) @map("is_emergency_contact")
+ consentRecordId BigInt? @unique @map("consent_record_id") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ removedAt DateTime? @map("removed_at") @db.DateTime(3)
+ beneficiary BeneficiaryProfile @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict)
+ consentRecord Consent? @relation(fields: [consentRecordId], references: [id], onDelete: Restrict)
+
+ @@index([beneficiaryId, removedAt, isGuardian], map: "beneficiary_contacts_beneficiary_active_guardian_idx")
+ @@index([mobileHash], map: "beneficiary_contacts_mobile_hash_idx")
+ @@map("beneficiary_contacts")
+}
+
+model Tag {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ code String @unique @db.VarChar(64)
+ name String @db.VarChar(50)
+ description String? @db.VarChar(255)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ beneficiaryTags BeneficiaryTag[]
+
+ @@map("tags")
+}
+
+model BeneficiaryTag {
+ beneficiaryId BigInt @map("beneficiary_id") @db.UnsignedBigInt
+ tagId BigInt @map("tag_id") @db.UnsignedBigInt
+ assignedById BigInt @map("assigned_by") @db.UnsignedBigInt
+ assignedAt DateTime @default(now()) @map("assigned_at") @db.DateTime(3)
+ beneficiary BeneficiaryProfile @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict)
+ tag Tag @relation(fields: [tagId], references: [id], onDelete: Restrict)
+ assigner User @relation("BeneficiaryTagAssigner", fields: [assignedById], references: [id], onDelete: Restrict)
+
+ @@id([beneficiaryId, tagId])
+ @@index([tagId], map: "beneficiary_tags_tag_id_idx")
+ @@map("beneficiary_tags")
+}
+
+model ServiceCase {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ caseNo String @unique @map("case_no") @db.VarChar(32)
+ applicationId BigInt @unique @map("application_id") @db.UnsignedBigInt
+ acceptRequestId String @map("accept_request_id") @db.Char(36)
+ acceptRequestFingerprint String @map("accept_request_fingerprint") @db.Char(64)
+ beneficiaryId BigInt @map("beneficiary_id") @db.UnsignedBigInt
+ projectId BigInt? @map("project_id") @db.UnsignedBigInt
+ ownerStaffId BigInt @map("owner_staff_id") @db.UnsignedBigInt
+ category ServiceCategory
+ priority ServiceCasePriority @default(NORMAL)
+ status ServiceCaseStatus @default(ASSIGNED)
+ plannedStartAt DateTime? @map("planned_start_at") @db.DateTime(3)
+ plannedEndAt DateTime? @map("planned_end_at") @db.DateTime(3)
+ serviceLocationEncrypted String? @map("service_location_encrypted") @db.VarChar(1024)
+ summaryEncrypted String @map("summary_encrypted") @db.Text
+ resultSummaryEncrypted String? @map("result_summary_encrypted") @db.Text
+ followUpPlanEncrypted String? @map("follow_up_plan_encrypted") @db.Text
+ completedAt DateTime? @map("completed_at") @db.DateTime(3)
+ completeRequestId String? @unique @map("complete_request_id") @db.Char(36)
+ completeRequestFingerprint String? @map("complete_request_fingerprint") @db.Char(64)
+ createdById BigInt @map("created_by") @db.UnsignedBigInt
+ updatedById BigInt @map("updated_by") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ application ServiceApplication @relation(fields: [applicationId], references: [id], onDelete: Restrict)
+ beneficiary BeneficiaryProfile @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict)
+ project Project? @relation(fields: [projectId], references: [id], onDelete: Restrict)
+ owner User @relation("CaseOwner", fields: [ownerStaffId], references: [id], onDelete: Restrict)
+ creator User @relation("CaseCreator", fields: [createdById], references: [id], onDelete: Restrict)
+ updater User @relation("CaseUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
+ assignees CaseAssignee[]
+ statusHistory ServiceCaseStatusHistory[]
+ serviceRecords ServiceRecord[]
+ files File[]
+ followUpTasks FollowUpTask[]
+
+ @@unique([createdById, acceptRequestId], map: "service_cases_creator_accept_request_key")
+ @@index([ownerStaffId, status, plannedStartAt], map: "service_cases_owner_status_planned_idx")
+ @@index([beneficiaryId, createdAt], map: "service_cases_beneficiary_created_at_idx")
+ @@index([projectId, createdAt], map: "service_cases_project_created_at_idx")
+ @@index([status, createdAt], map: "service_cases_status_created_at_idx")
+ @@map("service_cases")
+}
+
+model CaseAssignee {
+ caseId BigInt @map("case_id") @db.UnsignedBigInt
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ assigneeType CaseAssigneeType @map("assignee_type")
+ role CaseAssigneeRole
+ assignedById BigInt @map("assigned_by") @db.UnsignedBigInt
+ assignedAt DateTime @default(now()) @map("assigned_at") @db.DateTime(3)
+ removedAt DateTime? @map("removed_at") @db.DateTime(3)
+ serviceCase ServiceCase @relation(fields: [caseId], references: [id], onDelete: Restrict)
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ assigner User @relation("CaseAssigneeAssigner", fields: [assignedById], references: [id], onDelete: Restrict)
+
+ @@id([caseId, userId])
+ @@index([userId, removedAt, assignedAt], map: "case_assignees_user_active_assigned_idx")
+ @@map("case_assignees")
+}
+
+model ServiceCaseStatusHistory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ caseId BigInt @map("case_id") @db.UnsignedBigInt
+ fromStatus ServiceCaseStatus? @map("from_status")
+ toStatus ServiceCaseStatus @map("to_status")
+ operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
+ reasonCode String @map("reason_code") @db.VarChar(100)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ serviceCase ServiceCase @relation(fields: [caseId], references: [id], onDelete: Restrict)
+ operator User @relation("CaseStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
+
+ @@index([caseId, createdAt], map: "case_status_history_case_created_at_idx")
+ @@index([operatorUserId, createdAt], map: "case_status_history_operator_created_at_idx")
+ @@map("service_case_status_history")
+}
+
+model ServiceRecord {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ caseId BigInt @map("case_id") @db.UnsignedBigInt
+ beneficiaryId BigInt @map("beneficiary_id") @db.UnsignedBigInt
+ clientRequestId String @map("client_request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ serviceStartAt DateTime @map("service_start_at") @db.DateTime(3)
+ serviceEndAt DateTime? @map("service_end_at") @db.DateTime(3)
+ serviceType ServiceRecordType @map("service_type")
+ contentEncrypted String @map("content_encrypted") @db.Text
+ resultEncrypted String @map("result_encrypted") @db.Text
+ followUpSuggestionEncrypted String? @map("follow_up_suggestion_encrypted") @db.Text
+ beneficiaryConfirmStatus BeneficiaryConfirmStatus @default(NOT_REQUESTED) @map("beneficiary_confirm_status")
+ createdById BigInt @map("created_by") @db.UnsignedBigInt
+ updatedById BigInt @map("updated_by") @db.UnsignedBigInt
+ revision Int @default(1) @db.UnsignedInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ serviceCase ServiceCase @relation(fields: [caseId], references: [id], onDelete: Restrict)
+ beneficiary BeneficiaryProfile @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict)
+ creator User @relation("ServiceRecordCreator", fields: [createdById], references: [id], onDelete: Restrict)
+ updater User @relation("ServiceRecordUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
+ attachments ServiceRecordFile[]
+ revisions ServiceRecordRevision[]
+
+ @@unique([createdById, clientRequestId], map: "service_records_creator_client_request_key")
+ @@index([caseId, serviceStartAt], map: "service_records_case_start_at_idx")
+ @@index([beneficiaryId, serviceStartAt], map: "service_records_beneficiary_start_at_idx")
+ @@map("service_records")
+}
+
+model ServiceRecordRevision {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ serviceRecordId BigInt @map("service_record_id") @db.UnsignedBigInt
+ revision Int @db.UnsignedInt
+ serviceStartAt DateTime @map("service_start_at") @db.DateTime(3)
+ serviceEndAt DateTime? @map("service_end_at") @db.DateTime(3)
+ serviceType ServiceRecordType @map("service_type")
+ contentEncrypted String @map("content_encrypted") @db.Text
+ resultEncrypted String @map("result_encrypted") @db.Text
+ followUpSuggestionEncrypted String? @map("follow_up_suggestion_encrypted") @db.Text
+ beneficiaryConfirmStatus BeneficiaryConfirmStatus @map("beneficiary_confirm_status")
+ revisedById BigInt @map("revised_by") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Restrict)
+ reviser User @relation("ServiceRecordReviser", fields: [revisedById], references: [id], onDelete: Restrict)
+
+ @@unique([serviceRecordId, revision], map: "service_record_revisions_record_revision_key")
+ @@index([revisedById, createdAt], map: "service_record_revisions_reviser_created_at_idx")
+ @@map("service_record_revisions")
+}
+
+model File {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ caseId BigInt? @map("case_id") @db.UnsignedBigInt
+ clientRequestId String @map("client_request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ storageProvider String @map("storage_provider") @db.VarChar(50)
+ bucket String @db.VarChar(255)
+ objectKey String @unique @map("object_key") @db.VarChar(512)
+ originalFilenameEncrypted String @map("original_filename_encrypted") @db.VarChar(1024)
+ mimeType String @map("mime_type") @db.VarChar(100)
+ size BigInt @db.UnsignedBigInt
+ sha256 String? @db.Char(64)
+ businessType FileBusinessType @map("business_type")
+ uploaderUserId BigInt @map("uploader_user_id") @db.UnsignedBigInt
+ status FileStatus @default(UPLOAD_PENDING)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ serviceCase ServiceCase? @relation(fields: [caseId], references: [id], onDelete: Restrict)
+ uploader User @relation("FileUploader", fields: [uploaderUserId], references: [id], onDelete: Restrict)
+ serviceRecords ServiceRecordFile[]
+ projectCover Project? @relation("ProjectCover")
+ activityCover Activity? @relation("ActivityCover")
+ contentCover ContentPost? @relation("ContentCover")
+
+ @@unique([uploaderUserId, clientRequestId], map: "files_uploader_client_request_key")
+ @@index([caseId, status, createdAt], map: "files_case_status_created_at_idx")
+ @@index([uploaderUserId, status, createdAt], map: "files_uploader_status_created_at_idx")
+ @@map("files")
+}
+
+model ServiceRecordFile {
+ serviceRecordId BigInt @map("service_record_id") @db.UnsignedBigInt
+ fileId BigInt @map("file_id") @db.UnsignedBigInt
+ attachedAt DateTime @default(now()) @map("attached_at") @db.DateTime(3)
+ serviceRecord ServiceRecord @relation(fields: [serviceRecordId], references: [id], onDelete: Restrict)
+ file File @relation(fields: [fileId], references: [id], onDelete: Restrict)
+
+ @@id([serviceRecordId, fileId])
+ @@index([fileId], map: "service_record_files_file_id_idx")
+ @@map("service_record_files")
+}
+
+model FollowUpTask {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ followUpNo String @unique @map("follow_up_no") @db.VarChar(32)
+ beneficiaryId BigInt @map("beneficiary_id") @db.UnsignedBigInt
+ caseId BigInt? @map("case_id") @db.UnsignedBigInt
+ ownerStaffId BigInt @map("owner_staff_id") @db.UnsignedBigInt
+ dueAt DateTime @map("due_at") @db.DateTime(3)
+ followUpType FollowUpType @map("follow_up_type")
+ status FollowUpStatus @default(PENDING)
+ source FollowUpSource
+ reasonEncrypted String @map("reason_encrypted") @db.Text
+ resultCode FollowUpResultCode? @map("result_code")
+ resultNoteEncrypted String? @map("result_note_encrypted") @db.Text
+ createRequestId String @map("create_request_id") @db.Char(36)
+ createRequestFingerprint String @map("create_request_fingerprint") @db.Char(64)
+ completeRequestId String? @map("complete_request_id") @db.Char(36)
+ completeRequestFingerprint String? @map("complete_request_fingerprint") @db.Char(64)
+ completedAt DateTime? @map("completed_at") @db.DateTime(3)
+ createdById BigInt @map("created_by") @db.UnsignedBigInt
+ updatedById BigInt @map("updated_by") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ beneficiary BeneficiaryProfile @relation(fields: [beneficiaryId], references: [id], onDelete: Restrict)
+ serviceCase ServiceCase? @relation(fields: [caseId], references: [id], onDelete: Restrict)
+ owner User @relation("FollowUpOwner", fields: [ownerStaffId], references: [id], onDelete: Restrict)
+ creator User @relation("FollowUpCreator", fields: [createdById], references: [id], onDelete: Restrict)
+ updater User @relation("FollowUpUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
+ statusHistory FollowUpStatusHistory[]
+ continuedApplication ServiceApplication? @relation("FollowUpContinuedApplication")
+
+ @@unique([createdById, createRequestId], map: "follow_up_tasks_creator_request_key")
+ @@index([ownerStaffId, status, dueAt], map: "follow_up_tasks_owner_status_due_at_idx")
+ @@index([status, dueAt], map: "follow_up_tasks_status_due_at_idx")
+ @@index([beneficiaryId, dueAt], map: "follow_up_tasks_beneficiary_due_at_idx")
+ @@index([caseId, createdAt], map: "follow_up_tasks_case_created_at_idx")
+ @@map("follow_up_tasks")
+}
+
+model FollowUpStatusHistory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ followUpTaskId BigInt @map("follow_up_task_id") @db.UnsignedBigInt
+ fromStatus FollowUpStatus? @map("from_status")
+ toStatus FollowUpStatus @map("to_status")
+ operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
+ reasonCode String @map("reason_code") @db.VarChar(100)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ followUpTask FollowUpTask @relation(fields: [followUpTaskId], references: [id], onDelete: Restrict)
+ operator User @relation("FollowUpStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
+
+ @@index([followUpTaskId, createdAt], map: "follow_up_status_history_task_created_at_idx")
+ @@index([operatorUserId, createdAt], map: "follow_up_status_history_operator_created_at_idx")
+ @@map("follow_up_status_history")
+}
+
+model Volunteer {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ userId BigInt @unique @map("user_id") @db.UnsignedBigInt
+ consentId BigInt @unique @map("consent_id") @db.UnsignedBigInt
+ realNameEncrypted String @map("real_name_encrypted") @db.VarChar(512)
+ realNameHash String @map("real_name_hash") @db.Char(64)
+ mobileEncrypted String @map("mobile_encrypted") @db.VarChar(512)
+ mobileHash String @map("mobile_hash") @db.Char(64)
+ regionCode String @map("region_code") @db.VarChar(100)
+ availabilityNoteEncrypted String @map("availability_note_encrypted") @db.Text
+ status VolunteerStatus @default(APPLIED)
+ approvedById BigInt? @map("approved_by") @db.UnsignedBigInt
+ approvedAt DateTime? @map("approved_at") @db.DateTime(3)
+ revision Int @default(1) @db.UnsignedInt
+ createdById BigInt @map("created_by") @db.UnsignedBigInt
+ updatedById BigInt @map("updated_by") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ consent Consent @relation(fields: [consentId], references: [id], onDelete: Restrict)
+ reviewer User? @relation("VolunteerReviewer", fields: [approvedById], references: [id], onDelete: Restrict)
+ creator User @relation("VolunteerCreator", fields: [createdById], references: [id], onDelete: Restrict)
+ updater User @relation("VolunteerUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
+ skills VolunteerSkill[]
+ interests VolunteerInterest[]
+ statusHistory VolunteerStatusHistory[]
+ registrations ActivityRegistration[]
+
+ @@index([status, createdAt], map: "volunteers_status_created_at_idx")
+ @@index([mobileHash], map: "volunteers_mobile_hash_idx")
+ @@index([regionCode, status], map: "volunteers_region_status_idx")
+ @@map("volunteers")
+}
+
+model VolunteerSkill {
+ volunteerId BigInt @map("volunteer_id") @db.UnsignedBigInt
+ skillCode String @map("skill_code") @db.VarChar(64)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ volunteer Volunteer @relation(fields: [volunteerId], references: [id], onDelete: Restrict)
+
+ @@id([volunteerId, skillCode])
+ @@index([skillCode], map: "volunteer_skills_code_idx")
+ @@map("volunteer_skills")
+}
+
+model VolunteerInterest {
+ volunteerId BigInt @map("volunteer_id") @db.UnsignedBigInt
+ category ServiceCategory
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ volunteer Volunteer @relation(fields: [volunteerId], references: [id], onDelete: Restrict)
+
+ @@id([volunteerId, category])
+ @@index([category], map: "volunteer_interests_category_idx")
+ @@map("volunteer_interests")
+}
+
+model VolunteerStatusHistory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ volunteerId BigInt @map("volunteer_id") @db.UnsignedBigInt
+ requestId String @unique @map("request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ fromStatus VolunteerStatus? @map("from_status")
+ toStatus VolunteerStatus @map("to_status")
+ operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
+ reasonCode String @map("reason_code") @db.VarChar(100)
+ noteEncrypted String? @map("note_encrypted") @db.Text
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ volunteer Volunteer @relation(fields: [volunteerId], references: [id], onDelete: Restrict)
+ operator User @relation("VolunteerStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
+
+ @@index([volunteerId, createdAt], map: "volunteer_status_history_volunteer_created_at_idx")
+ @@index([operatorUserId, createdAt], map: "volunteer_status_history_operator_created_at_idx")
+ @@map("volunteer_status_history")
+}
+
+model Project {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ projectNo String @unique @map("project_no") @db.VarChar(32)
+ clientRequestId String @map("client_request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ name String @db.VarChar(200)
+ category ServiceCategory
+ summary String @db.VarChar(500)
+ content String @db.Text
+ coverFileId BigInt? @unique @map("cover_file_id") @db.UnsignedBigInt
+ managerUserId BigInt @map("manager_user_id") @db.UnsignedBigInt
+ startDate DateTime @map("start_date") @db.Date
+ endDate DateTime @map("end_date") @db.Date
+ targetPeople Int? @map("target_people") @db.UnsignedInt
+ status ProjectStatus @default(DRAFT)
+ isPublic Boolean @default(false) @map("is_public")
+ sortOrder Int @default(0) @map("sort_order")
+ revision Int @default(1) @db.UnsignedInt
+ createdById BigInt @map("created_by") @db.UnsignedBigInt
+ updatedById BigInt @map("updated_by") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ coverFile File? @relation("ProjectCover", fields: [coverFileId], references: [id], onDelete: Restrict)
+ manager User @relation("ProjectManager", fields: [managerUserId], references: [id], onDelete: Restrict)
+ creator User @relation("ProjectCreator", fields: [createdById], references: [id], onDelete: Restrict)
+ updater User @relation("ProjectUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
+ statusHistory ProjectStatusHistory[]
+ activities Activity[]
+ serviceCases ServiceCase[]
+
+ @@unique([createdById, clientRequestId], map: "projects_creator_request_key")
+ @@index([status, isPublic, sortOrder, createdAt], map: "projects_public_status_sort_idx")
+ @@index([managerUserId, status, createdAt], map: "projects_manager_status_idx")
+ @@map("projects")
+}
+
+model ProjectStatusHistory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ projectId BigInt @map("project_id") @db.UnsignedBigInt
+ requestId String @unique @map("request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ fromStatus ProjectStatus? @map("from_status")
+ toStatus ProjectStatus @map("to_status")
+ operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
+ reasonCode String @map("reason_code") @db.VarChar(100)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ project Project @relation(fields: [projectId], references: [id], onDelete: Restrict)
+ operator User @relation("ProjectStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
+
+ @@index([projectId, createdAt], map: "project_status_history_project_created_at_idx")
+ @@map("project_status_history")
+}
+
+model Activity {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ activityNo String @unique @map("activity_no") @db.VarChar(32)
+ projectId BigInt? @map("project_id") @db.UnsignedBigInt
+ clientRequestId String @map("client_request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ title String @db.VarChar(200)
+ summary String @db.VarChar(500)
+ content String @db.Text
+ coverFileId BigInt? @unique @map("cover_file_id") @db.UnsignedBigInt
+ startAt DateTime @map("start_at") @db.DateTime(3)
+ endAt DateTime @map("end_at") @db.DateTime(3)
+ registrationDeadline DateTime @map("registration_deadline") @db.DateTime(3)
+ locationText String @map("location_text") @db.VarChar(300)
+ capacity Int? @db.UnsignedInt
+ status ActivityStatus @default(DRAFT)
+ publishedAt DateTime? @map("published_at") @db.DateTime(3)
+ revision Int @default(1) @db.UnsignedInt
+ createdById BigInt @map("created_by") @db.UnsignedBigInt
+ updatedById BigInt @map("updated_by") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ project Project? @relation(fields: [projectId], references: [id], onDelete: Restrict)
+ coverFile File? @relation("ActivityCover", fields: [coverFileId], references: [id], onDelete: Restrict)
+ creator User @relation("ActivityCreator", fields: [createdById], references: [id], onDelete: Restrict)
+ updater User @relation("ActivityUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
+ statusHistory ActivityStatusHistory[]
+ registrations ActivityRegistration[]
+
+ @@unique([createdById, clientRequestId], map: "activities_creator_request_key")
+ @@index([status, startAt], map: "activities_status_start_at_idx")
+ @@index([projectId, startAt], map: "activities_project_start_at_idx")
+ @@map("activities")
+}
+
+model ActivityStatusHistory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ activityId BigInt @map("activity_id") @db.UnsignedBigInt
+ requestId String @unique @map("request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ fromStatus ActivityStatus? @map("from_status")
+ toStatus ActivityStatus @map("to_status")
+ operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
+ reasonCode String @map("reason_code") @db.VarChar(100)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ activity Activity @relation(fields: [activityId], references: [id], onDelete: Restrict)
+ operator User @relation("ActivityStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
+
+ @@index([activityId, createdAt], map: "activity_status_history_activity_created_at_idx")
+ @@map("activity_status_history")
+}
+
+model ActivityRegistration {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ activityId BigInt @map("activity_id") @db.UnsignedBigInt
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ volunteerId BigInt @map("volunteer_id") @db.UnsignedBigInt
+ clientRequestId String @map("client_request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ status ActivityRegistrationStatus @default(PENDING)
+ checkInStatus ActivityCheckInStatus @default(NOT_CHECKED_IN) @map("check_in_status")
+ registeredAt DateTime @default(now()) @map("registered_at") @db.DateTime(3)
+ reviewedById BigInt? @map("reviewed_by") @db.UnsignedBigInt
+ reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
+ reviewNoteEncrypted String? @map("review_note_encrypted") @db.Text
+ checkedInAt DateTime? @map("checked_in_at") @db.DateTime(3)
+ checkedInById BigInt? @map("checked_in_by") @db.UnsignedBigInt
+ serviceMinutes Int? @map("service_minutes") @db.UnsignedInt
+ serviceMinutesRecordedAt DateTime? @map("service_minutes_recorded_at") @db.DateTime(3)
+ serviceMinutesRecordedById BigInt? @map("service_minutes_recorded_by") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ activity Activity @relation(fields: [activityId], references: [id], onDelete: Restrict)
+ user User @relation("ActivityRegistrationUser", fields: [userId], references: [id], onDelete: Restrict)
+ volunteer Volunteer @relation(fields: [volunteerId], references: [id], onDelete: Restrict)
+ reviewer User? @relation("RegistrationReviewer", fields: [reviewedById], references: [id], onDelete: Restrict)
+ checkInOperator User? @relation("RegistrationCheckInOperator", fields: [checkedInById], references: [id], onDelete: Restrict)
+ serviceMinutesOperator User? @relation("RegistrationMinutesOperator", fields: [serviceMinutesRecordedById], references: [id], onDelete: Restrict)
+ history ActivityRegistrationHistory[]
+
+ @@unique([activityId, userId], map: "activity_registrations_activity_user_key")
+ @@unique([userId, clientRequestId], map: "activity_registrations_user_request_key")
+ @@index([activityId, status, registeredAt], map: "activity_registrations_activity_status_idx")
+ @@index([volunteerId, registeredAt], map: "activity_registrations_volunteer_registered_idx")
+ @@map("activity_registrations")
+}
+
+model ActivityRegistrationHistory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ registrationId BigInt @map("registration_id") @db.UnsignedBigInt
+ requestId String @unique @map("request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ fromStatus ActivityRegistrationStatus? @map("from_status")
+ toStatus ActivityRegistrationStatus? @map("to_status")
+ fromCheckInStatus ActivityCheckInStatus? @map("from_check_in_status")
+ toCheckInStatus ActivityCheckInStatus? @map("to_check_in_status")
+ serviceMinutesBefore Int? @map("service_minutes_before") @db.UnsignedInt
+ serviceMinutesAfter Int? @map("service_minutes_after") @db.UnsignedInt
+ operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
+ actionCode String @map("action_code") @db.VarChar(100)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ registration ActivityRegistration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
+ operator User @relation("RegistrationHistoryOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
+
+ @@index([registrationId, createdAt], map: "registration_history_registration_created_at_idx")
+ @@map("activity_registration_history")
+}
+
+model ContentPost {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ clientRequestId String @map("client_request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ type ContentPostType
+ title String @db.VarChar(200)
+ summary String @db.VarChar(500)
+ content String @db.Text
+ coverFileId BigInt? @unique @map("cover_file_id") @db.UnsignedBigInt
+ status ContentPostStatus @default(DRAFT)
+ revision Int @default(1) @db.UnsignedInt
+ privacyReviewedAt DateTime? @map("privacy_reviewed_at") @db.DateTime(3)
+ reviewedById BigInt? @map("reviewed_by") @db.UnsignedBigInt
+ reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
+ publishedAt DateTime? @map("published_at") @db.DateTime(3)
+ createdById BigInt @map("created_by") @db.UnsignedBigInt
+ updatedById BigInt @map("updated_by") @db.UnsignedBigInt
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ coverFile File? @relation("ContentCover", fields: [coverFileId], references: [id], onDelete: Restrict)
+ creator User @relation("ContentCreator", fields: [createdById], references: [id], onDelete: Restrict)
+ updater User @relation("ContentUpdater", fields: [updatedById], references: [id], onDelete: Restrict)
+ reviewer User? @relation("ContentReviewer", fields: [reviewedById], references: [id], onDelete: Restrict)
+ statusHistory ContentPostStatusHistory[]
+
+ @@unique([createdById, clientRequestId], map: "content_posts_creator_request_key")
+ @@index([status, type, publishedAt], map: "content_posts_status_type_published_idx")
+ @@index([createdById, status, updatedAt], map: "content_posts_creator_status_updated_idx")
+ @@map("content_posts")
+}
+
+model ContentPostStatusHistory {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ contentPostId BigInt @map("content_post_id") @db.UnsignedBigInt
+ requestId String @unique @map("request_id") @db.Char(36)
+ requestFingerprint String @map("request_fingerprint") @db.Char(64)
+ fromStatus ContentPostStatus? @map("from_status")
+ toStatus ContentPostStatus @map("to_status")
+ operatorUserId BigInt @map("operator_user_id") @db.UnsignedBigInt
+ reasonCode String @map("reason_code") @db.VarChar(100)
+ noteEncrypted String? @map("note_encrypted") @db.Text
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ contentPost ContentPost @relation(fields: [contentPostId], references: [id], onDelete: Restrict)
+ operator User @relation("ContentStatusOperator", fields: [operatorUserId], references: [id], onDelete: Restrict)
+
+ @@index([contentPostId, createdAt], map: "content_status_history_post_created_idx")
+ @@index([operatorUserId, createdAt], map: "content_status_history_operator_created_idx")
+ @@map("content_post_status_history")
+}
+
+model Notification {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ eventKey String @map("event_key") @db.VarChar(160)
+ type NotificationType
+ title String @db.VarChar(120)
+ body String @db.VarChar(500)
+ resourceType String? @map("resource_type") @db.VarChar(80)
+ resourcePublicId String? @map("resource_public_id") @db.Char(36)
+ readAt DateTime? @map("read_at") @db.DateTime(3)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+ deliveries NotificationDelivery[]
+
+ @@unique([userId, eventKey], map: "notifications_user_event_key")
+ @@index([userId, readAt, createdAt], map: "notifications_user_read_created_idx")
+ @@map("notifications")
+}
+
+model NotificationPreference {
+ userId BigInt @map("user_id") @db.UnsignedBigInt
+ type NotificationType
+ inAppEnabled Boolean @default(true) @map("in_app_enabled")
+ wechatEnabled Boolean @default(false) @map("wechat_enabled")
+ authorizedAt DateTime? @map("authorized_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ user User @relation(fields: [userId], references: [id], onDelete: Restrict)
+
+ @@id([userId, type])
+ @@index([type, wechatEnabled], map: "notification_preferences_type_wechat_idx")
+ @@map("notification_preferences")
+}
+
+model NotificationDelivery {
+ id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
+ publicId String @unique @map("public_id") @db.Char(36)
+ notificationId BigInt @map("notification_id") @db.UnsignedBigInt
+ channel NotificationChannel
+ status NotificationDeliveryStatus @default(PENDING)
+ attemptCount Int @default(0) @map("attempt_count") @db.UnsignedInt
+ nextAttemptAt DateTime @default(now()) @map("next_attempt_at") @db.DateTime(3)
+ lastAttemptAt DateTime? @map("last_attempt_at") @db.DateTime(3)
+ providerMessageId String? @map("provider_message_id") @db.VarChar(160)
+ errorCode String? @map("error_code") @db.VarChar(100)
+ createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
+ notification Notification @relation(fields: [notificationId], references: [id], onDelete: Restrict)
+
+ @@unique([notificationId, channel], map: "notification_deliveries_notification_channel_key")
+ @@index([status, nextAttemptAt], map: "notification_deliveries_status_next_idx")
+ @@map("notification_deliveries")
+}
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
new file mode 100644
index 0000000..24b0cf1
--- /dev/null
+++ b/apps/api/src/app.module.ts
@@ -0,0 +1,62 @@
+import { MiddlewareConsumer, Module, type NestModule } from '@nestjs/common';
+import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
+import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
+
+import { AuditModule } from './audit/audit.module';
+import { AuthModule } from './auth/auth.module';
+import { CaseManagementModule } from './case-management/case-management.module';
+import { AuthenticationGuard } from './auth/authentication.guard';
+import { ApiExceptionFilter } from './common/api-exception.filter';
+import { ApiResponseInterceptor } from './common/api-response.interceptor';
+import { RequestIdMiddleware } from './common/request-id.middleware';
+import { AppConfigModule } from './config/app-config.module';
+import { DatabaseModule } from './database/database.module';
+import { HealthController } from './health/health.controller';
+import { HealthService } from './health/health.service';
+import { FollowUpsModule } from './follow-ups/follow-ups.module';
+import { PermissionGuard } from './permissions/permission.guard';
+import { PermissionsModule } from './permissions/permissions.module';
+import { ParticipationModule } from './participation/participation.module';
+import { SecurityModule } from './security/security.module';
+import { ServiceApplicationsModule } from './service-applications/service-applications.module';
+import { UsersModule } from './users/users.module';
+import { ContentNotificationsModule } from './content-notifications/content-notifications.module';
+import { StatisticsModule } from './statistics/statistics.module';
+import { RequestMetricsService } from './common/request-metrics.service';
+import { RequestMetricsInterceptor } from './common/request-metrics.interceptor';
+import { SystemMetricsController } from './common/system-metrics.controller';
+
+@Module({
+ imports: [
+ AppConfigModule,
+ SecurityModule,
+ DatabaseModule,
+ AuditModule,
+ PermissionsModule,
+ ThrottlerModule.forRoot([{ name: 'default', ttl: 60_000, limit: 100 }]),
+ AuthModule,
+ UsersModule,
+ ServiceApplicationsModule,
+ CaseManagementModule,
+ FollowUpsModule,
+ ParticipationModule,
+ ContentNotificationsModule,
+ StatisticsModule,
+ ],
+ controllers: [HealthController, SystemMetricsController],
+ providers: [
+ HealthService,
+ RequestMetricsService,
+ { provide: APP_GUARD, useClass: ThrottlerGuard },
+ { provide: APP_GUARD, useClass: AuthenticationGuard },
+ { provide: APP_GUARD, useClass: PermissionGuard },
+ { provide: APP_INTERCEPTOR, useClass: ApiResponseInterceptor },
+ { provide: APP_INTERCEPTOR, useClass: RequestMetricsInterceptor },
+ { provide: APP_FILTER, useClass: ApiExceptionFilter },
+ ],
+})
+export class AppModule implements NestModule {
+ configure(consumer: MiddlewareConsumer): void {
+ consumer.apply(RequestIdMiddleware).forRoutes('*');
+ }
+}
diff --git a/apps/api/src/audit/audit.module.ts b/apps/api/src/audit/audit.module.ts
new file mode 100644
index 0000000..1180d10
--- /dev/null
+++ b/apps/api/src/audit/audit.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+
+import { AuditService } from './audit.service';
+
+@Module({
+ providers: [AuditService],
+ exports: [AuditService],
+})
+export class AuditModule {}
diff --git a/apps/api/src/audit/audit.service.spec.ts b/apps/api/src/audit/audit.service.spec.ts
new file mode 100644
index 0000000..67f463f
--- /dev/null
+++ b/apps/api/src/audit/audit.service.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+
+import type { PrismaService } from '../database/prisma.service';
+import type { SensitiveDataService } from '../security/sensitive-data.service';
+import { AuditService } from './audit.service';
+
+describe('AuditService metadata policy', () => {
+ const service = new AuditService({} as PrismaService, {} as SensitiveDataService);
+
+ it('allows non-sensitive action metadata', () => {
+ expect(service.safeMetadata({ reasonCode: 'INVALID_CREDENTIALS', attempt: 2 })).toEqual({
+ reasonCode: 'INVALID_CREDENTIALS',
+ attempt: 2,
+ });
+ });
+
+ it('rejects sensitive metadata keys', () => {
+ expect(() => service.safeMetadata({ password: 'never-store-this' })).toThrow(/not allowed/);
+ expect(() => service.safeMetadata({ openid: 'never-store-this' })).toThrow(/not allowed/);
+ });
+});
diff --git a/apps/api/src/audit/audit.service.ts b/apps/api/src/audit/audit.service.ts
new file mode 100644
index 0000000..df2b44c
--- /dev/null
+++ b/apps/api/src/audit/audit.service.ts
@@ -0,0 +1,67 @@
+import { Inject, Injectable } from '@nestjs/common';
+
+import type { Prisma } from '../generated/prisma/client';
+import type { AuditResult } from '../generated/prisma/enums';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { PrismaService } from '../database/prisma.service';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+
+const SENSITIVE_METADATA_KEY =
+ /(password|token|secret|openid|unionid|mobile|phone|address|description|content)/i;
+
+export interface AuditEvent {
+ action: string;
+ result: AuditResult;
+ actor?: AuthContext;
+ actorUserId?: bigint;
+ actorRole?: string;
+ resourceType?: string;
+ resourcePublicId?: string;
+ metadata?: Record;
+}
+
+@Injectable()
+export class AuditService {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
+ ) {}
+
+ async record(
+ request: RequestContext,
+ event: AuditEvent,
+ client: Prisma.TransactionClient | PrismaService = this.prisma,
+ ): Promise {
+ const metadata = this.safeMetadata(event.metadata);
+ const userAgent =
+ request
+ .header('user-agent')
+ ?.replace(/[\r\n\t]/g, ' ')
+ .slice(0, 255) || null;
+ await client.auditLog.create({
+ data: {
+ requestId: request.requestId,
+ actorUserId: event.actor?.userId ?? event.actorUserId ?? null,
+ actorRole: event.actor?.roles[0] ?? event.actorRole ?? null,
+ action: event.action,
+ resourceType: event.resourceType ?? null,
+ resourcePublicId: event.resourcePublicId ?? null,
+ result: event.result,
+ metadataJson: metadata ?? undefined,
+ ipHash: request.ip ? this.sensitiveData.auditIpHash(request.ip) : null,
+ userAgentSummary: userAgent,
+ },
+ });
+ }
+
+ safeMetadata(metadata: AuditEvent['metadata']): Prisma.InputJsonObject | undefined {
+ if (!metadata) return undefined;
+ const entries = Object.entries(metadata).map(([key, value]) => {
+ if (SENSITIVE_METADATA_KEY.test(key)) {
+ throw new Error(`Sensitive audit metadata key is not allowed: ${key}`);
+ }
+ return [key, value] as const;
+ });
+ return Object.fromEntries(entries);
+ }
+}
diff --git a/apps/api/src/auth/admin-auth.controller.ts b/apps/api/src/auth/admin-auth.controller.ts
new file mode 100644
index 0000000..2e46302
--- /dev/null
+++ b/apps/api/src/auth/admin-auth.controller.ts
@@ -0,0 +1,77 @@
+import { Body, Controller, Get, Inject, Post, Req, Res, UseGuards } from '@nestjs/common';
+import { Throttle } from '@nestjs/throttler';
+import type { Response } from 'express';
+
+import {
+ PERMISSION_CODES,
+ type AdminLoginResponse,
+ type CurrentUserResponse,
+ type LogoutResponse,
+} from '@lehuo/contracts';
+
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { Public } from '../common/decorators/public.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { AppConfigService } from '../config/app-config.service';
+import { SessionChannel } from '../generated/prisma/enums';
+import { AdminAuthService } from './admin-auth.service';
+import { AdminOriginGuard } from './admin-origin.guard';
+import { RequireSessionChannel } from './decorators/require-session-channel.decorator';
+import { AdminLoginDto } from './dto/admin-login.dto';
+import { ADMIN_SESSION_COOKIE } from './session.constants';
+import { SessionService } from './session.service';
+
+@Controller('api/v1/admin/auth')
+export class AdminAuthController {
+ constructor(
+ @Inject(AdminAuthService) private readonly adminAuth: AdminAuthService,
+ @Inject(SessionService) private readonly sessions: SessionService,
+ @Inject(AppConfigService) private readonly config: AppConfigService,
+ ) {}
+
+ @Public()
+ @UseGuards(AdminOriginGuard)
+ @Throttle({ default: { limit: 10, ttl: 60_000 } })
+ @Post('login')
+ async login(
+ @Body() input: AdminLoginDto,
+ @Req() request: RequestContext,
+ @Res({ passthrough: true }) response: Response,
+ ): Promise {
+ const result = await this.adminAuth.login(input, request);
+ response.cookie(ADMIN_SESSION_COOKIE, result.token, {
+ httpOnly: true,
+ secure: this.config.adminCookieSecure,
+ sameSite: 'strict',
+ path: '/api/v1',
+ maxAge: this.config.adminSessionTtlSeconds * 1_000,
+ });
+ return result.response;
+ }
+
+ @RequireSessionChannel(SessionChannel.ADMIN)
+ @RequirePermissions(PERMISSION_CODES.ADMIN_ACCESS)
+ @Get('me')
+ getCurrentAdmin(@CurrentAuth() auth: AuthContext): CurrentUserResponse {
+ return { user: this.sessions.toPublicUser(auth) };
+ }
+
+ @RequireSessionChannel(SessionChannel.ADMIN)
+ @UseGuards(AdminOriginGuard)
+ @Post('logout')
+ async logout(
+ @CurrentAuth() auth: AuthContext,
+ @Req() request: RequestContext,
+ @Res({ passthrough: true }) response: Response,
+ ): Promise {
+ await this.adminAuth.logout(auth, request);
+ response.clearCookie(ADMIN_SESSION_COOKIE, {
+ httpOnly: true,
+ secure: this.config.adminCookieSecure,
+ sameSite: 'strict',
+ path: '/api/v1',
+ });
+ return { loggedOut: true };
+ }
+}
diff --git a/apps/api/src/auth/admin-auth.service.ts b/apps/api/src/auth/admin-auth.service.ts
new file mode 100644
index 0000000..eeeca59
--- /dev/null
+++ b/apps/api/src/auth/admin-auth.service.ts
@@ -0,0 +1,172 @@
+import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+
+import { API_ERROR_CODES, type AdminLoginResponse } from '@lehuo/contracts';
+
+import { AuditService } from '../audit/audit.service';
+import { ApplicationError } from '../common/application-error';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { PrismaService } from '../database/prisma.service';
+import { SessionChannel } from '../generated/prisma/enums';
+import type { AdminLoginDto } from './dto/admin-login.dto';
+import { PasswordService } from './password.service';
+import { SessionService } from './session.service';
+
+const MAX_FAILED_LOGINS = 5;
+const LOCK_DURATION_MS = 15 * 60 * 1_000;
+
+@Injectable()
+export class AdminAuthService {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(PasswordService) private readonly passwords: PasswordService,
+ @Inject(SessionService) private readonly sessions: SessionService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ ) {}
+
+ async login(
+ input: AdminLoginDto,
+ request: RequestContext,
+ ): Promise<{ token: string; response: AdminLoginResponse }> {
+ const credential = await this.prisma.adminCredential.findUnique({
+ where: { username: input.username },
+ include: { user: true },
+ });
+ const passwordValid = await this.passwords.verifyOrDummy(
+ credential?.passwordHash ?? null,
+ input.password,
+ );
+ if (!credential || !passwordValid) {
+ await this.recordFailedLogin(
+ credential?.id,
+ credential?.userId,
+ request,
+ 'INVALID_CREDENTIALS',
+ );
+ throw new ApplicationError(
+ API_ERROR_CODES.INVALID_CREDENTIALS,
+ '用户名或密码不正确',
+ HttpStatus.UNAUTHORIZED,
+ );
+ }
+ if (credential.lockedUntil && credential.lockedUntil.getTime() > Date.now()) {
+ await this.audit.record(request, {
+ action: 'AUTH_ADMIN_LOGIN',
+ result: 'FAILURE',
+ actorUserId: credential.userId,
+ resourceType: 'USER',
+ resourcePublicId: credential.user.publicId,
+ metadata: { reasonCode: 'ACCOUNT_LOCKED' },
+ });
+ throw new ApplicationError(
+ API_ERROR_CODES.ACCOUNT_LOCKED,
+ '账户暂时锁定,请稍后再试',
+ HttpStatus.TOO_MANY_REQUESTS,
+ );
+ }
+ if (credential.user.status !== 'ACTIVE') {
+ await this.audit.record(request, {
+ action: 'AUTH_ADMIN_LOGIN',
+ result: 'FAILURE',
+ actorUserId: credential.userId,
+ resourceType: 'USER',
+ resourcePublicId: credential.user.publicId,
+ metadata: { reasonCode: 'ACCOUNT_DISABLED' },
+ });
+ throw new ApplicationError(
+ API_ERROR_CODES.ACCOUNT_DISABLED,
+ '账户当前不可用,请联系管理员',
+ HttpStatus.FORBIDDEN,
+ );
+ }
+ const session = await this.prisma.$transaction(async (transaction) => {
+ await transaction.adminCredential.update({
+ where: { id: credential.id },
+ data: { failedLoginCount: 0, lockedUntil: null },
+ });
+ await transaction.user.update({
+ where: { id: credential.userId },
+ data: { lastLoginAt: new Date() },
+ });
+ const created = await this.sessions.createRotatedSession(
+ transaction,
+ credential.userId,
+ SessionChannel.ADMIN,
+ );
+ await this.audit.record(
+ request,
+ {
+ action: 'AUTH_ADMIN_LOGIN',
+ result: 'SUCCESS',
+ actorUserId: credential.userId,
+ resourceType: 'USER',
+ resourcePublicId: credential.user.publicId,
+ },
+ transaction,
+ );
+ return created;
+ });
+ const auth = await this.sessions.authenticate(session.token);
+ return {
+ token: session.token,
+ response: {
+ user: this.sessions.toPublicUser(auth),
+ expiresAt: session.expiresAt.toISOString(),
+ },
+ };
+ }
+
+ async logout(auth: AuthContext, request: RequestContext): Promise {
+ await this.prisma.$transaction(async (transaction) => {
+ await transaction.authSession.updateMany({
+ where: { id: auth.sessionId, revokedAt: null },
+ data: { revokedAt: new Date() },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'AUTH_ADMIN_LOGOUT',
+ result: 'SUCCESS',
+ actor: auth,
+ resourceType: 'USER',
+ resourcePublicId: auth.userPublicId,
+ },
+ transaction,
+ );
+ });
+ }
+
+ private async recordFailedLogin(
+ credentialId: bigint | undefined,
+ userId: bigint | undefined,
+ request: RequestContext,
+ reasonCode: string,
+ ): Promise {
+ await this.prisma.$transaction(async (transaction) => {
+ if (credentialId) {
+ const current = await transaction.adminCredential.findUniqueOrThrow({
+ where: { id: credentialId },
+ });
+ const failures = current.failedLoginCount + 1;
+ await transaction.adminCredential.update({
+ where: { id: credentialId },
+ data: {
+ failedLoginCount: failures,
+ lockedUntil:
+ failures >= MAX_FAILED_LOGINS ? new Date(Date.now() + LOCK_DURATION_MS) : null,
+ },
+ });
+ }
+ await this.audit.record(
+ request,
+ {
+ action: 'AUTH_ADMIN_LOGIN',
+ result: 'FAILURE',
+ ...(userId ? { actorUserId: userId } : {}),
+ resourceType: 'USER',
+ metadata: { reasonCode },
+ },
+ transaction,
+ );
+ });
+ }
+}
diff --git a/apps/api/src/auth/admin-origin.guard.ts b/apps/api/src/auth/admin-origin.guard.ts
new file mode 100644
index 0000000..f1e6640
--- /dev/null
+++ b/apps/api/src/auth/admin-origin.guard.ts
@@ -0,0 +1,21 @@
+import {
+ ForbiddenException,
+ Inject,
+ Injectable,
+ type CanActivate,
+ type ExecutionContext,
+} from '@nestjs/common';
+
+import type { RequestContext } from '../common/request-context';
+import { AppConfigService } from '../config/app-config.service';
+
+@Injectable()
+export class AdminOriginGuard implements CanActivate {
+ constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {}
+
+ canActivate(context: ExecutionContext): boolean {
+ const request = context.switchToHttp().getRequest();
+ if (request.header('origin') !== this.config.adminWebOrigin) throw new ForbiddenException();
+ return true;
+ }
+}
diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts
new file mode 100644
index 0000000..166f958
--- /dev/null
+++ b/apps/api/src/auth/auth.controller.ts
@@ -0,0 +1,66 @@
+import { Body, Controller, Get, Inject, Post, Req } from '@nestjs/common';
+import { Throttle } from '@nestjs/throttler';
+
+import type { CurrentUserResponse, LogoutResponse, WechatLoginResponse } from '@lehuo/contracts';
+
+import { AuditService } from '../audit/audit.service';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { Public } from '../common/decorators/public.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { PrismaService } from '../database/prisma.service';
+import { SessionChannel } from '../generated/prisma/enums';
+import { RequireSessionChannel } from './decorators/require-session-channel.decorator';
+import { WechatLoginDto } from './dto/wechat-login.dto';
+import { SessionService } from './session.service';
+import { WechatAuthService } from './wechat-auth.service';
+
+@Controller('api/v1')
+export class AuthController {
+ constructor(
+ @Inject(WechatAuthService) private readonly wechatAuth: WechatAuthService,
+ @Inject(SessionService) private readonly sessions: SessionService,
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ ) {}
+
+ @Public()
+ @Throttle({ default: { limit: 10, ttl: 60_000 } })
+ @Post('auth/wechat/login')
+ loginWithWechat(
+ @Body() input: WechatLoginDto,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.wechatAuth.login(input, request);
+ }
+
+ @Get('me')
+ getCurrentUser(@CurrentAuth() auth: AuthContext): CurrentUserResponse {
+ return { user: this.sessions.toPublicUser(auth) };
+ }
+
+ @RequireSessionChannel(SessionChannel.WECHAT)
+ @Post('auth/logout')
+ async logout(
+ @CurrentAuth() auth: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ await this.prisma.$transaction(async (transaction) => {
+ await transaction.authSession.updateMany({
+ where: { id: auth.sessionId, revokedAt: null },
+ data: { revokedAt: new Date() },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'AUTH_WECHAT_LOGOUT',
+ result: 'SUCCESS',
+ actor: auth,
+ resourceType: 'USER',
+ resourcePublicId: auth.userPublicId,
+ },
+ transaction,
+ );
+ });
+ return { loggedOut: true };
+ }
+}
diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts
new file mode 100644
index 0000000..353ed12
--- /dev/null
+++ b/apps/api/src/auth/auth.module.ts
@@ -0,0 +1,28 @@
+import { Module } from '@nestjs/common';
+
+import { AuditModule } from '../audit/audit.module';
+import { AdminAuthController } from './admin-auth.controller';
+import { AdminAuthService } from './admin-auth.service';
+import { AdminOriginGuard } from './admin-origin.guard';
+import { AuthController } from './auth.controller';
+import { PasswordService } from './password.service';
+import { SessionService } from './session.service';
+import { WECHAT_AUTH_ADAPTER } from './wechat-auth.adapter';
+import { WechatAuthService } from './wechat-auth.service';
+import { WechatHttpAdapter } from './wechat-http.adapter';
+
+@Module({
+ imports: [AuditModule],
+ controllers: [AuthController, AdminAuthController],
+ providers: [
+ SessionService,
+ PasswordService,
+ WechatAuthService,
+ AdminAuthService,
+ AdminOriginGuard,
+ WechatHttpAdapter,
+ { provide: WECHAT_AUTH_ADAPTER, useExisting: WechatHttpAdapter },
+ ],
+ exports: [SessionService, PasswordService, AdminOriginGuard],
+})
+export class AuthModule {}
diff --git a/apps/api/src/auth/authentication.guard.ts b/apps/api/src/auth/authentication.guard.ts
new file mode 100644
index 0000000..61406e2
--- /dev/null
+++ b/apps/api/src/auth/authentication.guard.ts
@@ -0,0 +1,55 @@
+import {
+ Inject,
+ Injectable,
+ UnauthorizedException,
+ type CanActivate,
+ type ExecutionContext,
+} from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+
+import type { SessionChannel } from '../generated/prisma/enums';
+import { IS_PUBLIC_KEY } from '../common/decorators/public.decorator';
+import type { RequestContext } from '../common/request-context';
+import { REQUIRED_SESSION_CHANNEL_KEY } from './decorators/require-session-channel.decorator';
+import { ADMIN_SESSION_COOKIE } from './session.constants';
+import { SessionService } from './session.service';
+
+@Injectable()
+export class AuthenticationGuard implements CanActivate {
+ constructor(
+ @Inject(Reflector) private readonly reflector: Reflector,
+ @Inject(SessionService) private readonly sessions: SessionService,
+ ) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+ if (isPublic) return true;
+ const request = context.switchToHttp().getRequest();
+ const token = this.extractBearerToken(request) ?? this.extractAdminCookie(request);
+ if (!token) throw new UnauthorizedException();
+ const auth = await this.sessions.authenticate(token);
+ const requiredChannel = this.reflector.getAllAndOverride(
+ REQUIRED_SESSION_CHANNEL_KEY,
+ [context.getHandler(), context.getClass()],
+ );
+ if (requiredChannel && auth.channel !== requiredChannel) throw new UnauthorizedException();
+ request.auth = auth;
+ return true;
+ }
+
+ private extractBearerToken(request: RequestContext): string | null {
+ const authorization = request.header('authorization');
+ if (!authorization) return null;
+ const match = /^Bearer ([A-Za-z0-9_-]{32,128})$/.exec(authorization);
+ return match?.[1] ?? null;
+ }
+
+ private extractAdminCookie(request: RequestContext): string | null {
+ const cookies = request.cookies as Record | undefined;
+ const value = cookies?.[ADMIN_SESSION_COOKIE];
+ return typeof value === 'string' ? value : null;
+ }
+}
diff --git a/apps/api/src/auth/decorators/require-session-channel.decorator.ts b/apps/api/src/auth/decorators/require-session-channel.decorator.ts
new file mode 100644
index 0000000..8283d1d
--- /dev/null
+++ b/apps/api/src/auth/decorators/require-session-channel.decorator.ts
@@ -0,0 +1,7 @@
+import { SetMetadata } from '@nestjs/common';
+
+import type { SessionChannel } from '../../generated/prisma/enums';
+
+export const REQUIRED_SESSION_CHANNEL_KEY = 'requiredSessionChannel';
+export const RequireSessionChannel = (channel: SessionChannel): MethodDecorator & ClassDecorator =>
+ SetMetadata(REQUIRED_SESSION_CHANNEL_KEY, channel);
diff --git a/apps/api/src/auth/dto/admin-login.dto.ts b/apps/api/src/auth/dto/admin-login.dto.ts
new file mode 100644
index 0000000..53598cb
--- /dev/null
+++ b/apps/api/src/auth/dto/admin-login.dto.ts
@@ -0,0 +1,18 @@
+import { Transform } from 'class-transformer';
+import { IsString, Length, Matches } from 'class-validator';
+
+import type { AdminLoginRequest } from '@lehuo/contracts';
+
+export class AdminLoginDto implements AdminLoginRequest {
+ @Transform(({ value }: { value: unknown }) =>
+ typeof value === 'string' ? value.trim().toLowerCase() : value,
+ )
+ @IsString()
+ @Matches(/^[a-z0-9._-]+$/)
+ @Length(3, 64)
+ username!: string;
+
+ @IsString()
+ @Length(12, 128)
+ password!: string;
+}
diff --git a/apps/api/src/auth/dto/wechat-login.dto.ts b/apps/api/src/auth/dto/wechat-login.dto.ts
new file mode 100644
index 0000000..ce31d1f
--- /dev/null
+++ b/apps/api/src/auth/dto/wechat-login.dto.ts
@@ -0,0 +1,9 @@
+import { IsString, Matches } from 'class-validator';
+
+import type { WechatLoginRequest } from '@lehuo/contracts';
+
+export class WechatLoginDto implements WechatLoginRequest {
+ @IsString()
+ @Matches(/^[A-Za-z0-9_-]{1,128}$/)
+ code!: string;
+}
diff --git a/apps/api/src/auth/password.service.spec.ts b/apps/api/src/auth/password.service.spec.ts
new file mode 100644
index 0000000..fed410f
--- /dev/null
+++ b/apps/api/src/auth/password.service.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+
+import { PasswordService } from './password.service';
+
+describe('PasswordService', () => {
+ it('hashes and verifies passwords with Argon2id', async () => {
+ const service = new PasswordService();
+ const passwordHash = await service.hash('correct horse battery staple');
+
+ await expect(service.verifyOrDummy(passwordHash, 'correct horse battery staple')).resolves.toBe(
+ true,
+ );
+ await expect(service.verifyOrDummy(passwordHash, 'incorrect password')).resolves.toBe(false);
+ });
+
+ it('performs a dummy verification for unknown accounts', async () => {
+ await expect(
+ new PasswordService().verifyOrDummy(null, 'unknown account password'),
+ ).resolves.toBe(false);
+ });
+});
diff --git a/apps/api/src/auth/password.service.ts b/apps/api/src/auth/password.service.ts
new file mode 100644
index 0000000..8c6e598
--- /dev/null
+++ b/apps/api/src/auth/password.service.ts
@@ -0,0 +1,24 @@
+import { Injectable } from '@nestjs/common';
+import { hash, verify, argon2id } from 'argon2';
+
+const ARGON_OPTIONS = {
+ type: argon2id,
+ memoryCost: 19_456,
+ timeCost: 2,
+ parallelism: 1,
+} as const;
+
+@Injectable()
+export class PasswordService {
+ private readonly dummyHash = hash('not-a-real-account-password', ARGON_OPTIONS);
+
+ hash(password: string): Promise {
+ return hash(password, ARGON_OPTIONS);
+ }
+
+ async verifyOrDummy(passwordHash: string | null, password: string): Promise {
+ const selectedHash = passwordHash ?? (await this.dummyHash);
+ const valid = await verify(selectedHash, password);
+ return passwordHash !== null && valid;
+ }
+}
diff --git a/apps/api/src/auth/session.constants.ts b/apps/api/src/auth/session.constants.ts
new file mode 100644
index 0000000..26964f6
--- /dev/null
+++ b/apps/api/src/auth/session.constants.ts
@@ -0,0 +1 @@
+export const ADMIN_SESSION_COOKIE = 'lehuo_admin_session';
diff --git a/apps/api/src/auth/session.service.ts b/apps/api/src/auth/session.service.ts
new file mode 100644
index 0000000..24094e7
--- /dev/null
+++ b/apps/api/src/auth/session.service.ts
@@ -0,0 +1,150 @@
+import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
+import { randomBytes, randomUUID } from 'node:crypto';
+
+import {
+ PERMISSION_CODES,
+ ROLE_CODES,
+ USER_STATUSES,
+ USER_TYPES,
+ type AuthUser,
+ type PermissionCode,
+ type RoleCode,
+ type UserStatus,
+ type UserType,
+} from '@lehuo/contracts';
+
+import type { Prisma } from '../generated/prisma/client';
+import { SessionChannel } from '../generated/prisma/enums';
+import type { AuthContext } from '../common/request-context';
+import { AppConfigService } from '../config/app-config.service';
+import { PrismaService } from '../database/prisma.service';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+
+const PERMISSIONS = Object.values(PERMISSION_CODES) as string[];
+
+function isRoleCode(value: string): value is RoleCode {
+ return (ROLE_CODES as readonly string[]).includes(value);
+}
+
+function isPermissionCode(value: string): value is PermissionCode {
+ return PERMISSIONS.includes(value);
+}
+
+function isUserStatus(value: string): value is UserStatus {
+ return (USER_STATUSES as readonly string[]).includes(value);
+}
+
+function isUserType(value: string): value is UserType {
+ return (USER_TYPES as readonly string[]).includes(value);
+}
+
+@Injectable()
+export class SessionService {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(AppConfigService) private readonly config: AppConfigService,
+ @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
+ ) {}
+
+ async createRotatedSession(
+ client: Prisma.TransactionClient,
+ userId: bigint,
+ channel: SessionChannel,
+ ): Promise<{ token: string; expiresAt: Date }> {
+ const token = randomBytes(32).toString('base64url');
+ const ttlSeconds =
+ channel === SessionChannel.ADMIN
+ ? this.config.adminSessionTtlSeconds
+ : this.config.wechatSessionTtlSeconds;
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1_000);
+ await client.authSession.updateMany({
+ where: { userId, channel, revokedAt: null },
+ data: { revokedAt: new Date() },
+ });
+ await client.authSession.create({
+ data: {
+ publicId: randomUUID(),
+ userId,
+ channel,
+ tokenHash: this.sensitiveData.sessionTokenHash(token),
+ expiresAt,
+ },
+ });
+ return { token, expiresAt };
+ }
+
+ async authenticate(token: string): Promise {
+ const session = await this.prisma.authSession.findUnique({
+ where: { tokenHash: this.sensitiveData.sessionTokenHash(token) },
+ include: {
+ user: {
+ include: {
+ staffProfile: true,
+ userRoles: {
+ include: {
+ role: {
+ include: {
+ rolePermissions: { include: { permission: true } },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+ if (
+ !session ||
+ session.revokedAt ||
+ session.expiresAt.getTime() <= Date.now() ||
+ session.user.status !== 'ACTIVE'
+ ) {
+ throw new UnauthorizedException();
+ }
+ const roles = session.user.userRoles.map(({ role }) => role.code).filter(isRoleCode);
+ const permissions = [
+ ...new Set(
+ session.user.userRoles.flatMap(({ role }) =>
+ role.rolePermissions.map(({ permission }) => permission.code),
+ ),
+ ),
+ ].filter(isPermissionCode);
+ if (!isUserStatus(session.user.status) || !isUserType(session.user.userType)) {
+ throw new UnauthorizedException();
+ }
+ await this.prisma.authSession.update({
+ where: { id: session.id },
+ data: { lastUsedAt: new Date() },
+ });
+ return {
+ userId: session.user.id,
+ userPublicId: session.user.publicId,
+ status: session.user.status,
+ userType: session.user.userType,
+ displayName: session.user.staffProfile?.displayName ?? session.user.nickname,
+ roles,
+ permissions,
+ sessionId: session.id,
+ channel: session.channel,
+ rawToken: token,
+ };
+ }
+
+ async revoke(auth: AuthContext): Promise {
+ await this.prisma.authSession.updateMany({
+ where: { id: auth.sessionId, revokedAt: null },
+ data: { revokedAt: new Date() },
+ });
+ }
+
+ toPublicUser(auth: AuthContext): AuthUser {
+ return {
+ publicId: auth.userPublicId,
+ status: auth.status,
+ userType: auth.userType,
+ displayName: auth.displayName,
+ roles: auth.roles,
+ permissions: auth.permissions,
+ };
+ }
+}
diff --git a/apps/api/src/auth/wechat-auth.adapter.ts b/apps/api/src/auth/wechat-auth.adapter.ts
new file mode 100644
index 0000000..1c33bcf
--- /dev/null
+++ b/apps/api/src/auth/wechat-auth.adapter.ts
@@ -0,0 +1,10 @@
+export const WECHAT_AUTH_ADAPTER = Symbol('WECHAT_AUTH_ADAPTER');
+
+export interface WechatIdentityResult {
+ openid: string;
+ unionid?: string;
+}
+
+export interface WechatAuthAdapter {
+ exchangeCode(code: string): Promise;
+}
diff --git a/apps/api/src/auth/wechat-auth.service.ts b/apps/api/src/auth/wechat-auth.service.ts
new file mode 100644
index 0000000..4277d95
--- /dev/null
+++ b/apps/api/src/auth/wechat-auth.service.ts
@@ -0,0 +1,126 @@
+import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
+
+import { API_ERROR_CODES, type WechatLoginResponse } from '@lehuo/contracts';
+
+import { AuditService } from '../audit/audit.service';
+import { ApplicationError } from '../common/application-error';
+import type { RequestContext } from '../common/request-context';
+import { AppConfigService } from '../config/app-config.service';
+import { PrismaService } from '../database/prisma.service';
+import { SessionChannel } from '../generated/prisma/enums';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+import type { WechatLoginDto } from './dto/wechat-login.dto';
+import { SessionService } from './session.service';
+import { WECHAT_AUTH_ADAPTER, type WechatAuthAdapter } from './wechat-auth.adapter';
+
+@Injectable()
+export class WechatAuthService {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(AppConfigService) private readonly config: AppConfigService,
+ @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
+ @Inject(SessionService) private readonly sessions: SessionService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ @Inject(WECHAT_AUTH_ADAPTER) private readonly adapter: WechatAuthAdapter,
+ ) {}
+
+ async login(input: WechatLoginDto, request: RequestContext): Promise {
+ let identityResult;
+ try {
+ identityResult = await this.adapter.exchangeCode(input.code);
+ } catch (error) {
+ await this.audit.record(request, {
+ action: 'AUTH_WECHAT_LOGIN',
+ result: 'FAILURE',
+ resourceType: 'USER',
+ metadata: { reasonCode: 'WECHAT_EXCHANGE_FAILED' },
+ });
+ throw error;
+ }
+ if (!this.config.wechatAppId) {
+ throw new ApplicationError(
+ API_ERROR_CODES.WECHAT_LOGIN_FAILED,
+ '微信登录暂时不可用,请稍后重试',
+ HttpStatus.SERVICE_UNAVAILABLE,
+ );
+ }
+ const openidHash = this.sensitiveData.lookupHash('wechat-openid', identityResult.openid);
+ const unionidHash = identityResult.unionid
+ ? this.sensitiveData.lookupHash('wechat-unionid', identityResult.unionid)
+ : null;
+ const result = await this.prisma.$transaction(async (transaction) => {
+ let identity = await transaction.wechatIdentity.findFirst({
+ where: {
+ OR: [
+ { appid: this.config.wechatAppId!, openidHash },
+ ...(unionidHash ? [{ unionidHash }] : []),
+ ],
+ },
+ include: { user: true },
+ });
+ if (!identity) {
+ const user = await transaction.user.create({
+ data: { publicId: randomUUID(), userType: 'PUBLIC' },
+ });
+ identity = await transaction.wechatIdentity.create({
+ data: {
+ userId: user.id,
+ appid: this.config.wechatAppId!,
+ openidHash,
+ openidCiphertext: this.sensitiveData.encrypt(identityResult.openid),
+ unionidHash,
+ unionidCiphertext: identityResult.unionid
+ ? this.sensitiveData.encrypt(identityResult.unionid)
+ : null,
+ },
+ include: { user: true },
+ });
+ } else if (identityResult.unionid && !identity.unionidHash) {
+ identity = await transaction.wechatIdentity.update({
+ where: { id: identity.id },
+ data: {
+ unionidHash,
+ unionidCiphertext: this.sensitiveData.encrypt(identityResult.unionid),
+ },
+ include: { user: true },
+ });
+ }
+ if (identity.user.status !== 'ACTIVE') {
+ throw new ApplicationError(
+ API_ERROR_CODES.ACCOUNT_DISABLED,
+ '账户当前不可用,请联系客服',
+ HttpStatus.FORBIDDEN,
+ );
+ }
+ await transaction.user.update({
+ where: { id: identity.userId },
+ data: { lastLoginAt: new Date() },
+ });
+ const session = await this.sessions.createRotatedSession(
+ transaction,
+ identity.userId,
+ SessionChannel.WECHAT,
+ );
+ await this.audit.record(
+ request,
+ {
+ action: 'AUTH_WECHAT_LOGIN',
+ result: 'SUCCESS',
+ actorUserId: identity.userId,
+ resourceType: 'USER',
+ resourcePublicId: identity.user.publicId,
+ },
+ transaction,
+ );
+ return session;
+ });
+ const auth = await this.sessions.authenticate(result.token);
+ return {
+ accessToken: result.token,
+ tokenType: 'Bearer',
+ expiresAt: result.expiresAt.toISOString(),
+ user: this.sessions.toPublicUser(auth),
+ };
+ }
+}
diff --git a/apps/api/src/auth/wechat-http.adapter.ts b/apps/api/src/auth/wechat-http.adapter.ts
new file mode 100644
index 0000000..5bf98e6
--- /dev/null
+++ b/apps/api/src/auth/wechat-http.adapter.ts
@@ -0,0 +1,51 @@
+import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+
+import { API_ERROR_CODES } from '@lehuo/contracts';
+
+import { ApplicationError } from '../common/application-error';
+import { AppConfigService } from '../config/app-config.service';
+import type { WechatAuthAdapter, WechatIdentityResult } from './wechat-auth.adapter';
+
+interface WechatCodeResponse {
+ openid?: string;
+ unionid?: string;
+ errcode?: number;
+}
+
+@Injectable()
+export class WechatHttpAdapter implements WechatAuthAdapter {
+ constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {}
+
+ async exchangeCode(code: string): Promise {
+ if (!this.config.wechatAppId || !this.config.wechatAppSecret) {
+ throw new ApplicationError(
+ API_ERROR_CODES.WECHAT_LOGIN_FAILED,
+ '微信登录暂时不可用,请稍后重试',
+ HttpStatus.SERVICE_UNAVAILABLE,
+ );
+ }
+ const endpoint = new URL('https://api.weixin.qq.com/sns/jscode2session');
+ endpoint.search = new URLSearchParams({
+ appid: this.config.wechatAppId,
+ secret: this.config.wechatAppSecret,
+ js_code: code,
+ grant_type: 'authorization_code',
+ }).toString();
+ try {
+ const response = await fetch(endpoint, { signal: AbortSignal.timeout(5_000) });
+ if (!response.ok) throw new Error('wechat-http-status');
+ const payload = (await response.json()) as WechatCodeResponse;
+ if (payload.errcode || !payload.openid) throw new Error('wechat-code-rejected');
+ return {
+ openid: payload.openid,
+ ...(payload.unionid ? { unionid: payload.unionid } : {}),
+ };
+ } catch {
+ throw new ApplicationError(
+ API_ERROR_CODES.WECHAT_LOGIN_FAILED,
+ '微信登录失败,请稍后重试',
+ HttpStatus.BAD_GATEWAY,
+ );
+ }
+ }
+}
diff --git a/apps/api/src/case-management/admin-application-workflow.controller.ts b/apps/api/src/case-management/admin-application-workflow.controller.ts
new file mode 100644
index 0000000..4e1a7b8
--- /dev/null
+++ b/apps/api/src/case-management/admin-application-workflow.controller.ts
@@ -0,0 +1,57 @@
+import { Body, Controller, Inject, Param, Post, Req, ValidationPipe } from '@nestjs/common';
+
+import { PERMISSION_CODES, type AcceptServiceApplicationResponse } from '@lehuo/contracts';
+
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SessionChannel } from '../generated/prisma/enums';
+import { CaseManagementService } from './case-management.service';
+import {
+ AcceptServiceApplicationDto,
+ TransitionServiceApplicationDto,
+} from './dto/case-management.dto';
+
+@Controller('api/v1/admin/service-applications')
+@RequireSessionChannel(SessionChannel.ADMIN)
+@RequirePermissions(PERMISSION_CODES.APPLICATIONS_MANAGE)
+export class AdminApplicationWorkflowController {
+ constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {}
+
+ @Post(':publicId/transition')
+ transition(
+ @Param('publicId') publicId: string,
+ @Body(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: TransitionServiceApplicationDto,
+ }),
+ )
+ input: TransitionServiceApplicationDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise<{ status: string }> {
+ return this.cases.transitionApplication(publicId, input, actor, request);
+ }
+
+ @Post(':publicId/accept')
+ accept(
+ @Param('publicId') publicId: string,
+ @Body(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: AcceptServiceApplicationDto,
+ }),
+ )
+ input: AcceptServiceApplicationDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.acceptApplication(publicId, input, actor, request);
+ }
+}
diff --git a/apps/api/src/case-management/admin-beneficiaries.controller.ts b/apps/api/src/case-management/admin-beneficiaries.controller.ts
new file mode 100644
index 0000000..01aff5e
--- /dev/null
+++ b/apps/api/src/case-management/admin-beneficiaries.controller.ts
@@ -0,0 +1,81 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Inject,
+ Param,
+ Put,
+ Query,
+ Req,
+ ValidationPipe,
+} from '@nestjs/common';
+
+import {
+ PERMISSION_CODES,
+ type BeneficiaryDetailResponse,
+ type BeneficiaryTagOption,
+ type PaginatedBeneficiariesResponse,
+} from '@lehuo/contracts';
+
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SessionChannel } from '../generated/prisma/enums';
+import { CaseManagementService } from './case-management.service';
+import { BeneficiaryQueryDto, UpdateBeneficiaryDto } from './dto/case-management.dto';
+
+@Controller('api/v1/admin/beneficiaries')
+@RequireSessionChannel(SessionChannel.ADMIN)
+@RequirePermissions(PERMISSION_CODES.BENEFICIARIES_READ)
+export class AdminBeneficiariesController {
+ constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {}
+
+ @Get('tags')
+ tags(): Promise {
+ return this.cases.tags();
+ }
+
+ @Get()
+ list(
+ @Query(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: BeneficiaryQueryDto,
+ }),
+ )
+ query: BeneficiaryQueryDto,
+ ): Promise {
+ return this.cases.listBeneficiaries(query);
+ }
+
+ @Get(':publicId')
+ get(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.getBeneficiary(publicId, actor, request);
+ }
+
+ @Put(':publicId')
+ @RequirePermissions(PERMISSION_CODES.BENEFICIARIES_MANAGE)
+ update(
+ @Param('publicId') publicId: string,
+ @Body(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: UpdateBeneficiaryDto,
+ }),
+ )
+ input: UpdateBeneficiaryDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.updateBeneficiary(publicId, input, actor, request);
+ }
+}
diff --git a/apps/api/src/case-management/admin-service-cases.controller.ts b/apps/api/src/case-management/admin-service-cases.controller.ts
new file mode 100644
index 0000000..f2c7306
--- /dev/null
+++ b/apps/api/src/case-management/admin-service-cases.controller.ts
@@ -0,0 +1,140 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Inject,
+ Param,
+ Patch,
+ Post,
+ Put,
+ Query,
+ Req,
+ ValidationPipe,
+} from '@nestjs/common';
+
+import {
+ PERMISSION_CODES,
+ type AssigneeOption,
+ type PaginatedServiceCasesResponse,
+ type ServiceCaseDetailResponse,
+ type ServiceRecordResponse,
+} from '@lehuo/contracts';
+
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SessionChannel } from '../generated/prisma/enums';
+import { CaseManagementService } from './case-management.service';
+import {
+ CreateServiceRecordDto,
+ ReplaceCaseAssigneesDto,
+ ServiceCaseQueryDto,
+ UpdateServiceRecordDto,
+} from './dto/case-management.dto';
+
+@Controller('api/v1/admin/service-cases')
+@RequireSessionChannel(SessionChannel.ADMIN)
+@RequirePermissions(PERMISSION_CODES.CASES_READ)
+export class AdminServiceCasesController {
+ constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {}
+
+ @Get('assignee-options')
+ @RequirePermissions(PERMISSION_CODES.CASES_ASSIGN)
+ assignees(): Promise {
+ return this.cases.assigneeOptions();
+ }
+
+ @Get()
+ list(
+ @Query(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: ServiceCaseQueryDto,
+ }),
+ )
+ query: ServiceCaseQueryDto,
+ @CurrentAuth() actor: AuthContext,
+ ): Promise {
+ return this.cases.listCases(query, actor);
+ }
+
+ @Get(':publicId')
+ get(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.getCase(publicId, actor, request);
+ }
+
+ @Post(':publicId/start')
+ @RequirePermissions(PERMISSION_CODES.CASES_MANAGE)
+ start(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.startCase(publicId, actor, request);
+ }
+
+ @Put(':publicId/assignees')
+ @RequirePermissions(PERMISSION_CODES.CASES_ASSIGN)
+ replaceAssignees(
+ @Param('publicId') publicId: string,
+ @Body(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: ReplaceCaseAssigneesDto,
+ }),
+ )
+ input: ReplaceCaseAssigneesDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.replaceAssignees(publicId, input, actor, request);
+ }
+
+ @Post(':publicId/records')
+ @RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE)
+ createRecord(
+ @Param('publicId') publicId: string,
+ @Body(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: CreateServiceRecordDto,
+ }),
+ )
+ input: CreateServiceRecordDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.createRecord(publicId, input, actor, request);
+ }
+
+ @Patch(':publicId/records/:recordPublicId')
+ @RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE)
+ updateRecord(
+ @Param('publicId') publicId: string,
+ @Param('recordPublicId') recordPublicId: string,
+ @Body(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: UpdateServiceRecordDto,
+ }),
+ )
+ input: UpdateServiceRecordDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.updateRecord(publicId, recordPublicId, input, actor, request);
+ }
+}
diff --git a/apps/api/src/case-management/assigned-service-cases.controller.ts b/apps/api/src/case-management/assigned-service-cases.controller.ts
new file mode 100644
index 0000000..7995d06
--- /dev/null
+++ b/apps/api/src/case-management/assigned-service-cases.controller.ts
@@ -0,0 +1,102 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Inject,
+ Param,
+ Patch,
+ Post,
+ Query,
+ Req,
+ ValidationPipe,
+} from '@nestjs/common';
+
+import {
+ PERMISSION_CODES,
+ type PaginatedServiceCasesResponse,
+ type ServiceCaseDetailResponse,
+ type ServiceRecordResponse,
+} from '@lehuo/contracts';
+
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SessionChannel } from '../generated/prisma/enums';
+import { CaseManagementService } from './case-management.service';
+import {
+ CreateServiceRecordDto,
+ ServiceCaseQueryDto,
+ UpdateServiceRecordDto,
+} from './dto/case-management.dto';
+
+@Controller('api/v1/service-cases')
+@RequireSessionChannel(SessionChannel.WECHAT)
+@RequirePermissions(PERMISSION_CODES.ASSIGNED_CASES_READ)
+export class AssignedServiceCasesController {
+ constructor(@Inject(CaseManagementService) private readonly cases: CaseManagementService) {}
+
+ @Get()
+ list(
+ @Query(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: ServiceCaseQueryDto,
+ }),
+ )
+ query: ServiceCaseQueryDto,
+ @CurrentAuth() actor: AuthContext,
+ ): Promise {
+ return this.cases.listCases(query, actor);
+ }
+
+ @Get(':publicId')
+ get(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.getCase(publicId, actor, request, true);
+ }
+
+ @Post(':publicId/records')
+ @RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE)
+ createRecord(
+ @Param('publicId') publicId: string,
+ @Body(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: CreateServiceRecordDto,
+ }),
+ )
+ input: CreateServiceRecordDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.createRecord(publicId, input, actor, request);
+ }
+
+ @Patch(':publicId/records/:recordPublicId')
+ @RequirePermissions(PERMISSION_CODES.SERVICE_RECORDS_WRITE)
+ updateRecord(
+ @Param('publicId') publicId: string,
+ @Param('recordPublicId') recordPublicId: string,
+ @Body(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: UpdateServiceRecordDto,
+ }),
+ )
+ input: UpdateServiceRecordDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.cases.updateRecord(publicId, recordPublicId, input, actor, request);
+ }
+}
diff --git a/apps/api/src/case-management/case-files.controller.ts b/apps/api/src/case-management/case-files.controller.ts
new file mode 100644
index 0000000..a0cfba3
--- /dev/null
+++ b/apps/api/src/case-management/case-files.controller.ts
@@ -0,0 +1,56 @@
+import { Body, Controller, Get, Inject, Param, Post, Req, ValidationPipe } from '@nestjs/common';
+
+import {
+ PERMISSION_CODES,
+ type ConfirmUploadResponse,
+ type CreateUploadIntentResponse,
+ type DownloadUrlResponse,
+} from '@lehuo/contracts';
+
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { FilesService } from '../files/files.service';
+import { CreateUploadIntentDto } from './dto/case-management.dto';
+
+@Controller('api/v1')
+@RequirePermissions(PERMISSION_CODES.FILES_WRITE)
+export class CaseFilesController {
+ constructor(@Inject(FilesService) private readonly files: FilesService) {}
+
+ @Post('service-cases/:casePublicId/files/upload-intents')
+ createIntent(
+ @Param('casePublicId') casePublicId: string,
+ @Body(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: CreateUploadIntentDto,
+ }),
+ )
+ input: CreateUploadIntentDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.files.createUploadIntent(casePublicId, input, actor, request);
+ }
+
+ @Post('files/:publicId/confirm')
+ confirm(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.files.confirm(publicId, actor, request);
+ }
+
+ @Get('files/:publicId/download-url')
+ download(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.files.downloadUrl(publicId, actor, request);
+ }
+}
diff --git a/apps/api/src/case-management/case-management.module.ts b/apps/api/src/case-management/case-management.module.ts
new file mode 100644
index 0000000..d238a21
--- /dev/null
+++ b/apps/api/src/case-management/case-management.module.ts
@@ -0,0 +1,26 @@
+import { Module } from '@nestjs/common';
+
+import { AuditModule } from '../audit/audit.module';
+import { FilesModule } from '../files/files.module';
+import { FilesService } from '../files/files.service';
+import { ContentNotificationsModule } from '../content-notifications/content-notifications.module';
+import { AdminApplicationWorkflowController } from './admin-application-workflow.controller';
+import { AdminBeneficiariesController } from './admin-beneficiaries.controller';
+import { AdminServiceCasesController } from './admin-service-cases.controller';
+import { AssignedServiceCasesController } from './assigned-service-cases.controller';
+import { CaseFilesController } from './case-files.controller';
+import { CaseManagementRepository } from './case-management.repository';
+import { CaseManagementService } from './case-management.service';
+
+@Module({
+ imports: [AuditModule, FilesModule, ContentNotificationsModule],
+ controllers: [
+ AdminApplicationWorkflowController,
+ AdminBeneficiariesController,
+ AdminServiceCasesController,
+ AssignedServiceCasesController,
+ CaseFilesController,
+ ],
+ providers: [CaseManagementRepository, CaseManagementService, FilesService],
+})
+export class CaseManagementModule {}
diff --git a/apps/api/src/case-management/case-management.repository.ts b/apps/api/src/case-management/case-management.repository.ts
new file mode 100644
index 0000000..54db322
--- /dev/null
+++ b/apps/api/src/case-management/case-management.repository.ts
@@ -0,0 +1,856 @@
+import { Inject, Injectable } from '@nestjs/common';
+
+import type {
+ BeneficiaryConfirmStatus,
+ BeneficiaryGender,
+ BeneficiaryStatus,
+ ServiceCasePriority,
+ ServiceCaseStatus,
+ ServiceCategory,
+ ServiceRecordType,
+} from '@lehuo/contracts';
+
+import { AuditService } from '../audit/audit.service';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { PrismaService } from '../database/prisma.service';
+import type { Prisma } from '../generated/prisma/client';
+import { NotificationsService } from '../content-notifications/notifications.service';
+
+const beneficiaryInclude = {
+ contacts: { where: { removedAt: null }, orderBy: { createdAt: 'asc' as const } },
+ beneficiaryTags: { include: { tag: true }, orderBy: { assignedAt: 'asc' as const } },
+ _count: { select: { serviceCases: true } },
+} satisfies Prisma.BeneficiaryProfileInclude;
+
+const caseInclude = {
+ application: true,
+ beneficiary: { include: beneficiaryInclude },
+ owner: { include: { staffProfile: true } },
+ assignees: {
+ where: { removedAt: null },
+ include: { user: { include: { staffProfile: true } } },
+ orderBy: [{ role: 'asc' as const }, { assignedAt: 'asc' as const }],
+ },
+ statusHistory: { orderBy: { createdAt: 'asc' as const } },
+ serviceRecords: {
+ include: {
+ creator: { include: { staffProfile: true } },
+ attachments: { include: { file: true } },
+ },
+ orderBy: [{ serviceStartAt: 'desc' as const }, { id: 'desc' as const }],
+ },
+} satisfies Prisma.ServiceCaseInclude;
+
+export type StoredBeneficiary = Prisma.BeneficiaryProfileGetPayload<{
+ include: typeof beneficiaryInclude;
+}>;
+export type StoredServiceCase = Prisma.ServiceCaseGetPayload<{ include: typeof caseInclude }>;
+
+export interface PreparedContact {
+ publicId: string;
+ nameEncrypted: string;
+ relation: string;
+ mobileEncrypted: string;
+ mobileHash: string;
+ isGuardian: boolean;
+ isEmergencyContact: boolean;
+}
+
+export interface PreparedBeneficiary {
+ publicId: string;
+ displayNameEncrypted: string;
+ displayNameHash: string;
+ gender?: BeneficiaryGender;
+ birthYear?: number;
+ mobileEncrypted?: string;
+ mobileHash?: string;
+ regionCode: string;
+ addressDetailEncrypted?: string;
+ primaryCategory: ServiceCategory;
+ keyCareFlag: boolean;
+ isMinor: boolean;
+ notesEncrypted?: string;
+ contacts: PreparedContact[];
+ tagCodes: string[];
+ guardianConsentConfirmed: boolean;
+}
+
+export interface AcceptApplicationInput {
+ applicationPublicId: string;
+ acceptRequestId: string;
+ acceptRequestFingerprint: string;
+ existingBeneficiaryId?: bigint;
+ newBeneficiary?: PreparedBeneficiary;
+ ownerStaffId: bigint;
+ collaborators: Array<{ userId: bigint; type: 'STAFF' | 'VOLUNTEER' }>;
+ casePublicId: string;
+ caseNo: string;
+ priority: ServiceCasePriority;
+ plannedStartAt?: Date;
+ plannedEndAt?: Date;
+ serviceLocationEncrypted?: string;
+ summaryEncrypted: string;
+}
+
+export interface BeneficiaryFilters {
+ primaryCategory?: ServiceCategory;
+ status?: BeneficiaryStatus;
+ keyCareFlag?: boolean;
+}
+
+export interface CaseFilters {
+ status?: ServiceCaseStatus;
+ category?: ServiceCategory;
+ caseNo?: string;
+}
+
+export interface PreparedServiceRecord {
+ publicId: string;
+ clientRequestId: string;
+ requestFingerprint: string;
+ serviceStartAt: Date;
+ serviceEndAt?: Date;
+ serviceType: ServiceRecordType;
+ contentEncrypted: string;
+ resultEncrypted: string;
+ followUpSuggestionEncrypted?: string;
+ beneficiaryConfirmStatus: BeneficiaryConfirmStatus;
+ attachmentPublicIds: string[];
+}
+
+export interface PreparedBeneficiaryUpdate extends PreparedBeneficiary {
+ status: BeneficiaryStatus;
+}
+
+@Injectable()
+export class CaseManagementRepository {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ @Inject(NotificationsService) private readonly notifications: NotificationsService,
+ ) {}
+
+ findApplication(publicId: string) {
+ return this.prisma.serviceApplication.findUnique({
+ where: { publicId },
+ include: { serviceCase: { include: caseInclude } },
+ });
+ }
+
+ findBeneficiary(publicId: string): Promise {
+ return this.prisma.beneficiaryProfile.findUnique({
+ where: { publicId },
+ include: beneficiaryInclude,
+ });
+ }
+
+ async listBeneficiaries(filters: BeneficiaryFilters, page: number, pageSize: number) {
+ const where: Prisma.BeneficiaryProfileWhereInput = {
+ ...(filters.primaryCategory ? { primaryCategory: filters.primaryCategory } : {}),
+ ...(filters.status ? { status: filters.status } : {}),
+ ...(filters.keyCareFlag === undefined ? {} : { keyCareFlag: filters.keyCareFlag }),
+ };
+ const [items, total] = await this.prisma.$transaction([
+ this.prisma.beneficiaryProfile.findMany({
+ where,
+ include: beneficiaryInclude,
+ orderBy: [{ keyCareFlag: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.beneficiaryProfile.count({ where }),
+ ]);
+ return { items, total };
+ }
+
+ async updateBeneficiary(
+ existing: StoredBeneficiary,
+ input: PreparedBeneficiaryUpdate,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ return this.prisma.$transaction(async (transaction) => {
+ const tags = await transaction.tag.findMany({ where: { code: { in: input.tagCodes } } });
+ if (tags.length !== new Set(input.tagCodes).size) throw new Error('Unknown beneficiary tag');
+ await transaction.beneficiaryProfile.update({
+ where: { id: existing.id },
+ data: {
+ displayNameEncrypted: input.displayNameEncrypted,
+ displayNameHash: input.displayNameHash,
+ gender: input.gender,
+ birthYear: input.birthYear,
+ mobileEncrypted: input.mobileEncrypted,
+ mobileHash: input.mobileHash,
+ regionCode: input.regionCode,
+ addressDetailEncrypted: input.addressDetailEncrypted,
+ keyCareFlag: input.keyCareFlag,
+ isMinor: input.isMinor,
+ status: input.status,
+ notesEncrypted: input.notesEncrypted,
+ updatedById: actor.userId,
+ },
+ });
+ await transaction.beneficiaryContact.updateMany({
+ where: { beneficiaryId: existing.id, removedAt: null },
+ data: { removedAt: new Date() },
+ });
+ for (const contact of input.contacts) {
+ let consentRecordId: bigint | undefined;
+ if (input.isMinor && contact.isGuardian && input.guardianConsentConfirmed) {
+ const consent = await transaction.consent.create({
+ data: {
+ publicId: crypto.randomUUID(),
+ beneficiaryId: existing.id,
+ consentType: 'BENEFICIARY_GUARDIAN',
+ version: 'beneficiary-guardian-v1',
+ grantedBy: 'GUARDIAN',
+ grantedAt: new Date(),
+ evidenceJson: { source: 'ADMIN_STAFF_RECORDED', operation: 'PROFILE_UPDATE' },
+ },
+ });
+ consentRecordId = consent.id;
+ }
+ await transaction.beneficiaryContact.create({
+ data: { ...contact, beneficiaryId: existing.id, consentRecordId },
+ });
+ }
+ await transaction.beneficiaryTag.deleteMany({ where: { beneficiaryId: existing.id } });
+ if (tags.length) {
+ await transaction.beneficiaryTag.createMany({
+ data: tags.map((tag) => ({
+ beneficiaryId: existing.id,
+ tagId: tag.id,
+ assignedById: actor.userId,
+ })),
+ });
+ }
+ await this.audit.record(
+ request,
+ {
+ action: 'BENEFICIARY_PROFILE_UPDATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'BENEFICIARY',
+ resourcePublicId: existing.publicId,
+ metadata: {
+ status: input.status,
+ keyCareFlag: input.keyCareFlag,
+ isMinor: input.isMinor,
+ },
+ },
+ transaction,
+ );
+ return transaction.beneficiaryProfile.findUniqueOrThrow({
+ where: { id: existing.id },
+ include: beneficiaryInclude,
+ });
+ });
+ }
+
+ listTags() {
+ return this.prisma.tag.findMany({ orderBy: { code: 'asc' } });
+ }
+
+ findOwnerCandidate(publicId: string) {
+ return this.prisma.user.findFirst({
+ where: { publicId, status: 'ACTIVE', staffProfile: { status: 'ACTIVE' } },
+ include: { staffProfile: true },
+ });
+ }
+
+ async findCollaboratorCandidates(publicIds: string[]) {
+ return this.prisma.user.findMany({
+ where: {
+ publicId: { in: publicIds },
+ status: 'ACTIVE',
+ OR: [
+ { staffProfile: { status: 'ACTIVE' } },
+ { userRoles: { some: { role: { code: 'VOLUNTEER' } } } },
+ ],
+ },
+ include: { staffProfile: true, userRoles: { include: { role: true } } },
+ });
+ }
+
+ async listAssigneeOptions() {
+ return this.prisma.user.findMany({
+ where: {
+ status: 'ACTIVE',
+ OR: [
+ { staffProfile: { status: 'ACTIVE' } },
+ { userRoles: { some: { role: { code: 'VOLUNTEER' } } } },
+ ],
+ },
+ include: { staffProfile: true, userRoles: { include: { role: true } } },
+ orderBy: [{ createdAt: 'asc' }],
+ take: 200,
+ });
+ }
+
+ async transitionApplication(
+ publicId: string,
+ expectedStatus: 'SUBMITTED' | 'PENDING_CONTACT',
+ targetStatus: 'PENDING_CONTACT' | 'ASSESSING',
+ reasonCode: string,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ return this.prisma.$transaction(async (transaction) => {
+ const application = await transaction.serviceApplication.findUnique({ where: { publicId } });
+ if (!application) return false;
+ const updated = await transaction.serviceApplication.updateMany({
+ where: { id: application.id, status: expectedStatus },
+ data: { status: targetStatus },
+ });
+ if (updated.count !== 1) return false;
+ await transaction.serviceApplicationStatusHistory.create({
+ data: {
+ applicationId: application.id,
+ fromStatus: expectedStatus,
+ toStatus: targetStatus,
+ operatorUserId: actor.userId,
+ reasonCode,
+ },
+ });
+ if (targetStatus === 'PENDING_CONTACT')
+ await this.notifications.create(transaction, {
+ userId: application.applicantUserId,
+ eventKey: `application:${publicId}:accepted`,
+ type: 'APPLICATION_ACCEPTED',
+ title: '服务申请已受理',
+ body: '工作人员将与您联系并进一步了解需求。',
+ resourceType: 'SERVICE_APPLICATION',
+ resourcePublicId: publicId,
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'SERVICE_APPLICATION_STATUS_CHANGED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'SERVICE_APPLICATION',
+ resourcePublicId: publicId,
+ metadata: { fromStatus: expectedStatus, toStatus: targetStatus, reasonCode },
+ },
+ transaction,
+ );
+ return true;
+ });
+ }
+
+ async acceptApplication(
+ input: AcceptApplicationInput,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ return this.prisma.$transaction(async (transaction) => {
+ const application = await transaction.serviceApplication.findUniqueOrThrow({
+ where: { publicId: input.applicationPublicId },
+ });
+ const owner = await transaction.user.findFirstOrThrow({
+ where: {
+ id: input.ownerStaffId,
+ status: 'ACTIVE',
+ staffProfile: { status: 'ACTIVE' },
+ },
+ });
+ const collaboratorIds = input.collaborators.map((collaborator) => collaborator.userId);
+ const activeCollaboratorCount = await transaction.user.count({
+ where: {
+ id: { in: collaboratorIds },
+ status: 'ACTIVE',
+ OR: [
+ { staffProfile: { status: 'ACTIVE' } },
+ { userRoles: { some: { role: { code: 'VOLUNTEER' } } } },
+ ],
+ },
+ });
+ if (activeCollaboratorCount !== new Set(collaboratorIds).size) {
+ throw new Error('ASSIGNEE_STATE_CONFLICT');
+ }
+ let beneficiaryId = input.existingBeneficiaryId;
+ if (!beneficiaryId && input.newBeneficiary) {
+ const prepared = input.newBeneficiary;
+ const tags = await transaction.tag.findMany({ where: { code: { in: prepared.tagCodes } } });
+ if (tags.length !== new Set(prepared.tagCodes).size)
+ throw new Error('Unknown beneficiary tag');
+ const beneficiary = await transaction.beneficiaryProfile.create({
+ data: {
+ publicId: prepared.publicId,
+ displayNameEncrypted: prepared.displayNameEncrypted,
+ displayNameHash: prepared.displayNameHash,
+ gender: prepared.gender,
+ birthYear: prepared.birthYear,
+ mobileEncrypted: prepared.mobileEncrypted,
+ mobileHash: prepared.mobileHash,
+ regionCode: prepared.regionCode,
+ addressDetailEncrypted: prepared.addressDetailEncrypted,
+ primaryCategory: prepared.primaryCategory,
+ keyCareFlag: prepared.keyCareFlag,
+ isMinor: prepared.isMinor,
+ notesEncrypted: prepared.notesEncrypted,
+ createdById: actor.userId,
+ updatedById: actor.userId,
+ },
+ });
+ beneficiaryId = beneficiary.id;
+ for (const contact of prepared.contacts) {
+ let consentRecordId: bigint | undefined;
+ if (prepared.isMinor && contact.isGuardian && prepared.guardianConsentConfirmed) {
+ const consent = await transaction.consent.create({
+ data: {
+ publicId: crypto.randomUUID(),
+ beneficiaryId,
+ consentType: 'BENEFICIARY_GUARDIAN',
+ version: 'beneficiary-guardian-v1',
+ grantedBy: 'GUARDIAN',
+ grantedAt: new Date(),
+ evidenceJson: {
+ source: 'ADMIN_STAFF_RECORDED',
+ relatedApplicationPublicId: input.applicationPublicId,
+ },
+ },
+ });
+ consentRecordId = consent.id;
+ }
+ await transaction.beneficiaryContact.create({
+ data: { ...contact, beneficiaryId, consentRecordId },
+ });
+ }
+ if (tags.length) {
+ await transaction.beneficiaryTag.createMany({
+ data: tags.map((tag) => ({
+ beneficiaryId: beneficiary.id,
+ tagId: tag.id,
+ assignedById: actor.userId,
+ })),
+ });
+ }
+ }
+ if (!beneficiaryId) throw new Error('Beneficiary resolution failed');
+ const accepted = await transaction.serviceApplication.updateMany({
+ where: { id: application.id, status: 'ASSESSING' },
+ data: { status: 'ACCEPTED', beneficiaryId, acceptedAt: new Date() },
+ });
+ if (accepted.count !== 1) throw new Error('APPLICATION_STATE_CONFLICT');
+ await transaction.serviceApplicationStatusHistory.create({
+ data: {
+ applicationId: application.id,
+ fromStatus: 'ASSESSING',
+ toStatus: 'ACCEPTED',
+ operatorUserId: actor.userId,
+ reasonCode: 'ASSESSMENT_ACCEPTED',
+ },
+ });
+ const serviceCase = await transaction.serviceCase.create({
+ data: {
+ publicId: input.casePublicId,
+ caseNo: input.caseNo,
+ applicationId: application.id,
+ acceptRequestId: input.acceptRequestId,
+ acceptRequestFingerprint: input.acceptRequestFingerprint,
+ beneficiaryId,
+ ownerStaffId: owner.id,
+ category: application.serviceCategory,
+ priority: input.priority,
+ plannedStartAt: input.plannedStartAt,
+ plannedEndAt: input.plannedEndAt,
+ serviceLocationEncrypted: input.serviceLocationEncrypted,
+ summaryEncrypted: input.summaryEncrypted,
+ createdById: actor.userId,
+ updatedById: actor.userId,
+ },
+ });
+ await transaction.caseAssignee.create({
+ data: {
+ caseId: serviceCase.id,
+ userId: owner.id,
+ assigneeType: 'STAFF',
+ role: 'OWNER',
+ assignedById: actor.userId,
+ },
+ });
+ for (const collaborator of input.collaborators) {
+ if (collaborator.userId === owner.id) continue;
+ await transaction.caseAssignee.create({
+ data: {
+ caseId: serviceCase.id,
+ userId: collaborator.userId,
+ assigneeType: collaborator.type,
+ role: 'COLLABORATOR',
+ assignedById: actor.userId,
+ },
+ });
+ }
+ await transaction.serviceCaseStatusHistory.create({
+ data: {
+ caseId: serviceCase.id,
+ fromStatus: null,
+ toStatus: 'ASSIGNED',
+ operatorUserId: actor.userId,
+ reasonCode: 'CASE_CREATED_AND_ASSIGNED',
+ },
+ });
+ await transaction.serviceApplication.update({
+ where: { id: application.id },
+ data: { status: 'SERVICE_PLANNED' },
+ });
+ await transaction.serviceApplicationStatusHistory.create({
+ data: {
+ applicationId: application.id,
+ fromStatus: 'ACCEPTED',
+ toStatus: 'SERVICE_PLANNED',
+ operatorUserId: actor.userId,
+ reasonCode: 'CASE_CREATED_AND_ASSIGNED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'SERVICE_APPLICATION_ACCEPTED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'SERVICE_APPLICATION',
+ resourcePublicId: input.applicationPublicId,
+ metadata: {
+ casePublicId: input.casePublicId,
+ beneficiaryPublicId: input.newBeneficiary?.publicId ?? null,
+ },
+ },
+ transaction,
+ );
+ await this.audit.record(
+ request,
+ {
+ action: 'SERVICE_CASE_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'SERVICE_CASE',
+ resourcePublicId: input.casePublicId,
+ metadata: { priority: input.priority },
+ },
+ transaction,
+ );
+ return transaction.serviceCase.findUniqueOrThrow({
+ where: { id: serviceCase.id },
+ include: caseInclude,
+ });
+ });
+ }
+
+ findCase(publicId: string): Promise {
+ return this.prisma.serviceCase.findUnique({ where: { publicId }, include: caseInclude });
+ }
+
+ findCaseById(id: bigint): Promise {
+ return this.prisma.serviceCase.findUnique({ where: { id }, include: caseInclude });
+ }
+
+ async listCases(
+ filters: CaseFilters,
+ actorUserId: bigint,
+ accessAll: boolean,
+ page: number,
+ pageSize: number,
+ ) {
+ const where: Prisma.ServiceCaseWhereInput = {
+ ...(accessAll ? {} : { assignees: { some: { userId: actorUserId, removedAt: null } } }),
+ ...(filters.status ? { status: filters.status } : {}),
+ ...(filters.category ? { category: filters.category } : {}),
+ ...(filters.caseNo ? { caseNo: { startsWith: filters.caseNo } } : {}),
+ };
+ const [items, total] = await this.prisma.$transaction([
+ this.prisma.serviceCase.findMany({
+ where,
+ include: caseInclude,
+ orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.serviceCase.count({ where }),
+ ]);
+ return { items, total };
+ }
+
+ isAssigned(caseId: bigint, userId: bigint) {
+ return this.prisma.caseAssignee.count({ where: { caseId, userId, removedAt: null } });
+ }
+
+ async startCase(
+ serviceCase: StoredServiceCase,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ return this.prisma.$transaction(async (transaction) => {
+ const updated = await transaction.serviceCase.updateMany({
+ where: { id: serviceCase.id, status: 'ASSIGNED', ownerStaffId: actor.userId },
+ data: { status: 'IN_SERVICE', updatedById: actor.userId },
+ });
+ if (updated.count !== 1) return null;
+ await transaction.serviceCaseStatusHistory.create({
+ data: {
+ caseId: serviceCase.id,
+ fromStatus: 'ASSIGNED',
+ toStatus: 'IN_SERVICE',
+ operatorUserId: actor.userId,
+ reasonCode: 'SERVICE_STARTED',
+ },
+ });
+ const applicationUpdated = await transaction.serviceApplication.updateMany({
+ where: { id: serviceCase.applicationId, status: 'SERVICE_PLANNED' },
+ data: { status: 'IN_SERVICE' },
+ });
+ if (applicationUpdated.count !== 1) throw new Error('APPLICATION_STATE_CONFLICT');
+ await transaction.serviceApplicationStatusHistory.create({
+ data: {
+ applicationId: serviceCase.applicationId,
+ fromStatus: 'SERVICE_PLANNED',
+ toStatus: 'IN_SERVICE',
+ operatorUserId: actor.userId,
+ reasonCode: 'SERVICE_STARTED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'SERVICE_CASE_STATUS_CHANGED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'SERVICE_CASE',
+ resourcePublicId: serviceCase.publicId,
+ metadata: { fromStatus: 'ASSIGNED', toStatus: 'IN_SERVICE' },
+ },
+ transaction,
+ );
+ return transaction.serviceCase.findUniqueOrThrow({
+ where: { id: serviceCase.id },
+ include: caseInclude,
+ });
+ });
+ }
+
+ async replaceCollaborators(
+ serviceCase: StoredServiceCase,
+ collaborators: Array<{ userId: bigint; type: 'STAFF' | 'VOLUNTEER' }>,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ return this.prisma.$transaction(async (transaction) => {
+ await transaction.caseAssignee.updateMany({
+ where: { caseId: serviceCase.id, role: 'COLLABORATOR', removedAt: null },
+ data: { removedAt: new Date() },
+ });
+ for (const collaborator of collaborators) {
+ if (collaborator.userId === serviceCase.ownerStaffId) continue;
+ await transaction.caseAssignee.upsert({
+ where: { caseId_userId: { caseId: serviceCase.id, userId: collaborator.userId } },
+ create: {
+ caseId: serviceCase.id,
+ userId: collaborator.userId,
+ assigneeType: collaborator.type,
+ role: 'COLLABORATOR',
+ assignedById: actor.userId,
+ },
+ update: {
+ assigneeType: collaborator.type,
+ role: 'COLLABORATOR',
+ assignedById: actor.userId,
+ assignedAt: new Date(),
+ removedAt: null,
+ },
+ });
+ }
+ await this.audit.record(
+ request,
+ {
+ action: 'SERVICE_CASE_ASSIGNEES_REPLACED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'SERVICE_CASE',
+ resourcePublicId: serviceCase.publicId,
+ metadata: { collaboratorCount: collaborators.length },
+ },
+ transaction,
+ );
+ return transaction.serviceCase.findUniqueOrThrow({
+ where: { id: serviceCase.id },
+ include: caseInclude,
+ });
+ });
+ }
+
+ findRecordByIdempotency(createdById: bigint, clientRequestId: string) {
+ return this.prisma.serviceRecord.findUnique({
+ where: { createdById_clientRequestId: { createdById, clientRequestId } },
+ include: {
+ creator: { include: { staffProfile: true } },
+ attachments: { include: { file: true } },
+ },
+ });
+ }
+
+ findRecord(publicId: string) {
+ return this.prisma.serviceRecord.findUnique({
+ where: { publicId },
+ include: {
+ creator: { include: { staffProfile: true } },
+ attachments: { include: { file: true } },
+ serviceCase: { include: caseInclude },
+ },
+ });
+ }
+
+ async createRecord(
+ serviceCase: StoredServiceCase,
+ input: PreparedServiceRecord,
+ actor: AuthContext,
+ request: RequestContext,
+ ) {
+ return this.prisma.$transaction(async (transaction) => {
+ const assigned = await transaction.caseAssignee.count({
+ where: { caseId: serviceCase.id, userId: actor.userId, removedAt: null },
+ });
+ if (!assigned || serviceCase.status !== 'IN_SERVICE')
+ throw new Error('CASE_RECORD_FORBIDDEN');
+ const files = input.attachmentPublicIds.length
+ ? await transaction.file.findMany({
+ where: {
+ publicId: { in: input.attachmentPublicIds },
+ caseId: serviceCase.id,
+ uploaderUserId: actor.userId,
+ status: 'AVAILABLE',
+ },
+ })
+ : [];
+ if (files.length !== new Set(input.attachmentPublicIds).size) throw new Error('FILE_INVALID');
+ const record = await transaction.serviceRecord.create({
+ data: {
+ publicId: input.publicId,
+ caseId: serviceCase.id,
+ beneficiaryId: serviceCase.beneficiaryId,
+ clientRequestId: input.clientRequestId,
+ requestFingerprint: input.requestFingerprint,
+ serviceStartAt: input.serviceStartAt,
+ serviceEndAt: input.serviceEndAt,
+ serviceType: input.serviceType,
+ contentEncrypted: input.contentEncrypted,
+ resultEncrypted: input.resultEncrypted,
+ followUpSuggestionEncrypted: input.followUpSuggestionEncrypted,
+ beneficiaryConfirmStatus: input.beneficiaryConfirmStatus,
+ createdById: actor.userId,
+ updatedById: actor.userId,
+ },
+ });
+ if (files.length) {
+ await transaction.serviceRecordFile.createMany({
+ data: files.map((file) => ({ serviceRecordId: record.id, fileId: file.id })),
+ });
+ }
+ await this.audit.record(
+ request,
+ {
+ action: 'SERVICE_RECORD_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'SERVICE_RECORD',
+ resourcePublicId: input.publicId,
+ metadata: { casePublicId: serviceCase.publicId, serviceType: input.serviceType },
+ },
+ transaction,
+ );
+ return transaction.serviceRecord.findUniqueOrThrow({
+ where: { id: record.id },
+ include: {
+ creator: { include: { staffProfile: true } },
+ attachments: { include: { file: true } },
+ },
+ });
+ });
+ }
+
+ async updateRecord(
+ record: NonNullable>>,
+ input: Omit & {
+ expectedRevision: number;
+ },
+ actor: AuthContext,
+ request: RequestContext,
+ ) {
+ return this.prisma.$transaction(async (transaction) => {
+ const assigned = await transaction.caseAssignee.count({
+ where: { caseId: record.caseId, userId: actor.userId, removedAt: null },
+ });
+ if (!assigned || record.serviceCase.status !== 'IN_SERVICE') {
+ throw new Error('CASE_RECORD_FORBIDDEN');
+ }
+ const files = input.attachmentPublicIds.length
+ ? await transaction.file.findMany({
+ where: {
+ publicId: { in: input.attachmentPublicIds },
+ caseId: record.caseId,
+ status: 'AVAILABLE',
+ OR: [
+ { uploaderUserId: actor.userId },
+ { serviceRecords: { some: { serviceRecordId: record.id } } },
+ ],
+ },
+ })
+ : [];
+ if (files.length !== new Set(input.attachmentPublicIds).size) throw new Error('FILE_INVALID');
+ await transaction.serviceRecordRevision.create({
+ data: {
+ serviceRecordId: record.id,
+ revision: record.revision,
+ serviceStartAt: record.serviceStartAt,
+ serviceEndAt: record.serviceEndAt,
+ serviceType: record.serviceType,
+ contentEncrypted: record.contentEncrypted,
+ resultEncrypted: record.resultEncrypted,
+ followUpSuggestionEncrypted: record.followUpSuggestionEncrypted,
+ beneficiaryConfirmStatus: record.beneficiaryConfirmStatus,
+ revisedById: actor.userId,
+ },
+ });
+ const updated = await transaction.serviceRecord.updateMany({
+ where: { id: record.id, revision: input.expectedRevision },
+ data: {
+ serviceStartAt: input.serviceStartAt,
+ serviceEndAt: input.serviceEndAt,
+ serviceType: input.serviceType,
+ contentEncrypted: input.contentEncrypted,
+ resultEncrypted: input.resultEncrypted,
+ followUpSuggestionEncrypted: input.followUpSuggestionEncrypted,
+ beneficiaryConfirmStatus: input.beneficiaryConfirmStatus,
+ updatedById: actor.userId,
+ revision: { increment: 1 },
+ },
+ });
+ if (updated.count !== 1) throw new Error('RECORD_REVISION_CONFLICT');
+ await transaction.serviceRecordFile.deleteMany({ where: { serviceRecordId: record.id } });
+ if (files.length) {
+ await transaction.serviceRecordFile.createMany({
+ data: files.map((file) => ({ serviceRecordId: record.id, fileId: file.id })),
+ });
+ }
+ await this.audit.record(
+ request,
+ {
+ action: 'SERVICE_RECORD_UPDATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'SERVICE_RECORD',
+ resourcePublicId: record.publicId,
+ metadata: { fromRevision: record.revision, toRevision: record.revision + 1 },
+ },
+ transaction,
+ );
+ return transaction.serviceRecord.findUniqueOrThrow({
+ where: { id: record.id },
+ include: {
+ creator: { include: { staffProfile: true } },
+ attachments: { include: { file: true } },
+ },
+ });
+ });
+ }
+}
diff --git a/apps/api/src/case-management/case-management.service.ts b/apps/api/src/case-management/case-management.service.ts
new file mode 100644
index 0000000..6d2ad42
--- /dev/null
+++ b/apps/api/src/case-management/case-management.service.ts
@@ -0,0 +1,767 @@
+import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { randomBytes, randomUUID } from 'node:crypto';
+
+import {
+ API_ERROR_CODES,
+ PERMISSION_CODES,
+ SERVICE_CASE_PRIORITY_LABELS,
+ SERVICE_CASE_STATUS_LABELS,
+ SERVICE_RECORD_TYPE_LABELS,
+ type AcceptServiceApplicationResponse,
+ type AssigneeOption,
+ type BeneficiaryDetail,
+ type BeneficiaryDetailResponse,
+ type BeneficiaryListItem,
+ type BeneficiaryTagOption,
+ type PaginatedBeneficiariesResponse,
+ type PaginatedServiceCasesResponse,
+ type ServiceCaseDetail,
+ type ServiceCaseDetailResponse,
+ type ServiceCaseListItem,
+ type ServiceRecordItem,
+ type ServiceRecordResponse,
+ type UpdateBeneficiaryRequest,
+} from '@lehuo/contracts';
+
+import { AuditService } from '../audit/audit.service';
+import { ApplicationError } from '../common/application-error';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+import type {
+ AcceptServiceApplicationDto,
+ BeneficiaryQueryDto,
+ CreateServiceRecordDto,
+ ReplaceCaseAssigneesDto,
+ ServiceCaseQueryDto,
+ TransitionServiceApplicationDto,
+ UpdateBeneficiaryDto,
+ UpdateServiceRecordDto,
+} from './dto/case-management.dto';
+import {
+ CaseManagementRepository,
+ type PreparedBeneficiary,
+ type StoredBeneficiary,
+ type StoredServiceCase,
+} from './case-management.repository';
+
+function maskMobile(value: string): string {
+ return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
+}
+
+function shanghaiDatePrefix(date: Date): string {
+ const parts = new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(date);
+ const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
+ return `${value.year}${value.month}${value.day}`;
+}
+
+function isUniqueConstraintError(error: unknown): error is { code: 'P2002' } {
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
+}
+
+@Injectable()
+export class CaseManagementService {
+ constructor(
+ @Inject(CaseManagementRepository) private readonly repository: CaseManagementRepository,
+ @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ ) {}
+
+ tags(): Promise {
+ return this.repository
+ .listTags()
+ .then((tags) =>
+ tags.map((tag) => ({ code: tag.code, name: tag.name, description: tag.description })),
+ );
+ }
+
+ async transitionApplication(
+ publicId: string,
+ input: TransitionServiceApplicationDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise<{ status: string }> {
+ const expected = input.targetStatus === 'PENDING_CONTACT' ? 'SUBMITTED' : 'PENDING_CONTACT';
+ const application = await this.repository.findApplication(publicId);
+ if (!application)
+ throw this.notFound(API_ERROR_CODES.APPLICATION_NOT_FOUND, '未找到该服务申请');
+ if (application.status !== expected) throw this.invalidTransition();
+ const changed = await this.repository.transitionApplication(
+ publicId,
+ expected,
+ input.targetStatus,
+ input.reasonCode,
+ actor,
+ request,
+ );
+ if (!changed) throw this.invalidTransition();
+ return { status: input.targetStatus };
+ }
+
+ async acceptApplication(
+ publicId: string,
+ input: AcceptServiceApplicationDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const normalized = this.normalizeAcceptance(input);
+ const fingerprint = this.sensitiveData.lookupHash(
+ 'service-application-acceptance',
+ JSON.stringify(normalized),
+ );
+ const application = await this.repository.findApplication(publicId);
+ if (!application)
+ throw this.notFound(API_ERROR_CODES.APPLICATION_NOT_FOUND, '未找到该服务申请');
+ if (application.serviceCase) {
+ if (
+ application.serviceCase.acceptRequestId !== input.clientRequestId ||
+ application.serviceCase.acceptRequestFingerprint !== fingerprint
+ ) {
+ throw this.duplicateRequest();
+ }
+ return { serviceCase: this.toCaseDetail(application.serviceCase), idempotentReplay: true };
+ }
+ if (application.status !== 'ASSESSING') throw this.invalidTransition();
+ const owner = await this.repository.findOwnerCandidate(input.ownerStaffPublicId);
+ if (!owner) throw this.validation('负责人必须是有效工作人员');
+ const collaboratorIds = [...new Set(input.collaboratorUserPublicIds)];
+ if (collaboratorIds.length !== input.collaboratorUserPublicIds.length) {
+ throw this.validation('协作人员不能重复');
+ }
+ const collaboratorUsers = await this.repository.findCollaboratorCandidates(collaboratorIds);
+ if (collaboratorUsers.length !== collaboratorIds.length) {
+ throw this.validation('存在无效或未获授权的协作人员');
+ }
+ let existingBeneficiaryId: bigint | undefined;
+ let newBeneficiary: PreparedBeneficiary | undefined;
+ if (input.beneficiary.mode === 'LINK_EXISTING') {
+ if (
+ !input.beneficiary.beneficiaryPublicId ||
+ input.beneficiary.manualMatchConfirmed !== true
+ ) {
+ throw this.validation('关联已有档案必须完成人工核验');
+ }
+ const beneficiary = await this.repository.findBeneficiary(
+ input.beneficiary.beneficiaryPublicId,
+ );
+ if (!beneficiary) {
+ throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案');
+ }
+ if (!beneficiary.mobileHash || beneficiary.mobileHash !== application.contactMobileHash) {
+ throw this.validation('手机号不一致,不能关联已有档案');
+ }
+ existingBeneficiaryId = beneficiary.id;
+ } else {
+ if (!input.beneficiary.profile) throw this.validation('请填写服务对象档案');
+ const profile = input.beneficiary.profile;
+ const guardians = profile.contacts.filter((contact) => contact.isGuardian);
+ if (profile.isMinor && (!guardians.length || profile.guardianConsentConfirmed !== true)) {
+ throw this.validation('未成年人档案必须记录监护人及其明确授权');
+ }
+ newBeneficiary = this.prepareBeneficiary(profile, application.serviceCategory);
+ }
+ const plannedStartAt = normalized.plannedStartAt
+ ? new Date(normalized.plannedStartAt)
+ : undefined;
+ const plannedEndAt = normalized.plannedEndAt ? new Date(normalized.plannedEndAt) : undefined;
+ if (plannedStartAt && plannedEndAt && plannedEndAt < plannedStartAt) {
+ throw this.validation('计划结束时间不能早于开始时间');
+ }
+ try {
+ const created = await this.repository.acceptApplication(
+ {
+ applicationPublicId: publicId,
+ acceptRequestId: input.clientRequestId,
+ acceptRequestFingerprint: fingerprint,
+ existingBeneficiaryId,
+ newBeneficiary,
+ ownerStaffId: owner.id,
+ collaborators: collaboratorUsers.map((user) => ({
+ userId: user.id,
+ type: user.staffProfile ? 'STAFF' : 'VOLUNTEER',
+ })),
+ casePublicId: randomUUID(),
+ caseNo: `WO${shanghaiDatePrefix(new Date())}${randomBytes(5).toString('hex').toUpperCase()}`,
+ priority: input.priority,
+ plannedStartAt,
+ plannedEndAt,
+ serviceLocationEncrypted: normalized.serviceLocation
+ ? this.sensitiveData.encrypt(normalized.serviceLocation)
+ : undefined,
+ summaryEncrypted: this.sensitiveData.encrypt(normalized.summary),
+ },
+ actor,
+ request,
+ );
+ return { serviceCase: this.toCaseDetail(created), idempotentReplay: false };
+ } catch (error) {
+ if (isUniqueConstraintError(error)) {
+ const replay = await this.repository.findApplication(publicId);
+ if (
+ replay?.serviceCase?.acceptRequestId === input.clientRequestId &&
+ replay.serviceCase.acceptRequestFingerprint === fingerprint
+ ) {
+ return { serviceCase: this.toCaseDetail(replay.serviceCase), idempotentReplay: true };
+ }
+ throw this.duplicateRequest();
+ }
+ if (error instanceof Error && error.message === 'APPLICATION_STATE_CONFLICT') {
+ throw this.invalidTransition();
+ }
+ if (error instanceof Error && error.message === 'ASSIGNEE_STATE_CONFLICT') {
+ throw this.validation('协作人员状态已变化,请刷新后重试');
+ }
+ if (error instanceof Error && error.message === 'Unknown beneficiary tag') {
+ throw this.validation('包含未知的服务标签');
+ }
+ throw error;
+ }
+ }
+
+ async listBeneficiaries(query: BeneficiaryQueryDto): Promise {
+ const result = await this.repository.listBeneficiaries(
+ {
+ ...(query.primaryCategory ? { primaryCategory: query.primaryCategory } : {}),
+ ...(query.status ? { status: query.status } : {}),
+ ...(query.keyCareFlag === undefined ? {} : { keyCareFlag: query.keyCareFlag }),
+ },
+ query.page,
+ query.pageSize,
+ );
+ return {
+ items: result.items.map((item) => this.toBeneficiaryListItem(item)),
+ page: query.page,
+ pageSize: query.pageSize,
+ total: result.total,
+ };
+ }
+
+ async getBeneficiary(
+ publicId: string,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const beneficiary = await this.repository.findBeneficiary(publicId);
+ if (!beneficiary) {
+ throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案');
+ }
+ await this.audit.record(request, {
+ action: 'BENEFICIARY_DETAIL_VIEWED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'BENEFICIARY',
+ resourcePublicId: publicId,
+ });
+ return { beneficiary: this.toBeneficiaryDetail(beneficiary) };
+ }
+
+ async updateBeneficiary(
+ publicId: string,
+ input: UpdateBeneficiaryDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const existing = await this.repository.findBeneficiary(publicId);
+ if (!existing) {
+ throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案');
+ }
+ const guardians = input.contacts.filter((contact) => contact.isGuardian);
+ if (input.isMinor && (!guardians.length || input.guardianConsentConfirmed !== true)) {
+ throw this.validation('未成年人档案必须记录监护人及其明确授权');
+ }
+ try {
+ const prepared = this.prepareBeneficiary(input, existing.primaryCategory);
+ const updated = await this.repository.updateBeneficiary(
+ { ...existing },
+ { ...prepared, status: input.status },
+ actor,
+ request,
+ );
+ return { beneficiary: this.toBeneficiaryDetail(updated) };
+ } catch (error) {
+ if (error instanceof Error && error.message === 'Unknown beneficiary tag') {
+ throw this.validation('包含未知的服务标签');
+ }
+ throw error;
+ }
+ }
+
+ async assigneeOptions(): Promise {
+ const users = await this.repository.listAssigneeOptions();
+ return users.map((user) => ({
+ userPublicId: user.publicId,
+ displayName: user.staffProfile?.displayName ?? user.nickname ?? '志愿者',
+ type: user.staffProfile ? 'STAFF' : 'VOLUNTEER',
+ department: user.staffProfile?.department ?? null,
+ }));
+ }
+
+ async listCases(
+ query: ServiceCaseQueryDto,
+ actor: AuthContext,
+ ): Promise {
+ const result = await this.repository.listCases(
+ {
+ ...(query.status ? { status: query.status } : {}),
+ ...(query.category ? { category: query.category } : {}),
+ ...(query.caseNo?.trim() ? { caseNo: query.caseNo.trim().toUpperCase() } : {}),
+ },
+ actor.userId,
+ actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL),
+ query.page,
+ query.pageSize,
+ );
+ return {
+ items: result.items.map((item) => this.toCaseListItem(item)),
+ page: query.page,
+ pageSize: query.pageSize,
+ total: result.total,
+ };
+ }
+
+ async getCase(
+ publicId: string,
+ actor: AuthContext,
+ request: RequestContext,
+ minimal = false,
+ ): Promise {
+ const serviceCase = await this.requireAccessibleCase(publicId, actor);
+ await this.audit.record(request, {
+ action: minimal ? 'ASSIGNED_CASE_DETAIL_VIEWED' : 'SERVICE_CASE_DETAIL_VIEWED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'SERVICE_CASE',
+ resourcePublicId: publicId,
+ });
+ return { serviceCase: this.toCaseDetail(serviceCase, minimal) };
+ }
+
+ async startCase(
+ publicId: string,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const serviceCase = await this.requireAccessibleCase(publicId, actor);
+ if (serviceCase.status !== 'ASSIGNED' || serviceCase.ownerStaffId !== actor.userId) {
+ throw this.invalidTransition();
+ }
+ try {
+ const updated = await this.repository.startCase(serviceCase, actor, request);
+ if (!updated) throw this.invalidTransition();
+ return { serviceCase: this.toCaseDetail(updated) };
+ } catch (error) {
+ if (error instanceof Error && error.message === 'APPLICATION_STATE_CONFLICT') {
+ throw this.invalidTransition();
+ }
+ throw error;
+ }
+ }
+
+ async replaceAssignees(
+ publicId: string,
+ input: ReplaceCaseAssigneesDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const serviceCase = await this.requireAccessibleCase(publicId, actor);
+ const publicIds = [...new Set(input.collaboratorUserPublicIds)];
+ if (publicIds.length !== input.collaboratorUserPublicIds.length) {
+ throw this.validation('协作人员不能重复');
+ }
+ const users = await this.repository.findCollaboratorCandidates(publicIds);
+ if (users.length !== publicIds.length) throw this.validation('存在无效或未获授权的协作人员');
+ const updated = await this.repository.replaceCollaborators(
+ serviceCase,
+ users.map((user) => ({ userId: user.id, type: user.staffProfile ? 'STAFF' : 'VOLUNTEER' })),
+ actor,
+ request,
+ );
+ return { serviceCase: this.toCaseDetail(updated) };
+ }
+
+ async createRecord(
+ casePublicId: string,
+ input: CreateServiceRecordDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const serviceCase = await this.requireAssignedCase(casePublicId, actor);
+ const normalized = {
+ serviceStartAt: input.serviceStartAt,
+ serviceEndAt: input.serviceEndAt ?? null,
+ serviceType: input.serviceType,
+ content: input.content.trim(),
+ result: input.result.trim(),
+ followUpSuggestion: input.followUpSuggestion?.trim() || null,
+ beneficiaryConfirmStatus: input.beneficiaryConfirmStatus,
+ attachmentPublicIds: [...new Set(input.attachmentPublicIds)].sort(),
+ };
+ const fingerprint = this.sensitiveData.lookupHash(
+ 'service-record-request',
+ JSON.stringify(normalized),
+ );
+ const previous = await this.repository.findRecordByIdempotency(
+ actor.userId,
+ input.clientRequestId,
+ );
+ if (previous) {
+ if (previous.requestFingerprint !== fingerprint) throw this.duplicateRequest();
+ return { record: this.toServiceRecord(previous), idempotentReplay: true };
+ }
+ const start = new Date(input.serviceStartAt);
+ const end = input.serviceEndAt ? new Date(input.serviceEndAt) : undefined;
+ if (end && end < start) throw this.validation('服务结束时间不能早于开始时间');
+ if (serviceCase.status !== 'IN_SERVICE') throw this.invalidTransition();
+ try {
+ const record = await this.repository.createRecord(
+ serviceCase,
+ {
+ publicId: randomUUID(),
+ clientRequestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ serviceStartAt: start,
+ serviceEndAt: end,
+ serviceType: input.serviceType,
+ contentEncrypted: this.sensitiveData.encrypt(normalized.content),
+ resultEncrypted: this.sensitiveData.encrypt(normalized.result),
+ followUpSuggestionEncrypted: normalized.followUpSuggestion
+ ? this.sensitiveData.encrypt(normalized.followUpSuggestion)
+ : undefined,
+ beneficiaryConfirmStatus: input.beneficiaryConfirmStatus,
+ attachmentPublicIds: normalized.attachmentPublicIds,
+ },
+ actor,
+ request,
+ );
+ return { record: this.toServiceRecord(record), idempotentReplay: false };
+ } catch (error) {
+ if (error instanceof Error && error.message === 'FILE_INVALID') {
+ throw this.validation('附件不存在、尚未确认或不属于当前提交人');
+ }
+ if (error instanceof Error && error.message === 'CASE_RECORD_FORBIDDEN') {
+ throw this.invalidTransition();
+ }
+ throw error;
+ }
+ }
+
+ async updateRecord(
+ casePublicId: string,
+ recordPublicId: string,
+ input: UpdateServiceRecordDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const serviceCase = await this.requireAssignedCase(casePublicId, actor);
+ const record = await this.repository.findRecord(recordPublicId);
+ if (!record || record.caseId !== serviceCase.id) {
+ throw this.notFound(API_ERROR_CODES.SERVICE_RECORD_NOT_FOUND, '未找到该服务记录');
+ }
+ if (
+ record.createdById !== actor.userId &&
+ serviceCase.ownerStaffId !== actor.userId &&
+ !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
+ ) {
+ throw this.notFound(API_ERROR_CODES.SERVICE_RECORD_NOT_FOUND, '未找到该服务记录');
+ }
+ const start = new Date(input.serviceStartAt);
+ const end = input.serviceEndAt ? new Date(input.serviceEndAt) : undefined;
+ if (end && end < start) throw this.validation('服务结束时间不能早于开始时间');
+ try {
+ const updated = await this.repository.updateRecord(
+ record,
+ {
+ expectedRevision: input.expectedRevision,
+ serviceStartAt: start,
+ serviceEndAt: end,
+ serviceType: input.serviceType,
+ contentEncrypted: this.sensitiveData.encrypt(input.content.trim()),
+ resultEncrypted: this.sensitiveData.encrypt(input.result.trim()),
+ followUpSuggestionEncrypted: input.followUpSuggestion?.trim()
+ ? this.sensitiveData.encrypt(input.followUpSuggestion.trim())
+ : undefined,
+ beneficiaryConfirmStatus: input.beneficiaryConfirmStatus,
+ attachmentPublicIds: [...new Set(input.attachmentPublicIds)],
+ },
+ actor,
+ request,
+ );
+ return { record: this.toServiceRecord(updated) };
+ } catch (error) {
+ if (error instanceof Error && error.message === 'RECORD_REVISION_CONFLICT') {
+ throw new ApplicationError(
+ API_ERROR_CODES.INVALID_STATE_TRANSITION,
+ '服务记录已被更新,请刷新后重试',
+ HttpStatus.CONFLICT,
+ );
+ }
+ if (error instanceof Error && error.message === 'FILE_INVALID') {
+ throw this.validation('附件不存在、尚未确认或无权关联');
+ }
+ throw error;
+ }
+ }
+
+ async requireAssignedCase(publicId: string, actor: AuthContext): Promise {
+ const serviceCase = await this.repository.findCase(publicId);
+ if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
+ const assigned = await this.repository.isAssigned(serviceCase.id, actor.userId);
+ if (!assigned) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
+ return serviceCase;
+ }
+
+ async requireAssignedCaseByIdForFile(
+ caseId: bigint,
+ actor: AuthContext,
+ ): Promise {
+ const serviceCase = await this.repository.findCaseById(caseId);
+ if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
+ if (!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)) {
+ const assigned = await this.repository.isAssigned(caseId, actor.userId);
+ if (!assigned) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
+ }
+ return serviceCase;
+ }
+
+ private async requireAccessibleCase(
+ publicId: string,
+ actor: AuthContext,
+ ): Promise {
+ const serviceCase = await this.repository.findCase(publicId);
+ if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
+ if (!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)) {
+ const assigned = await this.repository.isAssigned(serviceCase.id, actor.userId);
+ if (!assigned) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务工单');
+ }
+ return serviceCase;
+ }
+
+ private normalizeAcceptance(input: AcceptServiceApplicationDto) {
+ return {
+ clientRequestId: input.clientRequestId,
+ beneficiary: input.beneficiary,
+ ownerStaffPublicId: input.ownerStaffPublicId,
+ collaboratorUserPublicIds: [...input.collaboratorUserPublicIds].sort(),
+ priority: input.priority,
+ plannedStartAt: input.plannedStartAt ?? null,
+ plannedEndAt: input.plannedEndAt ?? null,
+ serviceLocation: input.serviceLocation?.trim() || null,
+ summary: input.summary.trim(),
+ };
+ }
+
+ private prepareBeneficiary(
+ profile:
+ NonNullable | UpdateBeneficiaryRequest,
+ primaryCategory: PreparedBeneficiary['primaryCategory'],
+ ): PreparedBeneficiary {
+ const displayName = profile.displayName.trim();
+ const mobile = profile.mobile?.trim();
+ return {
+ publicId: randomUUID(),
+ displayNameEncrypted: this.sensitiveData.encrypt(displayName),
+ displayNameHash: this.sensitiveData.lookupHash('beneficiary-display-name', displayName),
+ gender: profile.gender,
+ birthYear: profile.birthYear,
+ mobileEncrypted: mobile ? this.sensitiveData.encrypt(mobile) : undefined,
+ mobileHash: mobile ? this.sensitiveData.lookupHash('beneficiary-mobile', mobile) : undefined,
+ regionCode: profile.regionCode.trim(),
+ addressDetailEncrypted: profile.addressDetail?.trim()
+ ? this.sensitiveData.encrypt(profile.addressDetail.trim())
+ : undefined,
+ primaryCategory,
+ keyCareFlag: profile.keyCareFlag,
+ isMinor: profile.isMinor,
+ notesEncrypted: profile.notes?.trim()
+ ? this.sensitiveData.encrypt(profile.notes.trim())
+ : undefined,
+ contacts: profile.contacts.map((contact) => ({
+ publicId: randomUUID(),
+ nameEncrypted: this.sensitiveData.encrypt(contact.name.trim()),
+ relation: contact.relation.trim(),
+ mobileEncrypted: this.sensitiveData.encrypt(contact.mobile.trim()),
+ mobileHash: this.sensitiveData.lookupHash(
+ 'beneficiary-contact-mobile',
+ contact.mobile.trim(),
+ ),
+ isGuardian: contact.isGuardian,
+ isEmergencyContact: contact.isEmergencyContact,
+ })),
+ tagCodes: [...new Set(profile.tagCodes)],
+ guardianConsentConfirmed: profile.guardianConsentConfirmed === true,
+ };
+ }
+
+ private toBeneficiaryListItem(beneficiary: StoredBeneficiary): BeneficiaryListItem {
+ const mobile = beneficiary.mobileEncrypted
+ ? this.sensitiveData.decrypt(beneficiary.mobileEncrypted)
+ : null;
+ return {
+ publicId: beneficiary.publicId,
+ displayName: this.sensitiveData.decrypt(beneficiary.displayNameEncrypted),
+ gender: beneficiary.gender,
+ birthYear: beneficiary.birthYear,
+ mobileMasked: mobile ? maskMobile(mobile) : null,
+ regionCode: beneficiary.regionCode,
+ primaryCategory: beneficiary.primaryCategory,
+ keyCareFlag: beneficiary.keyCareFlag,
+ isMinor: beneficiary.isMinor,
+ status: beneficiary.status,
+ tags: beneficiary.beneficiaryTags.map(({ tag }) => ({
+ code: tag.code,
+ name: tag.name,
+ description: tag.description,
+ })),
+ createdAt: beneficiary.createdAt.toISOString(),
+ };
+ }
+
+ private toBeneficiaryDetail(beneficiary: StoredBeneficiary): BeneficiaryDetail {
+ const mobile = beneficiary.mobileEncrypted
+ ? this.sensitiveData.decrypt(beneficiary.mobileEncrypted)
+ : null;
+ return {
+ ...this.toBeneficiaryListItem(beneficiary),
+ mobile,
+ addressDetail: beneficiary.addressDetailEncrypted
+ ? this.sensitiveData.decrypt(beneficiary.addressDetailEncrypted)
+ : null,
+ notes: beneficiary.notesEncrypted
+ ? this.sensitiveData.decrypt(beneficiary.notesEncrypted)
+ : null,
+ contacts: beneficiary.contacts.map((contact) => {
+ const contactMobile = this.sensitiveData.decrypt(contact.mobileEncrypted);
+ return {
+ publicId: contact.publicId,
+ name: this.sensitiveData.decrypt(contact.nameEncrypted),
+ relation: contact.relation,
+ mobile: contactMobile,
+ mobileMasked: maskMobile(contactMobile),
+ isGuardian: contact.isGuardian,
+ isEmergencyContact: contact.isEmergencyContact,
+ hasConsentRecord: contact.consentRecordId !== null,
+ };
+ }),
+ serviceCaseCount: beneficiary._count.serviceCases,
+ };
+ }
+
+ private toCaseListItem(serviceCase: StoredServiceCase): ServiceCaseListItem {
+ return {
+ publicId: serviceCase.publicId,
+ caseNo: serviceCase.caseNo,
+ applicationPublicId: serviceCase.application.publicId,
+ applicationNo: serviceCase.application.applicationNo,
+ beneficiaryPublicId: serviceCase.beneficiary.publicId,
+ beneficiaryDisplayName: this.sensitiveData.decrypt(
+ serviceCase.beneficiary.displayNameEncrypted,
+ ),
+ category: serviceCase.category,
+ priority: serviceCase.priority,
+ priorityLabel: SERVICE_CASE_PRIORITY_LABELS[serviceCase.priority],
+ status: serviceCase.status,
+ statusLabel: SERVICE_CASE_STATUS_LABELS[serviceCase.status],
+ ownerDisplayName: serviceCase.owner.staffProfile?.displayName ?? '工作人员',
+ plannedStartAt: serviceCase.plannedStartAt?.toISOString() ?? null,
+ createdAt: serviceCase.createdAt.toISOString(),
+ };
+ }
+
+ private toCaseDetail(serviceCase: StoredServiceCase, minimal = false): ServiceCaseDetail {
+ const mobile = serviceCase.beneficiary.mobileEncrypted
+ ? this.sensitiveData.decrypt(serviceCase.beneficiary.mobileEncrypted)
+ : null;
+ return {
+ ...this.toCaseListItem(serviceCase),
+ regionCode: serviceCase.beneficiary.regionCode,
+ beneficiaryMobile: minimal ? null : mobile,
+ beneficiaryMobileMasked: mobile ? maskMobile(mobile) : null,
+ serviceLocation: serviceCase.serviceLocationEncrypted
+ ? this.sensitiveData.decrypt(serviceCase.serviceLocationEncrypted)
+ : null,
+ summary: this.sensitiveData.decrypt(serviceCase.summaryEncrypted),
+ resultSummary: serviceCase.resultSummaryEncrypted
+ ? this.sensitiveData.decrypt(serviceCase.resultSummaryEncrypted)
+ : null,
+ followUpPlan: serviceCase.followUpPlanEncrypted
+ ? this.sensitiveData.decrypt(serviceCase.followUpPlanEncrypted)
+ : null,
+ completedAt: serviceCase.completedAt?.toISOString() ?? null,
+ assignees: serviceCase.assignees.map((assignee) => ({
+ userPublicId: assignee.user.publicId,
+ displayName: assignee.user.staffProfile?.displayName ?? assignee.user.nickname ?? '志愿者',
+ type: assignee.assigneeType,
+ department: assignee.user.staffProfile?.department ?? null,
+ role: assignee.role,
+ assignedAt: assignee.assignedAt.toISOString(),
+ })),
+ history: serviceCase.statusHistory.map((history) => ({
+ status: history.toStatus,
+ statusLabel: SERVICE_CASE_STATUS_LABELS[history.toStatus],
+ reasonLabel:
+ history.reasonCode === 'CASE_CREATED_AND_ASSIGNED'
+ ? '工单已创建并指派'
+ : history.reasonCode === 'SERVICE_COMPLETED'
+ ? '本阶段服务已完成并创建回访'
+ : '服务已开始',
+ createdAt: history.createdAt.toISOString(),
+ })),
+ records: serviceCase.serviceRecords.map((record) => this.toServiceRecord(record)),
+ };
+ }
+
+ private toServiceRecord(record: StoredServiceCase['serviceRecords'][number]): ServiceRecordItem {
+ return {
+ publicId: record.publicId,
+ serviceStartAt: record.serviceStartAt.toISOString(),
+ serviceEndAt: record.serviceEndAt?.toISOString() ?? null,
+ serviceType: record.serviceType,
+ serviceTypeLabel: SERVICE_RECORD_TYPE_LABELS[record.serviceType],
+ content: this.sensitiveData.decrypt(record.contentEncrypted),
+ result: this.sensitiveData.decrypt(record.resultEncrypted),
+ followUpSuggestion: record.followUpSuggestionEncrypted
+ ? this.sensitiveData.decrypt(record.followUpSuggestionEncrypted)
+ : null,
+ beneficiaryConfirmStatus: record.beneficiaryConfirmStatus,
+ createdByDisplayName:
+ record.creator.staffProfile?.displayName ?? record.creator.nickname ?? '志愿者',
+ revision: record.revision,
+ attachments: record.attachments.map(({ file }) => ({
+ publicId: file.publicId,
+ filename: this.sensitiveData.decrypt(file.originalFilenameEncrypted),
+ mimeType: file.mimeType,
+ size: Number(file.size),
+ })),
+ createdAt: record.createdAt.toISOString(),
+ updatedAt: record.updatedAt.toISOString(),
+ };
+ }
+
+ private invalidTransition(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.INVALID_STATE_TRANSITION,
+ '当前状态不能执行此操作,请刷新后重试',
+ HttpStatus.CONFLICT,
+ );
+ }
+
+ private duplicateRequest(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.DUPLICATE_REQUEST,
+ '本次提交标识已用于其他内容,请重新操作',
+ HttpStatus.CONFLICT,
+ );
+ }
+
+ private validation(message: string): ApplicationError {
+ return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
+ }
+
+ private notFound(code: string, message: string): ApplicationError {
+ return new ApplicationError(code as never, message, HttpStatus.NOT_FOUND);
+ }
+}
diff --git a/apps/api/src/case-management/dto/case-management.dto.ts b/apps/api/src/case-management/dto/case-management.dto.ts
new file mode 100644
index 0000000..eec91f4
--- /dev/null
+++ b/apps/api/src/case-management/dto/case-management.dto.ts
@@ -0,0 +1,331 @@
+import { Transform, Type } from 'class-transformer';
+import {
+ ArrayMaxSize,
+ IsArray,
+ IsBoolean,
+ IsIn,
+ IsInt,
+ IsISO8601,
+ IsOptional,
+ IsString,
+ IsUUID,
+ Length,
+ Matches,
+ Max,
+ MaxLength,
+ Min,
+ MinLength,
+ ValidateNested,
+} from 'class-validator';
+
+import {
+ BENEFICIARY_CONFIRM_STATUSES,
+ BENEFICIARY_GENDERS,
+ BENEFICIARY_STATUSES,
+ SERVICE_CASE_PRIORITIES,
+ SERVICE_CASE_STATUSES,
+ SERVICE_CATEGORIES,
+ SERVICE_RECORD_TYPES,
+} from '@lehuo/contracts';
+
+const MOBILE_PATTERN = /^1[3-9]\d{9}$/;
+const TAG_CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/;
+
+export class PaginationDto {
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ page = 1;
+
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(100)
+ pageSize = 20;
+}
+
+export class BeneficiaryContactDto {
+ @IsString()
+ @Length(1, 50)
+ name!: string;
+
+ @IsString()
+ @Length(1, 50)
+ relation!: string;
+
+ @Matches(MOBILE_PATTERN)
+ mobile!: string;
+
+ @IsBoolean()
+ isGuardian!: boolean;
+
+ @IsBoolean()
+ isEmergencyContact!: boolean;
+}
+
+export class BeneficiaryProfileDto {
+ @IsString()
+ @Length(1, 50)
+ displayName!: string;
+
+ @IsOptional()
+ @IsIn(BENEFICIARY_GENDERS)
+ gender?: (typeof BENEFICIARY_GENDERS)[number];
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1900)
+ @Max(2200)
+ birthYear?: number;
+
+ @IsOptional()
+ @Matches(MOBILE_PATTERN)
+ mobile?: string;
+
+ @IsString()
+ @Length(2, 100)
+ regionCode!: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(300)
+ addressDetail?: string;
+
+ @IsBoolean()
+ keyCareFlag!: boolean;
+
+ @IsBoolean()
+ isMinor!: boolean;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(1000)
+ notes?: string;
+
+ @IsArray()
+ @ArrayMaxSize(10)
+ @ValidateNested({ each: true })
+ @Type(() => BeneficiaryContactDto)
+ contacts!: BeneficiaryContactDto[];
+
+ @IsArray()
+ @ArrayMaxSize(20)
+ @Matches(TAG_CODE_PATTERN, { each: true })
+ tagCodes!: string[];
+
+ @IsOptional()
+ @IsIn([true])
+ guardianConsentConfirmed?: true;
+}
+
+export class BeneficiarySelectionDto {
+ @IsIn(['CREATE', 'LINK_EXISTING'])
+ mode!: 'CREATE' | 'LINK_EXISTING';
+
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => BeneficiaryProfileDto)
+ profile?: BeneficiaryProfileDto;
+
+ @IsOptional()
+ @IsUUID('4')
+ beneficiaryPublicId?: string;
+
+ @IsOptional()
+ @IsIn([true])
+ manualMatchConfirmed?: true;
+}
+
+export class AcceptServiceApplicationDto {
+ @IsUUID('4')
+ clientRequestId!: string;
+
+ @ValidateNested()
+ @Type(() => BeneficiarySelectionDto)
+ beneficiary!: BeneficiarySelectionDto;
+
+ @IsUUID('4')
+ ownerStaffPublicId!: string;
+
+ @IsArray()
+ @ArrayMaxSize(20)
+ @IsUUID('4', { each: true })
+ collaboratorUserPublicIds!: string[];
+
+ @IsIn(SERVICE_CASE_PRIORITIES)
+ priority!: (typeof SERVICE_CASE_PRIORITIES)[number];
+
+ @IsOptional()
+ @IsISO8601({ strict: true })
+ plannedStartAt?: string;
+
+ @IsOptional()
+ @IsISO8601({ strict: true })
+ plannedEndAt?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(300)
+ serviceLocation?: string;
+
+ @IsString()
+ @Length(10, 2000)
+ summary!: string;
+}
+
+export class TransitionServiceApplicationDto {
+ @IsIn(['PENDING_CONTACT', 'ASSESSING'])
+ targetStatus!: 'PENDING_CONTACT' | 'ASSESSING';
+
+ @IsString()
+ @Matches(/^[A-Z][A-Z0-9_]{2,99}$/)
+ reasonCode!: string;
+}
+
+export class BeneficiaryQueryDto extends PaginationDto {
+ @IsOptional()
+ @IsIn(SERVICE_CATEGORIES)
+ primaryCategory?: (typeof SERVICE_CATEGORIES)[number];
+
+ @IsOptional()
+ @IsIn(BENEFICIARY_STATUSES)
+ status?: (typeof BENEFICIARY_STATUSES)[number];
+
+ @IsOptional()
+ @Transform(({ value }) => {
+ const candidate: unknown = value;
+ if (candidate === 'true' || candidate === true) return true;
+ if (candidate === 'false' || candidate === false) return false;
+ return candidate;
+ })
+ @IsBoolean()
+ keyCareFlag?: boolean;
+}
+
+export class UpdateBeneficiaryDto extends BeneficiaryProfileDto {
+ @IsIn(BENEFICIARY_STATUSES)
+ status!: (typeof BENEFICIARY_STATUSES)[number];
+}
+
+export class ServiceCaseQueryDto extends PaginationDto {
+ @IsOptional()
+ @IsIn(SERVICE_CASE_STATUSES)
+ status?: (typeof SERVICE_CASE_STATUSES)[number];
+
+ @IsOptional()
+ @IsIn(SERVICE_CATEGORIES)
+ category?: (typeof SERVICE_CATEGORIES)[number];
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(32)
+ caseNo?: string;
+}
+
+export class ReplaceCaseAssigneesDto {
+ @IsArray()
+ @ArrayMaxSize(20)
+ @IsUUID('4', { each: true })
+ collaboratorUserPublicIds!: string[];
+}
+
+export class CreateServiceRecordDto {
+ @IsUUID('4')
+ clientRequestId!: string;
+
+ @IsISO8601({ strict: true })
+ serviceStartAt!: string;
+
+ @IsOptional()
+ @IsISO8601({ strict: true })
+ serviceEndAt?: string;
+
+ @IsIn(SERVICE_RECORD_TYPES)
+ serviceType!: (typeof SERVICE_RECORD_TYPES)[number];
+
+ @IsString()
+ @MinLength(2)
+ @MaxLength(5000)
+ content!: string;
+
+ @IsString()
+ @MinLength(2)
+ @MaxLength(5000)
+ result!: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(2000)
+ followUpSuggestion?: string;
+
+ @IsIn(BENEFICIARY_CONFIRM_STATUSES)
+ beneficiaryConfirmStatus!: (typeof BENEFICIARY_CONFIRM_STATUSES)[number];
+
+ @IsArray()
+ @ArrayMaxSize(9)
+ @IsUUID('4', { each: true })
+ attachmentPublicIds!: string[];
+}
+
+export class UpdateServiceRecordDto {
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ expectedRevision!: number;
+
+ @IsISO8601({ strict: true })
+ serviceStartAt!: string;
+
+ @IsOptional()
+ @IsISO8601({ strict: true })
+ serviceEndAt?: string;
+
+ @IsIn(SERVICE_RECORD_TYPES)
+ serviceType!: (typeof SERVICE_RECORD_TYPES)[number];
+
+ @IsString()
+ @MinLength(2)
+ @MaxLength(5000)
+ content!: string;
+
+ @IsString()
+ @MinLength(2)
+ @MaxLength(5000)
+ result!: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(2000)
+ followUpSuggestion?: string;
+
+ @IsIn(BENEFICIARY_CONFIRM_STATUSES)
+ beneficiaryConfirmStatus!: (typeof BENEFICIARY_CONFIRM_STATUSES)[number];
+
+ @IsArray()
+ @ArrayMaxSize(9)
+ @IsUUID('4', { each: true })
+ attachmentPublicIds!: string[];
+}
+
+export class CreateUploadIntentDto {
+ @IsUUID('4')
+ clientRequestId!: string;
+
+ @IsString()
+ @Length(1, 255)
+ filename!: string;
+
+ @IsIn(['image/jpeg', 'image/png', 'image/webp'])
+ mimeType!: 'image/jpeg' | 'image/png' | 'image/webp';
+
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(10 * 1024 * 1024)
+ size!: number;
+
+ @IsOptional()
+ @Matches(/^[a-f0-9]{64}$/)
+ sha256?: string;
+}
diff --git a/apps/api/src/common/api-exception.filter.ts b/apps/api/src/common/api-exception.filter.ts
new file mode 100644
index 0000000..cfd204f
--- /dev/null
+++ b/apps/api/src/common/api-exception.filter.ts
@@ -0,0 +1,100 @@
+import {
+ ArgumentsHost,
+ BadRequestException,
+ Catch,
+ ForbiddenException,
+ HttpException,
+ Logger,
+ UnauthorizedException,
+ type ExceptionFilter,
+} from '@nestjs/common';
+import { ThrottlerException } from '@nestjs/throttler';
+import type { Response } from 'express';
+
+import { API_ERROR_CODES, type ApiErrorCode, type ApiErrorResponse } from '@lehuo/contracts';
+
+import { ApplicationError } from './application-error';
+import type { RequestContext } from './request-context';
+
+@Catch()
+export class ApiExceptionFilter implements ExceptionFilter {
+ private readonly logger = new Logger(ApiExceptionFilter.name);
+
+ catch(exception: unknown, host: ArgumentsHost): void {
+ const http = host.switchToHttp();
+ const request = http.getRequest();
+ const response = http.getResponse();
+ const mapped = this.mapException(exception);
+ if (mapped.status >= 500) {
+ const name = exception instanceof Error ? exception.name : 'UnknownError';
+ this.logger.error(`Unhandled ${name}; requestId=${request.requestId}`);
+ }
+ response.status(mapped.status).json({
+ success: false,
+ error: {
+ code: mapped.code,
+ message: mapped.message,
+ ...(mapped.details ? { details: mapped.details } : {}),
+ },
+ requestId: request.requestId,
+ } satisfies ApiErrorResponse);
+ }
+
+ private mapException(exception: unknown): {
+ status: number;
+ code: ApiErrorCode;
+ message: string;
+ details?: Record;
+ } {
+ if (exception instanceof ApplicationError) {
+ return {
+ status: exception.getStatus(),
+ code: exception.code,
+ message: exception.message,
+ ...(exception.details ? { details: exception.details } : {}),
+ };
+ }
+ if (exception instanceof ThrottlerException) {
+ return {
+ status: 429,
+ code: API_ERROR_CODES.RATE_LIMITED,
+ message: '操作过于频繁,请稍后再试',
+ };
+ }
+ if (exception instanceof UnauthorizedException) {
+ return { status: 401, code: API_ERROR_CODES.AUTH_REQUIRED, message: '请先登录后再继续' };
+ }
+ if (exception instanceof ForbiddenException) {
+ return { status: 403, code: API_ERROR_CODES.FORBIDDEN, message: '您暂无权限执行此操作' };
+ }
+ if (exception instanceof BadRequestException) {
+ const response = exception.getResponse();
+ const messages =
+ typeof response === 'object' && response && 'message' in response ? response.message : null;
+ return {
+ status: 400,
+ code: API_ERROR_CODES.VALIDATION_ERROR,
+ message: '请检查填写内容后重试',
+ ...(Array.isArray(messages)
+ ? {
+ details: {
+ fields: messages.filter((item): item is string => typeof item === 'string'),
+ },
+ }
+ : {}),
+ };
+ }
+ if (exception instanceof HttpException) {
+ return {
+ status: exception.getStatus(),
+ code: API_ERROR_CODES.INTERNAL_ERROR,
+ message: exception.getStatus() >= 500 ? '服务暂时不可用,请稍后重试' : exception.message,
+ };
+ }
+ return {
+ status: 500,
+ code: API_ERROR_CODES.INTERNAL_ERROR,
+ message: '服务暂时不可用,请稍后重试',
+ };
+ }
+}
diff --git a/apps/api/src/common/api-response.interceptor.ts b/apps/api/src/common/api-response.interceptor.ts
new file mode 100644
index 0000000..128ba8f
--- /dev/null
+++ b/apps/api/src/common/api-response.interceptor.ts
@@ -0,0 +1,36 @@
+import {
+ Inject,
+ Injectable,
+ type CallHandler,
+ type ExecutionContext,
+ type NestInterceptor,
+} from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import type { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import type { ApiSuccessResponse } from '@lehuo/contracts';
+
+import { RAW_RESPONSE_KEY } from './decorators/raw-response.decorator';
+import type { RequestContext } from './request-context';
+
+@Injectable()
+export class ApiResponseInterceptor implements NestInterceptor {
+ constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
+
+ intercept(context: ExecutionContext, next: CallHandler): Observable {
+ const raw = this.reflector.getAllAndOverride(RAW_RESPONSE_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+ if (raw) return next.handle();
+ const request = context.switchToHttp().getRequest();
+ return next.handle().pipe(
+ map((data: unknown): ApiSuccessResponse => ({
+ success: true,
+ data,
+ requestId: request.requestId,
+ })),
+ );
+ }
+}
diff --git a/apps/api/src/common/application-error.ts b/apps/api/src/common/application-error.ts
new file mode 100644
index 0000000..7c855da
--- /dev/null
+++ b/apps/api/src/common/application-error.ts
@@ -0,0 +1,14 @@
+import { HttpException, type HttpStatus } from '@nestjs/common';
+
+import type { ApiErrorCode } from '@lehuo/contracts';
+
+export class ApplicationError extends HttpException {
+ constructor(
+ readonly code: ApiErrorCode,
+ message: string,
+ status: HttpStatus,
+ readonly details?: Record,
+ ) {
+ super(message, status);
+ }
+}
diff --git a/apps/api/src/common/decorators/current-auth.decorator.ts b/apps/api/src/common/decorators/current-auth.decorator.ts
new file mode 100644
index 0000000..87e8300
--- /dev/null
+++ b/apps/api/src/common/decorators/current-auth.decorator.ts
@@ -0,0 +1,11 @@
+import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
+
+import type { AuthContext, RequestContext } from '../request-context';
+
+export const CurrentAuth = createParamDecorator(
+ (_data: unknown, context: ExecutionContext): AuthContext => {
+ const request = context.switchToHttp().getRequest();
+ if (!request.auth) throw new Error('Authentication context is unavailable');
+ return request.auth;
+ },
+);
diff --git a/apps/api/src/common/decorators/public.decorator.ts b/apps/api/src/common/decorators/public.decorator.ts
new file mode 100644
index 0000000..089f550
--- /dev/null
+++ b/apps/api/src/common/decorators/public.decorator.ts
@@ -0,0 +1,4 @@
+import { SetMetadata } from '@nestjs/common';
+
+export const IS_PUBLIC_KEY = 'isPublic';
+export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
diff --git a/apps/api/src/common/decorators/raw-response.decorator.ts b/apps/api/src/common/decorators/raw-response.decorator.ts
new file mode 100644
index 0000000..18e7780
--- /dev/null
+++ b/apps/api/src/common/decorators/raw-response.decorator.ts
@@ -0,0 +1,5 @@
+import { SetMetadata } from '@nestjs/common';
+
+export const RAW_RESPONSE_KEY = 'rawResponse';
+export const RawResponse = (): MethodDecorator & ClassDecorator =>
+ SetMetadata(RAW_RESPONSE_KEY, true);
diff --git a/apps/api/src/common/decorators/require-permissions.decorator.ts b/apps/api/src/common/decorators/require-permissions.decorator.ts
new file mode 100644
index 0000000..22629dd
--- /dev/null
+++ b/apps/api/src/common/decorators/require-permissions.decorator.ts
@@ -0,0 +1,8 @@
+import { SetMetadata } from '@nestjs/common';
+
+import type { PermissionCode } from '@lehuo/contracts';
+
+export const REQUIRED_PERMISSIONS_KEY = 'requiredPermissions';
+export const RequirePermissions = (
+ ...permissions: PermissionCode[]
+): MethodDecorator & ClassDecorator => SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions);
diff --git a/apps/api/src/common/request-context.ts b/apps/api/src/common/request-context.ts
new file mode 100644
index 0000000..9d57a73
--- /dev/null
+++ b/apps/api/src/common/request-context.ts
@@ -0,0 +1,23 @@
+import type { Request } from 'express';
+
+import type { PermissionCode, RoleCode, UserStatus, UserType } from '@lehuo/contracts';
+
+import type { SessionChannel } from '../generated/prisma/enums';
+
+export interface AuthContext {
+ userId: bigint;
+ userPublicId: string;
+ status: UserStatus;
+ userType: UserType;
+ displayName: string | null;
+ roles: RoleCode[];
+ permissions: PermissionCode[];
+ sessionId: bigint;
+ channel: SessionChannel;
+ rawToken: string;
+}
+
+export interface RequestContext extends Request {
+ requestId: string;
+ auth?: AuthContext;
+}
diff --git a/apps/api/src/common/request-id.middleware.ts b/apps/api/src/common/request-id.middleware.ts
new file mode 100644
index 0000000..ae8d1b1
--- /dev/null
+++ b/apps/api/src/common/request-id.middleware.ts
@@ -0,0 +1,17 @@
+import { Injectable, type NestMiddleware } from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
+import type { NextFunction, Response } from 'express';
+
+import type { RequestContext } from './request-context';
+
+const REQUEST_ID_PATTERN = /^[a-zA-Z0-9_-]{8,64}$/;
+
+@Injectable()
+export class RequestIdMiddleware implements NestMiddleware {
+ use(request: RequestContext, response: Response, next: NextFunction): void {
+ const supplied = request.header('x-request-id');
+ request.requestId = supplied && REQUEST_ID_PATTERN.test(supplied) ? supplied : randomUUID();
+ response.setHeader('x-request-id', request.requestId);
+ next();
+ }
+}
diff --git a/apps/api/src/common/request-metrics.interceptor.ts b/apps/api/src/common/request-metrics.interceptor.ts
new file mode 100644
index 0000000..0886c2b
--- /dev/null
+++ b/apps/api/src/common/request-metrics.interceptor.ts
@@ -0,0 +1,33 @@
+import {
+ Inject,
+ Injectable,
+ type CallHandler,
+ type ExecutionContext,
+ type NestInterceptor,
+} from '@nestjs/common';
+import { catchError, finalize, type Observable, throwError } from 'rxjs';
+import { RequestMetricsService } from './request-metrics.service';
+@Injectable()
+export class RequestMetricsInterceptor implements NestInterceptor {
+ constructor(@Inject(RequestMetricsService) private readonly metrics: RequestMetricsService) {}
+ intercept(context: ExecutionContext, next: CallHandler): Observable {
+ const start = performance.now();
+ let status = 200;
+ return next.handle().pipe(
+ catchError((e: unknown) => {
+ status =
+ typeof e === 'object' && e !== null && 'status' in e && typeof e.status === 'number'
+ ? e.status
+ : 500;
+ return throwError(() => e);
+ }),
+ finalize(() => {
+ const response = context.switchToHttp().getResponse<{ statusCode: number }>();
+ this.metrics.record(
+ status === 200 ? response.statusCode : status,
+ performance.now() - start,
+ );
+ }),
+ );
+ }
+}
diff --git a/apps/api/src/common/request-metrics.service.ts b/apps/api/src/common/request-metrics.service.ts
new file mode 100644
index 0000000..4f92ebf
--- /dev/null
+++ b/apps/api/src/common/request-metrics.service.ts
@@ -0,0 +1,22 @@
+import { Injectable } from '@nestjs/common';
+@Injectable()
+export class RequestMetricsService {
+ private count = 0;
+ private errors = 0;
+ private durations: number[] = [];
+ record(status: number, duration: number) {
+ this.count++;
+ if (status >= 500) this.errors++;
+ this.durations.push(duration);
+ if (this.durations.length > 1000) this.durations.shift();
+ }
+ snapshot() {
+ const sorted = [...this.durations].sort((a, b) => a - b);
+ return {
+ requestCount: this.count,
+ serverErrorCount: this.errors,
+ p95Milliseconds: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
+ sampleSize: sorted.length,
+ };
+ }
+}
diff --git a/apps/api/src/common/system-metrics.controller.ts b/apps/api/src/common/system-metrics.controller.ts
new file mode 100644
index 0000000..38deffe
--- /dev/null
+++ b/apps/api/src/common/system-metrics.controller.ts
@@ -0,0 +1,18 @@
+import { Controller, Get, Inject } from '@nestjs/common';
+import { PERMISSION_CODES } from '@lehuo/contracts';
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { RequirePermissions } from './decorators/require-permissions.decorator';
+import { SessionChannel } from '../generated/prisma/enums';
+import { RequestMetricsService } from './request-metrics.service';
+@Controller('api/v1/admin/system')
+@RequireSessionChannel(SessionChannel.ADMIN)
+export class SystemMetricsController {
+ constructor(@Inject(RequestMetricsService) private readonly metrics: RequestMetricsService) {}
+ @Get('metrics') @RequirePermissions(PERMISSION_CODES.SYSTEM_MANAGE) get() {
+ return {
+ ...this.metrics.snapshot(),
+ uptimeSeconds: Math.floor(process.uptime()),
+ memory: process.memoryUsage().rss,
+ };
+ }
+}
diff --git a/apps/api/src/config/app-config.module.ts b/apps/api/src/config/app-config.module.ts
new file mode 100644
index 0000000..f967695
--- /dev/null
+++ b/apps/api/src/config/app-config.module.ts
@@ -0,0 +1,10 @@
+import { Global, Module } from '@nestjs/common';
+
+import { AppConfigService } from './app-config.service';
+
+@Global()
+@Module({
+ providers: [AppConfigService],
+ exports: [AppConfigService],
+})
+export class AppConfigModule {}
diff --git a/apps/api/src/config/app-config.service.spec.ts b/apps/api/src/config/app-config.service.spec.ts
new file mode 100644
index 0000000..143ea5b
--- /dev/null
+++ b/apps/api/src/config/app-config.service.spec.ts
@@ -0,0 +1,39 @@
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { AppConfigService } from './app-config.service';
+
+const ORIGINAL_ENV = { ...process.env };
+
+afterEach(() => {
+ process.env = { ...ORIGINAL_ENV };
+});
+
+function setValidEnvironment(): void {
+ process.env.DATABASE_URL = 'mysql://user:password@localhost:3306/lehuo_test';
+ process.env.SESSION_TOKEN_PEPPER = 'test-session-pepper-with-32-characters';
+ process.env.AUDIT_IP_HASH_KEY = 'test-audit-key-with-at-least-32-chars';
+ process.env.ADMIN_WEB_ORIGIN = 'http://localhost:5173';
+ process.env.FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('base64');
+}
+
+describe('AppConfigService', () => {
+ it('parses an explicit MySQL configuration', () => {
+ setValidEnvironment();
+ const config = new AppConfigService();
+
+ expect(config.database).toEqual({
+ host: 'localhost',
+ port: 3306,
+ user: 'user',
+ password: 'password',
+ database: 'lehuo_test',
+ });
+ });
+
+ it('rejects encryption keys that are not 32 bytes', () => {
+ setValidEnvironment();
+ process.env.FIELD_ENCRYPTION_KEY = Buffer.alloc(16).toString('base64');
+
+ expect(() => new AppConfigService()).toThrow(/32-byte key/);
+ });
+});
diff --git a/apps/api/src/config/app-config.service.ts b/apps/api/src/config/app-config.service.ts
new file mode 100644
index 0000000..01c5ca6
--- /dev/null
+++ b/apps/api/src/config/app-config.service.ts
@@ -0,0 +1,115 @@
+import { Injectable } from '@nestjs/common';
+
+export interface DatabaseConnectionOptions {
+ host: string;
+ port: number;
+ user: string;
+ password: string;
+ database: string;
+}
+
+function requiredEnvironmentValue(name: string): string {
+ const value = process.env[name]?.trim();
+ if (!value) {
+ throw new Error(`Missing required environment variable: ${name}`);
+ }
+ return value;
+}
+
+function positiveInteger(name: string, fallback: number): number {
+ const raw = process.env[name]?.trim();
+ if (!raw) return fallback;
+ const value = Number(raw);
+ if (!Number.isSafeInteger(value) || value <= 0) {
+ throw new Error(`${name} must be a positive integer`);
+ }
+ return value;
+}
+
+function parseBoolean(name: string, fallback: boolean): boolean {
+ const raw = process.env[name]?.trim().toLowerCase();
+ if (!raw) return fallback;
+ if (raw === 'true') return true;
+ if (raw === 'false') return false;
+ throw new Error(`${name} must be true or false`);
+}
+
+@Injectable()
+export class AppConfigService {
+ readonly appEnvironment = process.env.APP_ENV?.trim() || 'development';
+ readonly apiPort = positiveInteger('API_PORT', 3000);
+ readonly databaseUrl = requiredEnvironmentValue('DATABASE_URL');
+ readonly sessionTokenPepper = requiredEnvironmentValue('SESSION_TOKEN_PEPPER');
+ readonly auditIpHashKey = requiredEnvironmentValue('AUDIT_IP_HASH_KEY');
+ readonly adminWebOrigin = requiredEnvironmentValue('ADMIN_WEB_ORIGIN');
+ readonly adminCookieSecure = parseBoolean('ADMIN_COOKIE_SECURE', true);
+ readonly adminSessionTtlSeconds = positiveInteger('AUTH_SESSION_TTL_SECONDS', 28_800);
+ readonly wechatSessionTtlSeconds = positiveInteger('WECHAT_SESSION_TTL_SECONDS', 7_200);
+ readonly wechatAppId = process.env.WECHAT_APP_ID?.trim() || null;
+ readonly wechatAppSecret = process.env.WECHAT_APP_SECRET?.trim() || null;
+ readonly wechatNotificationTemplates = {
+ APPLICATION_SUBMITTED: process.env.WECHAT_TEMPLATE_APPLICATION_SUBMITTED?.trim() || null,
+ APPLICATION_ACCEPTED: process.env.WECHAT_TEMPLATE_APPLICATION_ACCEPTED?.trim() || null,
+ SERVICE_ARRANGED: process.env.WECHAT_TEMPLATE_SERVICE_ARRANGED?.trim() || null,
+ SERVICE_COMPLETED: process.env.WECHAT_TEMPLATE_SERVICE_COMPLETED?.trim() || null,
+ FOLLOW_UP_PENDING: process.env.WECHAT_TEMPLATE_FOLLOW_UP_PENDING?.trim() || null,
+ ACTIVITY_REGISTRATION_RESULT:
+ process.env.WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT?.trim() || null,
+ } as const;
+ readonly notificationWorkerEnabled = parseBoolean('NOTIFICATION_WORKER_ENABLED', false);
+ readonly objectStorageEndpoint = process.env.OBJECT_STORAGE_ENDPOINT?.trim() || null;
+ readonly objectStorageRegion = process.env.OBJECT_STORAGE_REGION?.trim() || 'auto';
+ readonly objectStorageBucket = process.env.OBJECT_STORAGE_BUCKET?.trim() || null;
+ readonly objectStorageAccessKey = process.env.OBJECT_STORAGE_ACCESS_KEY?.trim() || null;
+ readonly objectStorageSecretKey = process.env.OBJECT_STORAGE_SECRET_KEY?.trim() || null;
+ readonly objectStorageForcePathStyle = parseBoolean('OBJECT_STORAGE_FORCE_PATH_STYLE', true);
+ readonly fieldEncryptionKey = this.parseEncryptionKey();
+ readonly database = this.parseDatabaseUrl();
+
+ get isProduction(): boolean {
+ return this.appEnvironment === 'production';
+ }
+
+ get objectStorageConfigured(): boolean {
+ return Boolean(
+ this.objectStorageEndpoint &&
+ this.objectStorageBucket &&
+ this.objectStorageAccessKey &&
+ this.objectStorageSecretKey,
+ );
+ }
+ get wechatNotificationConfigured(): boolean {
+ return Boolean(
+ this.wechatAppId &&
+ this.wechatAppSecret &&
+ Object.values(this.wechatNotificationTemplates).every(Boolean),
+ );
+ }
+
+ private parseEncryptionKey(): Buffer {
+ const encoded = requiredEnvironmentValue('FIELD_ENCRYPTION_KEY');
+ const key = Buffer.from(encoded, 'base64');
+ if (key.length !== 32) {
+ throw new Error('FIELD_ENCRYPTION_KEY must be a base64-encoded 32-byte key');
+ }
+ return key;
+ }
+
+ private parseDatabaseUrl(): DatabaseConnectionOptions {
+ const url = new URL(this.databaseUrl);
+ if (url.protocol !== 'mysql:') {
+ throw new Error('DATABASE_URL must use the mysql protocol');
+ }
+ const database = url.pathname.replace(/^\//, '');
+ if (!url.hostname || !url.username || !database) {
+ throw new Error('DATABASE_URL must include host, user, and database');
+ }
+ return {
+ host: url.hostname,
+ port: url.port ? Number(url.port) : 3306,
+ user: decodeURIComponent(url.username),
+ password: decodeURIComponent(url.password),
+ database,
+ };
+ }
+}
diff --git a/apps/api/src/content-notifications/content-notifications.module.ts b/apps/api/src/content-notifications/content-notifications.module.ts
new file mode 100644
index 0000000..e7e9da3
--- /dev/null
+++ b/apps/api/src/content-notifications/content-notifications.module.ts
@@ -0,0 +1,31 @@
+import { Module } from '@nestjs/common';
+import { AuditModule } from '../audit/audit.module';
+import { AdminContentController, PublicContentController } from './content.controller';
+import { ContentService } from './content.service';
+import {
+ AdminNotificationsController,
+ MeNotificationsController,
+} from './notifications.controller';
+import { NotificationsService } from './notifications.service';
+import { NotificationWorkerService } from './notification-worker.service';
+import {
+ HttpWechatSubscriptionAdapter,
+ WechatSubscriptionAdapter,
+} from './wechat-subscription.adapter';
+@Module({
+ imports: [AuditModule],
+ controllers: [
+ PublicContentController,
+ AdminContentController,
+ MeNotificationsController,
+ AdminNotificationsController,
+ ],
+ providers: [
+ ContentService,
+ NotificationsService,
+ NotificationWorkerService,
+ { provide: WechatSubscriptionAdapter, useClass: HttpWechatSubscriptionAdapter },
+ ],
+ exports: [NotificationsService],
+})
+export class ContentNotificationsModule {}
diff --git a/apps/api/src/content-notifications/content.controller.ts b/apps/api/src/content-notifications/content.controller.ts
new file mode 100644
index 0000000..f433661
--- /dev/null
+++ b/apps/api/src/content-notifications/content.controller.ts
@@ -0,0 +1,91 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Inject,
+ Param,
+ Patch,
+ Post,
+ Query,
+ Req,
+ ValidationPipe,
+} from '@nestjs/common';
+import {
+ PERMISSION_CODES,
+ type ContentPostDetailResponse,
+ type PaginatedContentPostsResponse,
+} from '@lehuo/contracts';
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { Public } from '../common/decorators/public.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SessionChannel } from '../generated/prisma/enums';
+import { ContentService } from './content.service';
+import {
+ ContentQueryDto,
+ CreateContentDto,
+ PublicContentQueryDto,
+ TransitionContentDto,
+ UpdateContentDto,
+} from './dto/content-notifications.dto';
+const strict = (expectedType: new () => object) =>
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType,
+ });
+
+@Controller('api/v1/content')
+@Public()
+export class PublicContentController {
+ constructor(@Inject(ContentService) private readonly service: ContentService) {}
+ @Get() list(
+ @Query(strict(PublicContentQueryDto)) q: PublicContentQueryDto,
+ ): Promise {
+ return this.service.listPublic(q);
+ }
+ @Get(':publicId') get(@Param('publicId') id: string): Promise {
+ return this.service.getPublic(id);
+ }
+}
+
+@Controller('api/v1/admin/content')
+@RequireSessionChannel(SessionChannel.ADMIN)
+export class AdminContentController {
+ constructor(@Inject(ContentService) private readonly service: ContentService) {}
+ @Get() @RequirePermissions(PERMISSION_CODES.CONTENT_READ) list(
+ @Query(strict(ContentQueryDto)) q: ContentQueryDto,
+ ) {
+ return this.service.listAdmin(q);
+ }
+ @Get(':publicId') @RequirePermissions(PERMISSION_CODES.CONTENT_READ) get(
+ @Param('publicId') id: string,
+ ) {
+ return this.service.getAdmin(id);
+ }
+ @Post() @RequirePermissions(PERMISSION_CODES.CONTENT_MANAGE) create(
+ @Body(strict(CreateContentDto)) input: CreateContentDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() req: RequestContext,
+ ) {
+ return this.service.create(input, actor, req);
+ }
+ @Patch(':publicId') @RequirePermissions(PERMISSION_CODES.CONTENT_MANAGE) update(
+ @Param('publicId') id: string,
+ @Body(strict(UpdateContentDto)) input: UpdateContentDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() req: RequestContext,
+ ) {
+ return this.service.update(id, input, actor, req);
+ }
+ @Post(':publicId/transition') @RequirePermissions(PERMISSION_CODES.CONTENT_REVIEW) transition(
+ @Param('publicId') id: string,
+ @Body(strict(TransitionContentDto)) input: TransitionContentDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() req: RequestContext,
+ ) {
+ return this.service.transition(id, input, actor, req);
+ }
+}
diff --git a/apps/api/src/content-notifications/content.service.ts b/apps/api/src/content-notifications/content.service.ts
new file mode 100644
index 0000000..ce67479
--- /dev/null
+++ b/apps/api/src/content-notifications/content.service.ts
@@ -0,0 +1,328 @@
+import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
+import {
+ API_ERROR_CODES,
+ type ContentPostDetail,
+ type ContentPostDetailResponse,
+ type PaginatedContentPostsResponse,
+} from '@lehuo/contracts';
+import { AuditService } from '../audit/audit.service';
+import { ApplicationError } from '../common/application-error';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { PrismaService } from '../database/prisma.service';
+import type { Prisma } from '../generated/prisma/client';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+import type {
+ ContentQueryDto,
+ CreateContentDto,
+ PublicContentQueryDto,
+ TransitionContentDto,
+ UpdateContentDto,
+} from './dto/content-notifications.dto';
+
+const include = {
+ coverFile: true,
+ statusHistory: { orderBy: { createdAt: 'desc' as const } },
+} satisfies Prisma.ContentPostInclude;
+type Stored = Prisma.ContentPostGetPayload<{ include: typeof include }>;
+@Injectable()
+export class ContentService {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ ) {}
+ listAdmin(q: ContentQueryDto) {
+ return this.list(
+ {
+ ...(q.type ? { type: q.type as never } : {}),
+ ...(q.status ? { status: q.status as never } : {}),
+ },
+ q.page,
+ q.pageSize,
+ );
+ }
+ listPublic(q: PublicContentQueryDto) {
+ return this.list(
+ { status: 'PUBLISHED', ...(q.type ? { type: q.type as never } : {}) },
+ q.page,
+ q.pageSize,
+ );
+ }
+ async getAdmin(id: string) {
+ return { post: this.map(await this.find(id)) };
+ }
+ async getPublic(id: string) {
+ const post = await this.find(id);
+ if (post.status !== 'PUBLISHED') throw this.notFound();
+ return { post: this.map(post) };
+ }
+ async create(
+ input: CreateContentDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ this.assertPlain(input);
+ const fp = this.fingerprint('content-create', input);
+ const replay = await this.prisma.contentPost.findUnique({
+ where: {
+ createdById_clientRequestId: {
+ createdById: actor.userId,
+ clientRequestId: input.clientRequestId,
+ },
+ },
+ include,
+ });
+ if (replay) {
+ if (replay.requestFingerprint !== fp) throw this.duplicate();
+ return { post: this.map(replay), idempotentReplay: true };
+ }
+ const post = await this.prisma.$transaction(async (tx) => {
+ const p = await tx.contentPost.create({
+ data: {
+ publicId: randomUUID(),
+ clientRequestId: input.clientRequestId,
+ requestFingerprint: fp,
+ type: input.type as never,
+ title: input.title.trim(),
+ summary: input.summary.trim(),
+ content: input.content.trim(),
+ createdById: actor.userId,
+ updatedById: actor.userId,
+ },
+ });
+ await tx.contentPostStatusHistory.create({
+ data: {
+ contentPostId: p.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fp,
+ toStatus: 'DRAFT',
+ operatorUserId: actor.userId,
+ reasonCode: 'CREATED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'CONTENT_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'CONTENT_POST',
+ resourcePublicId: p.publicId,
+ metadata: { type: input.type },
+ },
+ tx,
+ );
+ return tx.contentPost.findUniqueOrThrow({ where: { id: p.id }, include });
+ });
+ return { post: this.map(post), idempotentReplay: false };
+ }
+ async update(id: string, input: UpdateContentDto, actor: AuthContext, request: RequestContext) {
+ this.assertPlain(input);
+ const post = await this.find(id);
+ if (post.status !== 'DRAFT') throw this.invalid();
+ if (post.revision !== input.expectedRevision) throw this.conflict();
+ const updated = await this.prisma.$transaction(async (tx) => {
+ const r = await tx.contentPost.updateMany({
+ where: { id: post.id, status: 'DRAFT', revision: input.expectedRevision },
+ data: {
+ type: input.type as never,
+ title: input.title.trim(),
+ summary: input.summary.trim(),
+ content: input.content.trim(),
+ updatedById: actor.userId,
+ revision: { increment: 1 },
+ privacyReviewedAt: null,
+ },
+ });
+ if (r.count !== 1) throw this.conflict();
+ await this.audit.record(
+ request,
+ {
+ action: 'CONTENT_UPDATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'CONTENT_POST',
+ resourcePublicId: id,
+ },
+ tx,
+ );
+ return tx.contentPost.findUniqueOrThrow({ where: { id: post.id }, include });
+ });
+ return { post: this.map(updated) };
+ }
+ async transition(
+ id: string,
+ input: TransitionContentDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ) {
+ const post = await this.find(id);
+ const fp = this.fingerprint('content-transition', {
+ id,
+ target: input.targetStatus,
+ note: input.note,
+ });
+ const replay = await this.prisma.contentPostStatusHistory.findUnique({
+ where: { requestId: input.clientRequestId },
+ include: { contentPost: { include } },
+ });
+ if (replay) {
+ if (replay.requestFingerprint !== fp) throw this.duplicate();
+ return { post: this.map(replay.contentPost), idempotentReplay: true };
+ }
+ const from = post.status;
+ const allowed: Record = {
+ DRAFT: ['PENDING_REVIEW'],
+ PENDING_REVIEW: ['PUBLISHED', 'DRAFT'],
+ PUBLISHED: ['ARCHIVED'],
+ ARCHIVED: [],
+ };
+ if (!allowed[from]?.includes(input.targetStatus)) throw this.invalid();
+ if (
+ input.targetStatus === 'PUBLISHED' &&
+ post.type === 'SERVICE_STORY' &&
+ input.privacyReviewed !== true
+ )
+ throw new ApplicationError(
+ API_ERROR_CODES.VALIDATION_ERROR,
+ '服务故事发布前必须确认已完成隐私脱敏审核',
+ HttpStatus.BAD_REQUEST,
+ );
+ const now = new Date();
+ const updated = await this.prisma.$transaction(async (tx) => {
+ const r = await tx.contentPost.updateMany({
+ where: { id: post.id, status: from },
+ data: {
+ status: input.targetStatus as never,
+ updatedById: actor.userId,
+ revision: { increment: 1 },
+ ...(input.targetStatus === 'PUBLISHED'
+ ? {
+ reviewedById: actor.userId,
+ reviewedAt: now,
+ publishedAt: now,
+ privacyReviewedAt: post.type === 'SERVICE_STORY' ? now : post.privacyReviewedAt,
+ }
+ : {}),
+ },
+ });
+ if (r.count !== 1) throw this.conflict();
+ await tx.contentPostStatusHistory.create({
+ data: {
+ contentPostId: post.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fp,
+ fromStatus: from,
+ toStatus: input.targetStatus as never,
+ operatorUserId: actor.userId,
+ reasonCode: input.targetStatus === 'DRAFT' ? 'REJECTED' : input.targetStatus,
+ noteEncrypted: this.sensitive.encrypt(input.note.trim()),
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'CONTENT_STATUS_CHANGED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'CONTENT_POST',
+ resourcePublicId: id,
+ metadata: { fromStatus: from, toStatus: input.targetStatus },
+ },
+ tx,
+ );
+ return tx.contentPost.findUniqueOrThrow({ where: { id: post.id }, include });
+ });
+ return { post: this.map(updated), idempotentReplay: false };
+ }
+ private async list(
+ where: Prisma.ContentPostWhereInput,
+ page: number,
+ pageSize: number,
+ ): Promise {
+ const [total, items] = await this.prisma.$transaction([
+ this.prisma.contentPost.count({ where }),
+ this.prisma.contentPost.findMany({
+ where,
+ include,
+ orderBy: [{ publishedAt: 'desc' }, { updatedAt: 'desc' }],
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ ]);
+ return { items: items.map((p) => this.item(p)), page, pageSize, total };
+ }
+ private async find(publicId: string) {
+ const p = await this.prisma.contentPost.findUnique({ where: { publicId }, include });
+ if (!p) throw this.notFound();
+ return p;
+ }
+ private item(p: Stored) {
+ return {
+ publicId: p.publicId,
+ type: p.type,
+ title: p.title,
+ summary: p.summary,
+ status: p.status,
+ revision: p.revision,
+ publishedAt: p.publishedAt?.toISOString() ?? null,
+ updatedAt: p.updatedAt.toISOString(),
+ };
+ }
+ private map(p: Stored): ContentPostDetail {
+ return {
+ ...this.item(p),
+ content: p.content,
+ coverFilePublicId: p.coverFile?.publicId ?? null,
+ privacyReviewedAt: p.privacyReviewedAt?.toISOString() ?? null,
+ reviewedAt: p.reviewedAt?.toISOString() ?? null,
+ history: p.statusHistory.map((h) => ({
+ fromStatus: h.fromStatus,
+ toStatus: h.toStatus,
+ reasonCode: h.reasonCode,
+ note: h.noteEncrypted ? this.sensitive.decrypt(h.noteEncrypted) : null,
+ createdAt: h.createdAt.toISOString(),
+ })),
+ };
+ }
+ private assertPlain(v: { title: string; summary: string; content: string }) {
+ if (/<\s*script|javascript:|on\w+\s*=/i.test(`${v.title}\n${v.summary}\n${v.content}`))
+ throw new ApplicationError(
+ API_ERROR_CODES.VALIDATION_ERROR,
+ '内容包含不安全标记,请使用纯文本或 Markdown',
+ HttpStatus.BAD_REQUEST,
+ );
+ }
+ private fingerprint(d: string, v: object) {
+ return this.sensitive.lookupHash(d, JSON.stringify(v));
+ }
+ private notFound() {
+ return new ApplicationError(
+ API_ERROR_CODES.RESOURCE_NOT_FOUND,
+ '未找到该内容',
+ HttpStatus.NOT_FOUND,
+ );
+ }
+ private invalid() {
+ return new ApplicationError(
+ API_ERROR_CODES.INVALID_STATE_TRANSITION,
+ '当前内容状态不能执行该操作',
+ HttpStatus.CONFLICT,
+ );
+ }
+ private conflict() {
+ return new ApplicationError(
+ API_ERROR_CODES.RESOURCE_CONFLICT,
+ '内容已更新,请刷新后重试',
+ HttpStatus.CONFLICT,
+ );
+ }
+ private duplicate() {
+ return new ApplicationError(
+ API_ERROR_CODES.DUPLICATE_REQUEST,
+ '请求标识已用于其他操作',
+ HttpStatus.CONFLICT,
+ );
+ }
+}
diff --git a/apps/api/src/content-notifications/dto/content-notifications.dto.ts b/apps/api/src/content-notifications/dto/content-notifications.dto.ts
new file mode 100644
index 0000000..7597d50
--- /dev/null
+++ b/apps/api/src/content-notifications/dto/content-notifications.dto.ts
@@ -0,0 +1,64 @@
+import { Type } from 'class-transformer';
+import {
+ ArrayMaxSize,
+ IsArray,
+ IsBoolean,
+ IsIn,
+ IsInt,
+ IsOptional,
+ IsString,
+ IsUUID,
+ Length,
+ Min,
+ ValidateNested,
+} from 'class-validator';
+import { CONTENT_POST_STATUSES, CONTENT_POST_TYPES, NOTIFICATION_TYPES } from '@lehuo/contracts';
+import { PaginationDto } from '../../case-management/dto/case-management.dto';
+
+export class ContentQueryDto extends PaginationDto {
+ @IsOptional() @IsIn(CONTENT_POST_TYPES) type?: string;
+ @IsOptional() @IsIn(CONTENT_POST_STATUSES) status?: string;
+}
+export class PublicContentQueryDto extends PaginationDto {
+ @IsOptional() @IsIn(CONTENT_POST_TYPES) type?: string;
+}
+export class CreateContentDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsIn(CONTENT_POST_TYPES) type!: string;
+ @IsString() @Length(2, 200) title!: string;
+ @IsString() @Length(10, 500) summary!: string;
+ @IsString() @Length(20, 50000) content!: string;
+ @IsOptional() @IsUUID('4') coverFilePublicId?: string;
+}
+export class UpdateContentDto {
+ @Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
+ @IsIn(CONTENT_POST_TYPES) type!: string;
+ @IsString() @Length(2, 200) title!: string;
+ @IsString() @Length(10, 500) summary!: string;
+ @IsString() @Length(20, 50000) content!: string;
+ @IsOptional() @IsUUID('4') coverFilePublicId?: string;
+}
+export class TransitionContentDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsIn(CONTENT_POST_STATUSES) targetStatus!: string;
+ @IsString() @Length(2, 500) note!: string;
+ @IsOptional() @IsIn([true]) privacyReviewed?: true;
+}
+export class NotificationQueryDto extends PaginationDto {
+ @IsOptional() @IsBoolean() @Type(() => Boolean) unreadOnly?: boolean;
+}
+export class PreferenceInputDto {
+ @IsIn(NOTIFICATION_TYPES) type!: string;
+ @IsBoolean() inAppEnabled!: boolean;
+ @IsBoolean() wechatEnabled!: boolean;
+}
+export class UpdatePreferencesDto {
+ @IsArray()
+ @ArrayMaxSize(NOTIFICATION_TYPES.length)
+ @ValidateNested({ each: true })
+ @Type(() => PreferenceInputDto)
+ items!: PreferenceInputDto[];
+}
+export class DeliveryQueryDto extends PaginationDto {
+ @IsOptional() @IsIn(['PENDING', 'PROCESSING', 'SENT', 'FAILED', 'DEAD']) status?: string;
+}
diff --git a/apps/api/src/content-notifications/notification-worker.service.ts b/apps/api/src/content-notifications/notification-worker.service.ts
new file mode 100644
index 0000000..0e6f47f
--- /dev/null
+++ b/apps/api/src/content-notifications/notification-worker.service.ts
@@ -0,0 +1,19 @@
+import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
+import { AppConfigService } from '../config/app-config.service';
+import { NotificationsService } from './notifications.service';
+@Injectable()
+export class NotificationWorkerService implements OnModuleInit, OnModuleDestroy {
+ private timer?: ReturnType;
+ constructor(
+ @Inject(AppConfigService) private readonly config: AppConfigService,
+ @Inject(NotificationsService) private readonly notifications: NotificationsService,
+ ) {}
+ onModuleInit() {
+ if (!this.config.notificationWorkerEnabled) return;
+ this.timer = setInterval(() => void this.notifications.processBatch(), 30_000);
+ this.timer.unref();
+ }
+ onModuleDestroy() {
+ if (this.timer) clearInterval(this.timer);
+ }
+}
diff --git a/apps/api/src/content-notifications/notifications.controller.ts b/apps/api/src/content-notifications/notifications.controller.ts
new file mode 100644
index 0000000..f07e206
--- /dev/null
+++ b/apps/api/src/content-notifications/notifications.controller.ts
@@ -0,0 +1,78 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Inject,
+ Param,
+ Post,
+ Put,
+ Query,
+ Req,
+ ValidationPipe,
+} from '@nestjs/common';
+import { PERMISSION_CODES } from '@lehuo/contracts';
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SessionChannel } from '../generated/prisma/enums';
+import {
+ DeliveryQueryDto,
+ NotificationQueryDto,
+ UpdatePreferencesDto,
+} from './dto/content-notifications.dto';
+import { NotificationsService } from './notifications.service';
+const strict = (expectedType: new () => object) =>
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType,
+ });
+@Controller('api/v1/me/notifications')
+@RequireSessionChannel(SessionChannel.WECHAT)
+export class MeNotificationsController {
+ constructor(@Inject(NotificationsService) private readonly service: NotificationsService) {}
+ @Get() list(
+ @Query(strict(NotificationQueryDto)) q: NotificationQueryDto,
+ @CurrentAuth() a: AuthContext,
+ ) {
+ return this.service.list(q, a);
+ }
+ @Post(':publicId/read') read(
+ @Param('publicId') id: string,
+ @CurrentAuth() a: AuthContext,
+ @Req() r: RequestContext,
+ ) {
+ return this.service.read(id, a, r);
+ }
+ @Get('preferences') preferences(@CurrentAuth() a: AuthContext) {
+ return this.service.preferences(a);
+ }
+ @Put('preferences') update(
+ @Body(strict(UpdatePreferencesDto)) i: UpdatePreferencesDto,
+ @CurrentAuth() a: AuthContext,
+ @Req() r: RequestContext,
+ ) {
+ return this.service.updatePreferences(i, a, r);
+ }
+}
+@Controller('api/v1/admin/notification-deliveries')
+@RequireSessionChannel(SessionChannel.ADMIN)
+@RequirePermissions(PERMISSION_CODES.NOTIFICATIONS_MANAGE)
+export class AdminNotificationsController {
+ constructor(@Inject(NotificationsService) private readonly service: NotificationsService) {}
+ @Get() list(@Query(strict(DeliveryQueryDto)) q: DeliveryQueryDto) {
+ return this.service.deliveries(q);
+ }
+ @Post(':publicId/retry') retry(
+ @Param('publicId') id: string,
+ @CurrentAuth() a: AuthContext,
+ @Req() r: RequestContext,
+ ) {
+ return this.service.retry(id, a, r);
+ }
+ @Post('process') process() {
+ return this.service.processBatch();
+ }
+}
diff --git a/apps/api/src/content-notifications/notifications.service.ts b/apps/api/src/content-notifications/notifications.service.ts
new file mode 100644
index 0000000..5f47baa
--- /dev/null
+++ b/apps/api/src/content-notifications/notifications.service.ts
@@ -0,0 +1,282 @@
+import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
+import {
+ API_ERROR_CODES,
+ NOTIFICATION_TYPES,
+ type NotificationPreferencesResponse,
+ type PaginatedNotificationDeliveriesResponse,
+ type PaginatedNotificationsResponse,
+} from '@lehuo/contracts';
+import { AuditService } from '../audit/audit.service';
+import { ApplicationError } from '../common/application-error';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { AppConfigService } from '../config/app-config.service';
+import { PrismaService } from '../database/prisma.service';
+import type { NotificationType, Prisma } from '../generated/prisma/client';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+import type {
+ DeliveryQueryDto,
+ NotificationQueryDto,
+ UpdatePreferencesDto,
+} from './dto/content-notifications.dto';
+import { WechatSubscriptionAdapter } from './wechat-subscription.adapter';
+
+@Injectable()
+export class NotificationsService {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(AppConfigService) private readonly config: AppConfigService,
+ @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ @Inject(WechatSubscriptionAdapter) private readonly wechat: WechatSubscriptionAdapter,
+ ) {}
+ async list(q: NotificationQueryDto, actor: AuthContext): Promise {
+ const where = { userId: actor.userId, ...(q.unreadOnly ? { readAt: null } : {}) };
+ const [total, unreadCount, items] = await this.prisma.$transaction([
+ this.prisma.notification.count({ where }),
+ this.prisma.notification.count({ where: { userId: actor.userId, readAt: null } }),
+ this.prisma.notification.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ skip: (q.page - 1) * q.pageSize,
+ take: q.pageSize,
+ }),
+ ]);
+ return {
+ items: items.map((n) => ({
+ publicId: n.publicId,
+ type: n.type,
+ title: n.title,
+ body: n.body,
+ resourceType: n.resourceType,
+ resourcePublicId: n.resourcePublicId,
+ readAt: n.readAt?.toISOString() ?? null,
+ createdAt: n.createdAt.toISOString(),
+ })),
+ page: q.page,
+ pageSize: q.pageSize,
+ total,
+ unreadCount,
+ };
+ }
+ async read(id: string, actor: AuthContext, request: RequestContext) {
+ const n = await this.prisma.notification.findFirst({
+ where: { publicId: id, userId: actor.userId },
+ });
+ if (!n)
+ throw new ApplicationError(
+ API_ERROR_CODES.RESOURCE_NOT_FOUND,
+ '未找到该消息',
+ HttpStatus.NOT_FOUND,
+ );
+ if (n.readAt) return { read: true, idempotentReplay: true };
+ await this.prisma.$transaction(async (tx) => {
+ await tx.notification.update({ where: { id: n.id }, data: { readAt: new Date() } });
+ await this.audit.record(
+ request,
+ {
+ action: 'NOTIFICATION_READ',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'NOTIFICATION',
+ resourcePublicId: id,
+ },
+ tx,
+ );
+ });
+ return { read: true, idempotentReplay: false };
+ }
+ async preferences(actor: AuthContext): Promise {
+ const stored = await this.prisma.notificationPreference.findMany({
+ where: { userId: actor.userId },
+ });
+ return {
+ items: NOTIFICATION_TYPES.map((type) => {
+ const p = stored.find((v) => v.type === type);
+ return {
+ type,
+ inAppEnabled: p?.inAppEnabled ?? true,
+ wechatEnabled: p?.wechatEnabled ?? false,
+ authorizedAt: p?.authorizedAt?.toISOString() ?? null,
+ };
+ }),
+ wechatAdapterConfigured: this.wechat.configured,
+ };
+ }
+ async updatePreferences(
+ input: UpdatePreferencesDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ) {
+ if (new Set(input.items.map((i) => i.type)).size !== input.items.length)
+ throw new ApplicationError(
+ API_ERROR_CODES.VALIDATION_ERROR,
+ '通知类型不能重复',
+ HttpStatus.BAD_REQUEST,
+ );
+ await this.prisma.$transaction(async (tx) => {
+ for (const item of input.items)
+ await tx.notificationPreference.upsert({
+ where: { userId_type: { userId: actor.userId, type: item.type as NotificationType } },
+ create: {
+ userId: actor.userId,
+ type: item.type as NotificationType,
+ inAppEnabled: item.inAppEnabled,
+ wechatEnabled: item.wechatEnabled,
+ authorizedAt: item.wechatEnabled ? new Date() : null,
+ },
+ update: {
+ inAppEnabled: item.inAppEnabled,
+ wechatEnabled: item.wechatEnabled,
+ authorizedAt: item.wechatEnabled ? new Date() : null,
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'NOTIFICATION_PREFERENCES_UPDATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'USER',
+ resourcePublicId: actor.userPublicId,
+ metadata: { preferenceCount: input.items.length },
+ },
+ tx,
+ );
+ });
+ return this.preferences(actor);
+ }
+ async create(
+ tx: Prisma.TransactionClient,
+ input: {
+ userId: bigint;
+ eventKey: string;
+ type: NotificationType;
+ title: string;
+ body: string;
+ resourceType?: string;
+ resourcePublicId?: string;
+ },
+ ) {
+ const pref = await tx.notificationPreference.findUnique({
+ where: { userId_type: { userId: input.userId, type: input.type } },
+ });
+ if (pref?.inAppEnabled === false && !pref.wechatEnabled) return null;
+ const n = await tx.notification.upsert({
+ where: { userId_eventKey: { userId: input.userId, eventKey: input.eventKey } },
+ create: { publicId: randomUUID(), ...input },
+ update: {},
+ });
+ if (pref?.wechatEnabled)
+ await tx.notificationDelivery.upsert({
+ where: { notificationId_channel: { notificationId: n.id, channel: 'WECHAT_SUBSCRIPTION' } },
+ create: { publicId: randomUUID(), notificationId: n.id, channel: 'WECHAT_SUBSCRIPTION' },
+ update: {},
+ });
+ return n;
+ }
+ async deliveries(q: DeliveryQueryDto): Promise {
+ const where = q.status ? { status: q.status as never } : {};
+ const [total, rows] = await this.prisma.$transaction([
+ this.prisma.notificationDelivery.count({ where }),
+ this.prisma.notificationDelivery.findMany({
+ where,
+ include: { notification: true },
+ orderBy: { createdAt: 'desc' },
+ skip: (q.page - 1) * q.pageSize,
+ take: q.pageSize,
+ }),
+ ]);
+ return {
+ items: rows.map((d) => ({
+ publicId: d.publicId,
+ notificationPublicId: d.notification.publicId,
+ type: d.notification.type,
+ channel: d.channel,
+ status: d.status,
+ attemptCount: d.attemptCount,
+ nextAttemptAt: d.nextAttemptAt.toISOString(),
+ lastAttemptAt: d.lastAttemptAt?.toISOString() ?? null,
+ errorCode: d.errorCode,
+ createdAt: d.createdAt.toISOString(),
+ })),
+ page: q.page,
+ pageSize: q.pageSize,
+ total,
+ };
+ }
+ async retry(id: string, actor: AuthContext, request: RequestContext) {
+ const d = await this.prisma.notificationDelivery.findUnique({ where: { publicId: id } });
+ if (!d)
+ throw new ApplicationError(
+ API_ERROR_CODES.RESOURCE_NOT_FOUND,
+ '未找到该投递任务',
+ HttpStatus.NOT_FOUND,
+ );
+ await this.prisma.$transaction(async (tx) => {
+ await tx.notificationDelivery.update({
+ where: { id: d.id },
+ data: { status: 'PENDING', nextAttemptAt: new Date(), errorCode: null },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'NOTIFICATION_DELIVERY_RETRIED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'NOTIFICATION_DELIVERY',
+ resourcePublicId: id,
+ },
+ tx,
+ );
+ });
+ return { queued: true };
+ }
+ async processBatch(limit = 20) {
+ if (!this.wechat.configured) return { processed: 0, unavailable: true };
+ const jobs = await this.prisma.notificationDelivery.findMany({
+ where: { status: { in: ['PENDING', 'FAILED'] }, nextAttemptAt: { lte: new Date() } },
+ include: { notification: { include: { user: { include: { wechatIdentities: true } } } } },
+ orderBy: { nextAttemptAt: 'asc' },
+ take: limit,
+ });
+ let processed = 0;
+ for (const job of jobs) {
+ const claimed = await this.prisma.notificationDelivery.updateMany({
+ where: { id: job.id, status: job.status },
+ data: { status: 'PROCESSING', lastAttemptAt: new Date(), attemptCount: { increment: 1 } },
+ });
+ if (!claimed.count) continue;
+ processed++;
+ try {
+ const identity = job.notification.user.wechatIdentities.find(
+ (i) => i.appid === this.config.wechatAppId,
+ );
+ if (!identity) throw new Error('WECHAT_IDENTITY_NOT_FOUND');
+ const result = await this.wechat.send({
+ openid: this.sensitive.decrypt(identity.openidCiphertext),
+ type: job.notification.type,
+ title: job.notification.title,
+ body: job.notification.body,
+ resourcePublicId: job.notification.resourcePublicId,
+ });
+ await this.prisma.notificationDelivery.update({
+ where: { id: job.id },
+ data: { status: 'SENT', providerMessageId: result.providerMessageId, errorCode: null },
+ });
+ } catch (error) {
+ const attempts = job.attemptCount + 1;
+ const code = error instanceof Error ? error.message.slice(0, 100) : 'WECHAT_SEND_FAILED';
+ await this.prisma.notificationDelivery.update({
+ where: { id: job.id },
+ data: {
+ status: attempts >= 5 ? 'DEAD' : 'FAILED',
+ errorCode: code,
+ nextAttemptAt: new Date(Date.now() + Math.min(3600, 2 ** attempts * 30) * 1000),
+ },
+ });
+ }
+ }
+ return { processed, unavailable: false };
+ }
+}
diff --git a/apps/api/src/content-notifications/wechat-subscription.adapter.ts b/apps/api/src/content-notifications/wechat-subscription.adapter.ts
new file mode 100644
index 0000000..9b79ddd
--- /dev/null
+++ b/apps/api/src/content-notifications/wechat-subscription.adapter.ts
@@ -0,0 +1,64 @@
+import { Inject, Injectable } from '@nestjs/common';
+import type { NotificationType } from '@lehuo/contracts';
+import { AppConfigService } from '../config/app-config.service';
+export interface SubscriptionMessage {
+ openid: string;
+ type: NotificationType;
+ title: string;
+ body: string;
+ resourcePublicId: string | null;
+}
+export interface SubscriptionSendResult {
+ providerMessageId: string | null;
+}
+export abstract class WechatSubscriptionAdapter {
+ abstract get configured(): boolean;
+ abstract send(message: SubscriptionMessage): Promise;
+}
+@Injectable()
+export class HttpWechatSubscriptionAdapter extends WechatSubscriptionAdapter {
+ constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {
+ super();
+ }
+ get configured() {
+ return this.config.wechatNotificationConfigured;
+ }
+ async send(message: SubscriptionMessage) {
+ if (!this.config.wechatAppId || !this.config.wechatAppSecret)
+ throw new Error('WECHAT_NOT_CONFIGURED');
+ const templateId = this.config.wechatNotificationTemplates[message.type];
+ if (!templateId) throw new Error('WECHAT_TEMPLATE_NOT_CONFIGURED');
+ const tokenResponse = await fetch(
+ `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(this.config.wechatAppId)}&secret=${encodeURIComponent(this.config.wechatAppSecret)}`,
+ );
+ const token = (await tokenResponse.json()) as { access_token?: string; errcode?: number };
+ if (!tokenResponse.ok || !token.access_token)
+ throw new Error(`WECHAT_TOKEN_${token.errcode ?? 'FAILED'}`);
+ const response = await fetch(
+ `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${encodeURIComponent(token.access_token)}`,
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ touser: message.openid,
+ template_id: templateId,
+ page: message.resourcePublicId
+ ? `pages/index/index?resource=${message.resourcePublicId}`
+ : 'pages/index/index',
+ data: {
+ thing1: { value: message.title.slice(0, 20) },
+ thing2: { value: message.body.slice(0, 20) },
+ },
+ }),
+ },
+ );
+ const result = (await response.json()) as {
+ errcode?: number;
+ errmsg?: string;
+ msgid?: string | number;
+ };
+ if (!response.ok || result.errcode !== 0)
+ throw new Error(`WECHAT_SEND_${result.errcode ?? 'FAILED'}`);
+ return { providerMessageId: result.msgid?.toString() ?? null };
+ }
+}
diff --git a/apps/api/src/database/database.module.ts b/apps/api/src/database/database.module.ts
new file mode 100644
index 0000000..b87d7a4
--- /dev/null
+++ b/apps/api/src/database/database.module.ts
@@ -0,0 +1,10 @@
+import { Global, Module } from '@nestjs/common';
+
+import { PrismaService } from './prisma.service';
+
+@Global()
+@Module({
+ providers: [PrismaService],
+ exports: [PrismaService],
+})
+export class DatabaseModule {}
diff --git a/apps/api/src/database/prisma.service.ts b/apps/api/src/database/prisma.service.ts
new file mode 100644
index 0000000..3804d89
--- /dev/null
+++ b/apps/api/src/database/prisma.service.ts
@@ -0,0 +1,26 @@
+import { PrismaMariaDb } from '@prisma/adapter-mariadb';
+import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
+
+import { AppConfigService } from '../config/app-config.service';
+import { PrismaClient } from '../generated/prisma/client';
+
+@Injectable()
+export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
+ constructor(@Inject(AppConfigService) config: AppConfigService) {
+ const adapter = new PrismaMariaDb({
+ ...config.database,
+ connectionLimit: 10,
+ connectTimeout: 5_000,
+ acquireTimeout: 10_000,
+ });
+ super({ adapter });
+ }
+
+ async onModuleInit(): Promise {
+ await this.$connect();
+ }
+
+ async onModuleDestroy(): Promise {
+ await this.$disconnect();
+ }
+}
diff --git a/apps/api/src/files/files.module.ts b/apps/api/src/files/files.module.ts
new file mode 100644
index 0000000..1ccc77a
--- /dev/null
+++ b/apps/api/src/files/files.module.ts
@@ -0,0 +1,17 @@
+import { Module } from '@nestjs/common';
+
+import { AuditModule } from '../audit/audit.module';
+import { OBJECT_STORAGE_ADAPTER } from './object-storage.adapter';
+import { FilesRepository } from './files.repository';
+import { S3ObjectStorageAdapter } from './s3-object-storage.adapter';
+
+@Module({
+ imports: [AuditModule],
+ providers: [
+ S3ObjectStorageAdapter,
+ FilesRepository,
+ { provide: OBJECT_STORAGE_ADAPTER, useExisting: S3ObjectStorageAdapter },
+ ],
+ exports: [OBJECT_STORAGE_ADAPTER, FilesRepository],
+})
+export class FilesModule {}
diff --git a/apps/api/src/files/files.repository.ts b/apps/api/src/files/files.repository.ts
new file mode 100644
index 0000000..d25c801
--- /dev/null
+++ b/apps/api/src/files/files.repository.ts
@@ -0,0 +1,124 @@
+import { Inject, Injectable } from '@nestjs/common';
+
+import { AuditService } from '../audit/audit.service';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import type { FileBusinessType } from '../generated/prisma/enums';
+import { PrismaService } from '../database/prisma.service';
+
+@Injectable()
+export class FilesRepository {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ ) {}
+
+ findByIdempotency(uploaderUserId: bigint, clientRequestId: string) {
+ return this.prisma.file.findUnique({
+ where: { uploaderUserId_clientRequestId: { uploaderUserId, clientRequestId } },
+ });
+ }
+
+ findByPublicId(publicId: string) {
+ return this.prisma.file.findUnique({ where: { publicId } });
+ }
+
+ async createPending(
+ input: {
+ publicId: string;
+ caseId?: bigint;
+ businessType?: FileBusinessType;
+ clientRequestId: string;
+ requestFingerprint: string;
+ provider: string;
+ bucket: string;
+ objectKey: string;
+ filenameEncrypted: string;
+ mimeType: string;
+ size: bigint;
+ sha256?: string;
+ },
+ actor: AuthContext,
+ request: RequestContext,
+ ) {
+ return this.prisma.$transaction(async (transaction) => {
+ const file = await transaction.file.create({
+ data: {
+ publicId: input.publicId,
+ caseId: input.caseId,
+ clientRequestId: input.clientRequestId,
+ requestFingerprint: input.requestFingerprint,
+ storageProvider: input.provider,
+ bucket: input.bucket,
+ objectKey: input.objectKey,
+ originalFilenameEncrypted: input.filenameEncrypted,
+ mimeType: input.mimeType,
+ size: input.size,
+ sha256: input.sha256,
+ businessType: input.businessType ?? 'SERVICE_RECORD',
+ uploaderUserId: actor.userId,
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'FILE_UPLOAD_INTENT_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'FILE',
+ resourcePublicId: file.publicId,
+ metadata: {
+ businessType: input.businessType ?? 'SERVICE_RECORD',
+ mimeType: input.mimeType,
+ size: Number(input.size),
+ },
+ },
+ transaction,
+ );
+ return file;
+ });
+ }
+
+ async markAvailable(publicId: string, actor: AuthContext, request: RequestContext) {
+ return this.prisma.$transaction(async (transaction) => {
+ const file = await transaction.file.update({
+ where: { publicId },
+ data: { status: 'AVAILABLE' },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'FILE_UPLOAD_CONFIRMED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'FILE',
+ resourcePublicId: publicId,
+ },
+ transaction,
+ );
+ return file;
+ });
+ }
+
+ async quarantine(
+ publicId: string,
+ actor: AuthContext,
+ request: RequestContext,
+ reasonCode: string,
+ ) {
+ await this.prisma.$transaction(async (transaction) => {
+ await transaction.file.update({ where: { publicId }, data: { status: 'QUARANTINED' } });
+ await this.audit.record(
+ request,
+ {
+ action: 'FILE_UPLOAD_REJECTED',
+ result: 'FAILURE',
+ actor,
+ resourceType: 'FILE',
+ resourcePublicId: publicId,
+ metadata: { reasonCode },
+ },
+ transaction,
+ );
+ });
+ }
+}
diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts
new file mode 100644
index 0000000..34ef74f
--- /dev/null
+++ b/apps/api/src/files/files.service.ts
@@ -0,0 +1,246 @@
+import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
+
+import {
+ API_ERROR_CODES,
+ type ConfirmUploadResponse,
+ type CreateUploadIntentResponse,
+ type DownloadUrlResponse,
+ type ServiceRecordAttachment,
+} from '@lehuo/contracts';
+
+import { CaseManagementService } from '../case-management/case-management.service';
+import type { CreateUploadIntentDto } from '../case-management/dto/case-management.dto';
+import { AuditService } from '../audit/audit.service';
+import { ApplicationError } from '../common/application-error';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import type { File } from '../generated/prisma/client';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+import { FilesRepository } from './files.repository';
+import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from './object-storage.adapter';
+
+const EXTENSIONS: Record = {
+ 'image/jpeg': 'jpg',
+ 'image/png': 'png',
+ 'image/webp': 'webp',
+};
+
+@Injectable()
+export class FilesService {
+ constructor(
+ @Inject(FilesRepository) private readonly repository: FilesRepository,
+ @Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter,
+ @Inject(CaseManagementService) private readonly cases: CaseManagementService,
+ @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ ) {}
+
+ async createUploadIntent(
+ casePublicId: string,
+ input: CreateUploadIntentDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ this.assertConfigured();
+ const serviceCase = await this.cases.requireAssignedCase(casePublicId, actor);
+ const filename = this.safeFilename(input.filename);
+ const extension = EXTENSIONS[input.mimeType];
+ if (
+ !extension ||
+ (!filename.toLowerCase().endsWith(`.${extension}`) &&
+ !(extension === 'jpg' && filename.toLowerCase().endsWith('.jpeg')))
+ ) {
+ throw this.validation('文件扩展名与图片类型不一致');
+ }
+ const normalized = {
+ casePublicId,
+ filename,
+ mimeType: input.mimeType,
+ size: input.size,
+ sha256: input.sha256 ?? null,
+ };
+ const fingerprint = this.sensitiveData.lookupHash(
+ 'file-upload-intent',
+ JSON.stringify(normalized),
+ );
+ const previous = await this.repository.findByIdempotency(actor.userId, input.clientRequestId);
+ if (previous) {
+ if (previous.requestFingerprint !== fingerprint) throw this.duplicateRequest();
+ return this.uploadResponse(previous, true);
+ }
+ const now = new Date();
+ const objectKey = `service-records/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
+ const file = await this.repository.createPending(
+ {
+ publicId: randomUUID(),
+ caseId: serviceCase.id,
+ clientRequestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ provider: this.storage.providerName,
+ bucket: this.storage.bucket ?? '',
+ objectKey,
+ filenameEncrypted: this.sensitiveData.encrypt(filename),
+ mimeType: input.mimeType,
+ size: BigInt(input.size),
+ sha256: input.sha256,
+ },
+ actor,
+ request,
+ );
+ return this.uploadResponse(file, false);
+ }
+
+ async confirm(
+ publicId: string,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ this.assertConfigured();
+ const file = await this.repository.findByPublicId(publicId);
+ if (!file) throw this.notFound();
+ if (file.caseId === null || file.businessType !== 'SERVICE_RECORD') throw this.notFound();
+ await this.cases.requireAssignedCaseByIdForFile(file.caseId, actor);
+ if (file.uploaderUserId !== actor.userId) throw this.notFound();
+ if (file.status === 'AVAILABLE') return { file: this.toAttachment(file) };
+ if (file.status !== 'UPLOAD_PENDING') {
+ throw new ApplicationError(
+ API_ERROR_CODES.UPLOAD_NOT_READY,
+ '该附件当前不能确认,请重新选择文件',
+ HttpStatus.CONFLICT,
+ );
+ }
+ let metadata;
+ try {
+ metadata = await this.storage.headObject(file.objectKey);
+ } catch {
+ throw this.storageUnavailable();
+ }
+ if (!metadata) {
+ throw new ApplicationError(
+ API_ERROR_CODES.UPLOAD_NOT_READY,
+ '尚未收到上传文件,请完成上传后重试',
+ HttpStatus.CONFLICT,
+ );
+ }
+ const mismatch =
+ metadata.contentLength !== Number(file.size) ||
+ metadata.contentType !== file.mimeType ||
+ (file.sha256 !== null && metadata.sha256 !== file.sha256);
+ if (mismatch) {
+ await this.repository.quarantine(publicId, actor, request, 'OBJECT_METADATA_MISMATCH');
+ throw this.validation('附件校验未通过,请重新选择文件');
+ }
+ return {
+ file: this.toAttachment(await this.repository.markAvailable(publicId, actor, request)),
+ };
+ }
+
+ async downloadUrl(
+ publicId: string,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ this.assertConfigured();
+ const file = await this.repository.findByPublicId(publicId);
+ if (!file || file.status !== 'AVAILABLE') throw this.notFound();
+ if (file.caseId === null || file.businessType !== 'SERVICE_RECORD') throw this.notFound();
+ await this.cases.requireAssignedCaseByIdForFile(file.caseId, actor);
+ const expiresIn = 300;
+ let downloadUrl: string;
+ try {
+ downloadUrl = await this.storage.createDownloadUrl(file.objectKey, expiresIn);
+ } catch {
+ throw this.storageUnavailable();
+ }
+ await this.audit.record(request, {
+ action: 'PRIVATE_FILE_DOWNLOAD_URL_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'FILE',
+ resourcePublicId: publicId,
+ });
+ return {
+ downloadUrl,
+ expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
+ };
+ }
+
+ private async uploadResponse(file: File, idempotentReplay: boolean) {
+ const expiresIn = 600;
+ let uploadUrl: string;
+ try {
+ uploadUrl = await this.storage.createUploadUrl(
+ file.objectKey,
+ file.mimeType,
+ file.sha256 ?? undefined,
+ expiresIn,
+ );
+ } catch {
+ throw this.storageUnavailable();
+ }
+ return {
+ file: this.toAttachment(file),
+ uploadUrl,
+ uploadHeaders: {
+ 'Content-Type': file.mimeType,
+ ...(file.sha256 ? { 'x-amz-meta-sha256': file.sha256 } : {}),
+ },
+ expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
+ idempotentReplay,
+ } satisfies CreateUploadIntentResponse;
+ }
+
+ private safeFilename(value: string): string {
+ const filename = value.trim();
+ if (
+ !filename ||
+ filename.includes('/') ||
+ filename.includes('\\') ||
+ Array.from(filename).some((character) => character.charCodeAt(0) <= 31)
+ ) {
+ throw this.validation('文件名不合法');
+ }
+ return filename;
+ }
+
+ private toAttachment(file: File): ServiceRecordAttachment {
+ return {
+ publicId: file.publicId,
+ filename: this.sensitiveData.decrypt(file.originalFilenameEncrypted),
+ mimeType: file.mimeType,
+ size: Number(file.size),
+ };
+ }
+
+ private assertConfigured(): void {
+ if (!this.storage.configured || !this.storage.bucket) throw this.storageUnavailable();
+ }
+
+ private storageUnavailable(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.STORAGE_UNAVAILABLE,
+ '附件服务尚未配置或暂时不可用,请稍后重试',
+ HttpStatus.SERVICE_UNAVAILABLE,
+ );
+ }
+
+ private validation(message: string): ApplicationError {
+ return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
+ }
+
+ private duplicateRequest(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.DUPLICATE_REQUEST,
+ '本次上传标识已用于其他文件,请重新选择',
+ HttpStatus.CONFLICT,
+ );
+ }
+
+ private notFound(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.FILE_NOT_FOUND,
+ '未找到该附件',
+ HttpStatus.NOT_FOUND,
+ );
+ }
+}
diff --git a/apps/api/src/files/object-storage.adapter.ts b/apps/api/src/files/object-storage.adapter.ts
new file mode 100644
index 0000000..3c66a31
--- /dev/null
+++ b/apps/api/src/files/object-storage.adapter.ts
@@ -0,0 +1,21 @@
+export interface StoredObjectMetadata {
+ contentLength: number;
+ contentType: string | null;
+ sha256: string | null;
+}
+
+export interface ObjectStorageAdapter {
+ readonly configured: boolean;
+ readonly providerName: string;
+ readonly bucket: string | null;
+ createUploadUrl(
+ objectKey: string,
+ mimeType: string,
+ sha256: string | undefined,
+ expiresInSeconds: number,
+ ): Promise;
+ createDownloadUrl(objectKey: string, expiresInSeconds: number): Promise;
+ headObject(objectKey: string): Promise;
+}
+
+export const OBJECT_STORAGE_ADAPTER = Symbol('OBJECT_STORAGE_ADAPTER');
diff --git a/apps/api/src/files/s3-object-storage.adapter.ts b/apps/api/src/files/s3-object-storage.adapter.ts
new file mode 100644
index 0000000..fd232fe
--- /dev/null
+++ b/apps/api/src/files/s3-object-storage.adapter.ts
@@ -0,0 +1,102 @@
+import {
+ GetObjectCommand,
+ HeadObjectCommand,
+ NotFound,
+ PutObjectCommand,
+ S3Client,
+} from '@aws-sdk/client-s3';
+import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
+import { Inject, Injectable } from '@nestjs/common';
+
+import { AppConfigService } from '../config/app-config.service';
+import type { ObjectStorageAdapter, StoredObjectMetadata } from './object-storage.adapter';
+
+@Injectable()
+export class S3ObjectStorageAdapter implements ObjectStorageAdapter {
+ readonly providerName = 'S3_COMPATIBLE';
+ private readonly client: S3Client | null;
+
+ constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {
+ this.client = config.objectStorageConfigured
+ ? new S3Client({
+ endpoint: config.objectStorageEndpoint ?? undefined,
+ region: config.objectStorageRegion,
+ forcePathStyle: config.objectStorageForcePathStyle,
+ credentials: {
+ accessKeyId: config.objectStorageAccessKey ?? '',
+ secretAccessKey: config.objectStorageSecretKey ?? '',
+ },
+ })
+ : null;
+ }
+
+ get configured(): boolean {
+ return this.client !== null;
+ }
+
+ get bucket(): string | null {
+ return this.config.objectStorageBucket;
+ }
+
+ createUploadUrl(
+ objectKey: string,
+ mimeType: string,
+ sha256: string | undefined,
+ expiresInSeconds: number,
+ ): Promise {
+ return getSignedUrl(
+ this.requiredClient(),
+ new PutObjectCommand({
+ Bucket: this.requiredBucket(),
+ Key: objectKey,
+ ContentType: mimeType,
+ Metadata: sha256 ? { sha256 } : undefined,
+ }),
+ { expiresIn: expiresInSeconds },
+ );
+ }
+
+ createDownloadUrl(objectKey: string, expiresInSeconds: number): Promise {
+ return getSignedUrl(
+ this.requiredClient(),
+ new GetObjectCommand({ Bucket: this.requiredBucket(), Key: objectKey }),
+ { expiresIn: expiresInSeconds },
+ );
+ }
+
+ async headObject(objectKey: string): Promise {
+ try {
+ const result = await this.requiredClient().send(
+ new HeadObjectCommand({ Bucket: this.requiredBucket(), Key: objectKey }),
+ );
+ return {
+ contentLength: result.ContentLength ?? 0,
+ contentType: result.ContentType ?? null,
+ sha256: result.Metadata?.sha256 ?? null,
+ };
+ } catch (error) {
+ if (
+ error instanceof NotFound ||
+ (this.isMetadataError(error) && error.$metadata.httpStatusCode === 404)
+ ) {
+ return null;
+ }
+ throw error;
+ }
+ }
+
+ private requiredClient(): S3Client {
+ if (!this.client) throw new Error('Object storage is not configured');
+ return this.client;
+ }
+
+ private requiredBucket(): string {
+ const bucket = this.config.objectStorageBucket;
+ if (!bucket) throw new Error('Object storage bucket is not configured');
+ return bucket;
+ }
+
+ private isMetadataError(error: unknown): error is { $metadata: { httpStatusCode?: number } } {
+ return typeof error === 'object' && error !== null && '$metadata' in error;
+ }
+}
diff --git a/apps/api/src/follow-ups/admin-follow-ups.controller.ts b/apps/api/src/follow-ups/admin-follow-ups.controller.ts
new file mode 100644
index 0000000..710cb40
--- /dev/null
+++ b/apps/api/src/follow-ups/admin-follow-ups.controller.ts
@@ -0,0 +1,103 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Inject,
+ Param,
+ Post,
+ Query,
+ Req,
+ ValidationPipe,
+} from '@nestjs/common';
+
+import {
+ PERMISSION_CODES,
+ type CompleteServiceCaseResponse,
+ type FollowUpDetailResponse,
+ type FollowUpMutationResponse,
+ type FollowUpWorkbenchSummary,
+ type PaginatedFollowUpsResponse,
+} from '@lehuo/contracts';
+
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SessionChannel } from '../generated/prisma/enums';
+import {
+ CompleteFollowUpDto,
+ CompleteServiceCaseDto,
+ CreateFollowUpDto,
+ FollowUpQueryDto,
+} from './dto/follow-up.dto';
+import { FollowUpsService } from './follow-ups.service';
+
+const strictValidation = (expectedType: new () => object) =>
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType,
+ });
+
+@Controller('api/v1/admin')
+@RequireSessionChannel(SessionChannel.ADMIN)
+export class AdminFollowUpsController {
+ constructor(@Inject(FollowUpsService) private readonly followUps: FollowUpsService) {}
+
+ @Post('service-cases/:publicId/complete')
+ @RequirePermissions(PERMISSION_CODES.CASES_MANAGE, PERMISSION_CODES.FOLLOW_UPS_MANAGE)
+ completeCase(
+ @Param('publicId') publicId: string,
+ @Body(strictValidation(CompleteServiceCaseDto)) input: CompleteServiceCaseDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.followUps.completeCase(publicId, input, actor, request);
+ }
+
+ @Get('follow-ups/summary')
+ @RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_READ)
+ summary(@CurrentAuth() actor: AuthContext): Promise {
+ return this.followUps.summary(actor);
+ }
+
+ @Get('follow-ups')
+ @RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_READ)
+ list(
+ @Query(strictValidation(FollowUpQueryDto)) query: FollowUpQueryDto,
+ @CurrentAuth() actor: AuthContext,
+ ): Promise {
+ return this.followUps.list(query, actor);
+ }
+
+ @Post('follow-ups')
+ @RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_MANAGE)
+ create(
+ @Body(strictValidation(CreateFollowUpDto)) input: CreateFollowUpDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.followUps.create(input, actor, request);
+ }
+
+ @Get('follow-ups/:publicId')
+ @RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_READ)
+ get(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ ): Promise {
+ return this.followUps.get(publicId, actor);
+ }
+
+ @Post('follow-ups/:publicId/complete')
+ @RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_COMPLETE)
+ complete(
+ @Param('publicId') publicId: string,
+ @Body(strictValidation(CompleteFollowUpDto)) input: CompleteFollowUpDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.followUps.complete(publicId, input, actor, request);
+ }
+}
diff --git a/apps/api/src/follow-ups/dto/follow-up.dto.ts b/apps/api/src/follow-ups/dto/follow-up.dto.ts
new file mode 100644
index 0000000..ec3d548
--- /dev/null
+++ b/apps/api/src/follow-ups/dto/follow-up.dto.ts
@@ -0,0 +1,113 @@
+import { Transform, Type } from 'class-transformer';
+import {
+ IsBoolean,
+ IsIn,
+ IsInt,
+ IsISO8601,
+ IsOptional,
+ IsString,
+ IsUUID,
+ Max,
+ MaxLength,
+ Min,
+ MinLength,
+} from 'class-validator';
+
+import { FOLLOW_UP_RESULT_CODES, FOLLOW_UP_STATUSES, FOLLOW_UP_TYPES } from '@lehuo/contracts';
+
+export class CompleteServiceCaseDto {
+ @IsUUID('4')
+ clientRequestId!: string;
+
+ @IsString()
+ @MinLength(2)
+ @MaxLength(5000)
+ resultSummary!: string;
+
+ @IsString()
+ @MinLength(2)
+ @MaxLength(2000)
+ followUpPlan!: string;
+
+ @IsISO8601({ strict: true })
+ followUpDueAt!: string;
+}
+
+export class CreateFollowUpDto {
+ @IsUUID('4')
+ clientRequestId!: string;
+
+ @IsUUID('4')
+ beneficiaryPublicId!: string;
+
+ @IsUUID('4')
+ ownerStaffPublicId!: string;
+
+ @IsOptional()
+ @IsUUID('4')
+ serviceCasePublicId?: string;
+
+ @IsIn(FOLLOW_UP_TYPES.filter((value) => value !== 'SERVICE_COMPLETION'))
+ followUpType!: 'KEY_CARE' | 'MANUAL';
+
+ @IsISO8601({ strict: true })
+ dueAt!: string;
+
+ @IsString()
+ @MinLength(2)
+ @MaxLength(2000)
+ reason!: string;
+}
+
+export class CompleteFollowUpDto {
+ @IsUUID('4')
+ clientRequestId!: string;
+
+ @IsIn(FOLLOW_UP_RESULT_CODES)
+ resultCode!: (typeof FOLLOW_UP_RESULT_CODES)[number];
+
+ @IsString()
+ @MinLength(2)
+ @MaxLength(5000)
+ resultNote!: string;
+
+ @IsOptional()
+ @IsIn([true])
+ continuationConsentConfirmed?: true;
+
+ @IsOptional()
+ @IsString()
+ @MinLength(10)
+ @MaxLength(5000)
+ continuationDescription?: string;
+}
+
+export class FollowUpQueryDto {
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ page = 1;
+
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(100)
+ pageSize = 20;
+
+ @IsOptional()
+ @IsIn(FOLLOW_UP_STATUSES)
+ status?: (typeof FOLLOW_UP_STATUSES)[number];
+
+ @IsOptional()
+ @IsIn(FOLLOW_UP_TYPES)
+ followUpType?: (typeof FOLLOW_UP_TYPES)[number];
+
+ @IsOptional()
+ @Transform(({ value }) => {
+ if (value === 'true' || value === true) return true;
+ if (value === 'false' || value === false) return false;
+ return value as unknown;
+ })
+ @IsBoolean()
+ overdue?: boolean;
+}
diff --git a/apps/api/src/follow-ups/follow-ups.module.ts b/apps/api/src/follow-ups/follow-ups.module.ts
new file mode 100644
index 0000000..960f16e
--- /dev/null
+++ b/apps/api/src/follow-ups/follow-ups.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+
+import { AuditModule } from '../audit/audit.module';
+import { ContentNotificationsModule } from '../content-notifications/content-notifications.module';
+import { AdminFollowUpsController } from './admin-follow-ups.controller';
+import { FollowUpsRepository } from './follow-ups.repository';
+import { FollowUpsService } from './follow-ups.service';
+
+@Module({
+ imports: [AuditModule, ContentNotificationsModule],
+ controllers: [AdminFollowUpsController],
+ providers: [FollowUpsRepository, FollowUpsService],
+})
+export class FollowUpsModule {}
diff --git a/apps/api/src/follow-ups/follow-ups.repository.ts b/apps/api/src/follow-ups/follow-ups.repository.ts
new file mode 100644
index 0000000..4dc5273
--- /dev/null
+++ b/apps/api/src/follow-ups/follow-ups.repository.ts
@@ -0,0 +1,466 @@
+import { Inject, Injectable } from '@nestjs/common';
+
+import type { FollowUpResultCode, FollowUpStatus, FollowUpType } from '@lehuo/contracts';
+
+import { AuditService } from '../audit/audit.service';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { PrismaService } from '../database/prisma.service';
+import type { Prisma } from '../generated/prisma/client';
+import { NotificationsService } from '../content-notifications/notifications.service';
+
+const followUpInclude = {
+ beneficiary: true,
+ serviceCase: { include: { application: true } },
+ owner: { include: { staffProfile: true } },
+ continuedApplication: true,
+ statusHistory: { orderBy: { createdAt: 'asc' as const } },
+} satisfies Prisma.FollowUpTaskInclude;
+
+export type StoredFollowUp = Prisma.FollowUpTaskGetPayload<{ include: typeof followUpInclude }>;
+
+export interface CompleteCaseInput {
+ requestId: string;
+ fingerprint: string;
+ resultSummaryEncrypted: string;
+ followUpPlanEncrypted: string;
+ followUpDueAt: Date;
+ followUpPublicId: string;
+ followUpNo: string;
+ followUpReasonEncrypted: string;
+}
+
+export interface CreateFollowUpInput {
+ requestId: string;
+ fingerprint: string;
+ publicId: string;
+ followUpNo: string;
+ beneficiaryId: bigint;
+ caseId?: bigint;
+ ownerStaffId: bigint;
+ dueAt: Date;
+ followUpType: 'KEY_CARE' | 'MANUAL';
+ reasonEncrypted: string;
+}
+
+export interface CompleteFollowUpInput {
+ requestId: string;
+ fingerprint: string;
+ resultCode: FollowUpResultCode;
+ resultNoteEncrypted: string;
+ continuation?: {
+ publicId: string;
+ applicationNo: string;
+ requestFingerprint: string;
+ consentPublicId: string;
+ descriptionEncrypted: string;
+ };
+}
+
+@Injectable()
+export class FollowUpsRepository {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ @Inject(NotificationsService) private readonly notifications: NotificationsService,
+ ) {}
+
+ findCase(publicId: string) {
+ return this.prisma.serviceCase.findUnique({
+ where: { publicId },
+ include: {
+ application: true,
+ beneficiary: true,
+ owner: { include: { staffProfile: true } },
+ _count: { select: { serviceRecords: true } },
+ followUpTasks: { where: { source: 'AUTO_SERVICE_COMPLETION' }, include: followUpInclude },
+ },
+ });
+ }
+
+ findBeneficiary(publicId: string) {
+ return this.prisma.beneficiaryProfile.findUnique({ where: { publicId } });
+ }
+
+ findStaff(publicId: string) {
+ return this.prisma.user.findFirst({
+ where: { publicId, status: 'ACTIVE', userType: 'STAFF', staffProfile: { isNot: null } },
+ include: { staffProfile: true },
+ });
+ }
+
+ findByCreateRequest(createdById: bigint, requestId: string) {
+ return this.prisma.followUpTask.findUnique({
+ where: { createdById_createRequestId: { createdById, createRequestId: requestId } },
+ include: followUpInclude,
+ });
+ }
+
+ find(publicId: string) {
+ return this.prisma.followUpTask.findUnique({ where: { publicId }, include: followUpInclude });
+ }
+
+ async list(
+ filters: { status?: FollowUpStatus; followUpType?: FollowUpType; overdue?: boolean },
+ actorUserId: bigint,
+ accessAll: boolean,
+ page: number,
+ pageSize: number,
+ now: Date,
+ ) {
+ const where: Prisma.FollowUpTaskWhereInput = {
+ ...(accessAll ? {} : { ownerStaffId: actorUserId }),
+ ...(filters.status ? { status: filters.status } : {}),
+ ...(filters.followUpType ? { followUpType: filters.followUpType } : {}),
+ ...(filters.overdue === true ? { status: 'PENDING', dueAt: { lt: now } } : {}),
+ };
+ const [items, total] = await this.prisma.$transaction([
+ this.prisma.followUpTask.findMany({
+ where,
+ include: followUpInclude,
+ orderBy: [{ dueAt: 'asc' }, { id: 'asc' }],
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ this.prisma.followUpTask.count({ where }),
+ ]);
+ return { items, total };
+ }
+
+ async summary(
+ actorUserId: bigint,
+ accessAll: boolean,
+ start: Date,
+ tomorrow: Date,
+ now: Date,
+ weekEnd: Date,
+ ) {
+ const base: Prisma.FollowUpTaskWhereInput = {
+ status: 'PENDING',
+ ...(accessAll ? {} : { ownerStaffId: actorUserId }),
+ };
+ const [dueToday, overdue, dueThisWeek, keyCarePending] = await this.prisma.$transaction([
+ this.prisma.followUpTask.count({ where: { ...base, dueAt: { gte: start, lt: tomorrow } } }),
+ this.prisma.followUpTask.count({ where: { ...base, dueAt: { lt: now } } }),
+ this.prisma.followUpTask.count({ where: { ...base, dueAt: { gte: now, lt: weekEnd } } }),
+ this.prisma.followUpTask.count({
+ where: { ...base, beneficiary: { keyCareFlag: true } },
+ }),
+ ]);
+ return { dueToday, overdue, dueThisWeek, keyCarePending };
+ }
+
+ async completeCase(
+ serviceCase: NonNullable>>,
+ input: CompleteCaseInput,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ return this.prisma.$transaction(async (transaction) => {
+ const current = await transaction.serviceCase.findUniqueOrThrow({
+ where: { id: serviceCase.id },
+ include: { _count: { select: { serviceRecords: true } } },
+ });
+ if (current.status !== 'IN_SERVICE' || current.ownerStaffId !== actor.userId)
+ throw new Error('CASE_COMPLETE_STATE_CONFLICT');
+ if (current._count.serviceRecords < 1) throw new Error('CASE_REQUIRES_RECORD');
+
+ await transaction.serviceCase.update({
+ where: { id: current.id },
+ data: {
+ status: 'SERVICE_COMPLETED',
+ resultSummaryEncrypted: input.resultSummaryEncrypted,
+ followUpPlanEncrypted: input.followUpPlanEncrypted,
+ completedAt: new Date(),
+ completeRequestId: input.requestId,
+ completeRequestFingerprint: input.fingerprint,
+ updatedById: actor.userId,
+ },
+ });
+ await transaction.serviceCaseStatusHistory.create({
+ data: {
+ caseId: current.id,
+ fromStatus: 'IN_SERVICE',
+ toStatus: 'SERVICE_COMPLETED',
+ operatorUserId: actor.userId,
+ reasonCode: 'SERVICE_COMPLETED',
+ },
+ });
+ const applicationCompleted = await transaction.serviceApplication.updateMany({
+ where: { id: current.applicationId, status: 'IN_SERVICE' },
+ data: { status: 'SERVICE_COMPLETED' },
+ });
+ if (applicationCompleted.count !== 1) throw new Error('APPLICATION_STATE_CONFLICT');
+ await transaction.serviceApplicationStatusHistory.create({
+ data: {
+ applicationId: current.applicationId,
+ fromStatus: 'IN_SERVICE',
+ toStatus: 'SERVICE_COMPLETED',
+ operatorUserId: actor.userId,
+ reasonCode: 'SERVICE_COMPLETED',
+ },
+ });
+ await transaction.serviceApplication.update({
+ where: { id: current.applicationId },
+ data: { status: 'FOLLOW_UP_PENDING' },
+ });
+ await transaction.serviceApplicationStatusHistory.create({
+ data: {
+ applicationId: current.applicationId,
+ fromStatus: 'SERVICE_COMPLETED',
+ toStatus: 'FOLLOW_UP_PENDING',
+ operatorUserId: actor.userId,
+ reasonCode: 'FOLLOW_UP_CREATED',
+ },
+ });
+ const application = await transaction.serviceApplication.findUniqueOrThrow({
+ where: { id: current.applicationId },
+ });
+ await this.notifications.create(transaction, {
+ userId: application.applicantUserId,
+ eventKey: `application:${application.publicId}:completed`,
+ type: 'SERVICE_COMPLETED',
+ title: '本次服务已完成',
+ body: '本次服务已记录完成,感谢您的配合。',
+ resourceType: 'SERVICE_APPLICATION',
+ resourcePublicId: application.publicId,
+ });
+ await this.notifications.create(transaction, {
+ userId: application.applicantUserId,
+ eventKey: `application:${application.publicId}:follow-up`,
+ type: 'FOLLOW_UP_PENDING',
+ title: '后续将进行服务回访',
+ body: '工作人员将在约定时间与您回访服务情况。',
+ resourceType: 'SERVICE_APPLICATION',
+ resourcePublicId: application.publicId,
+ });
+ const followUp = await transaction.followUpTask.create({
+ data: {
+ publicId: input.followUpPublicId,
+ followUpNo: input.followUpNo,
+ beneficiaryId: current.beneficiaryId,
+ caseId: current.id,
+ ownerStaffId: current.ownerStaffId,
+ dueAt: input.followUpDueAt,
+ followUpType: 'SERVICE_COMPLETION',
+ source: 'AUTO_SERVICE_COMPLETION',
+ reasonEncrypted: input.followUpReasonEncrypted,
+ createRequestId: input.requestId,
+ createRequestFingerprint: input.fingerprint,
+ createdById: actor.userId,
+ updatedById: actor.userId,
+ },
+ });
+ await transaction.followUpStatusHistory.create({
+ data: {
+ followUpTaskId: followUp.id,
+ toStatus: 'PENDING',
+ operatorUserId: actor.userId,
+ reasonCode: 'AUTO_CREATED_AFTER_SERVICE',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'SERVICE_CASE_COMPLETED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'SERVICE_CASE',
+ resourcePublicId: serviceCase.publicId,
+ metadata: { followUpPublicId: input.followUpPublicId },
+ },
+ transaction,
+ );
+ await this.audit.record(
+ request,
+ {
+ action: 'FOLLOW_UP_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'FOLLOW_UP_TASK',
+ resourcePublicId: input.followUpPublicId,
+ metadata: { source: 'AUTO_SERVICE_COMPLETION' },
+ },
+ transaction,
+ );
+ return transaction.followUpTask.findUniqueOrThrow({
+ where: { id: followUp.id },
+ include: followUpInclude,
+ });
+ });
+ }
+
+ async create(
+ input: CreateFollowUpInput,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ return this.prisma.$transaction(async (transaction) => {
+ const followUp = await transaction.followUpTask.create({
+ data: {
+ publicId: input.publicId,
+ followUpNo: input.followUpNo,
+ beneficiaryId: input.beneficiaryId,
+ caseId: input.caseId,
+ ownerStaffId: input.ownerStaffId,
+ dueAt: input.dueAt,
+ followUpType: input.followUpType,
+ source: input.followUpType === 'KEY_CARE' ? 'KEY_CARE_PLAN' : 'STAFF_MANUAL',
+ reasonEncrypted: input.reasonEncrypted,
+ createRequestId: input.requestId,
+ createRequestFingerprint: input.fingerprint,
+ createdById: actor.userId,
+ updatedById: actor.userId,
+ },
+ });
+ await transaction.followUpStatusHistory.create({
+ data: {
+ followUpTaskId: followUp.id,
+ toStatus: 'PENDING',
+ operatorUserId: actor.userId,
+ reasonCode: 'MANUAL_CREATED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'FOLLOW_UP_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'FOLLOW_UP_TASK',
+ resourcePublicId: input.publicId,
+ metadata: {
+ source: input.followUpType === 'KEY_CARE' ? 'KEY_CARE_PLAN' : 'STAFF_MANUAL',
+ },
+ },
+ transaction,
+ );
+ return transaction.followUpTask.findUniqueOrThrow({
+ where: { id: followUp.id },
+ include: followUpInclude,
+ });
+ });
+ }
+
+ async complete(
+ followUp: StoredFollowUp,
+ input: CompleteFollowUpInput,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ return this.prisma.$transaction(async (transaction) => {
+ const changed = await transaction.followUpTask.updateMany({
+ where: { id: followUp.id, status: 'PENDING', ownerStaffId: actor.userId },
+ data: {
+ status: 'COMPLETED',
+ resultCode: input.resultCode,
+ resultNoteEncrypted: input.resultNoteEncrypted,
+ completeRequestId: input.requestId,
+ completeRequestFingerprint: input.fingerprint,
+ completedAt: new Date(),
+ updatedById: actor.userId,
+ },
+ });
+ if (changed.count !== 1) throw new Error('FOLLOW_UP_STATE_CONFLICT');
+ await transaction.followUpStatusHistory.create({
+ data: {
+ followUpTaskId: followUp.id,
+ fromStatus: 'PENDING',
+ toStatus: 'COMPLETED',
+ operatorUserId: actor.userId,
+ reasonCode: input.resultCode,
+ },
+ });
+
+ const parent = followUp.serviceCase?.application;
+ if (input.continuation) {
+ if (!parent) throw new Error('CONTINUATION_REQUIRES_APPLICATION');
+ const beneficiary = await transaction.beneficiaryProfile.findUniqueOrThrow({
+ where: { id: followUp.beneficiaryId },
+ });
+ const consent = await transaction.consent.create({
+ data: {
+ publicId: input.continuation.consentPublicId,
+ userId: parent.applicantUserId,
+ beneficiaryId: followUp.beneficiaryId,
+ consentType: 'SERVICE_APPLICATION',
+ version: 'service-continuation-v1',
+ grantedBy: beneficiary.isMinor ? 'GUARDIAN' : 'APPLICANT',
+ grantedAt: new Date(),
+ evidenceJson: {
+ source: 'ADMIN_FOLLOW_UP',
+ confirmationMethod: 'EXPLICIT_CONFIRMATION',
+ followUpPublicId: followUp.publicId,
+ },
+ },
+ });
+ const continued = await transaction.serviceApplication.create({
+ data: {
+ publicId: input.continuation.publicId,
+ applicationNo: input.continuation.applicationNo,
+ applicantUserId: parent.applicantUserId,
+ consentId: consent.id,
+ clientRequestId: input.requestId,
+ requestFingerprint: input.continuation.requestFingerprint,
+ applyMode: parent.applyMode,
+ serviceCategory: parent.serviceCategory,
+ beneficiaryNameEncrypted: parent.beneficiaryNameEncrypted,
+ contactMobileEncrypted: parent.contactMobileEncrypted,
+ contactMobileHash: parent.contactMobileHash,
+ regionCode: parent.regionCode,
+ descriptionEncrypted: input.continuation.descriptionEncrypted,
+ preferredContactTime: parent.preferredContactTime,
+ needsHomeVisit: parent.needsHomeVisit,
+ mobilityDifficulty: parent.mobilityDifficulty,
+ source: 'FOLLOW_UP',
+ beneficiaryId: followUp.beneficiaryId,
+ sourceFollowUpTaskId: followUp.id,
+ parentApplicationId: parent.id,
+ submittedAt: new Date(),
+ },
+ });
+ await transaction.serviceApplicationStatusHistory.create({
+ data: {
+ applicationId: continued.id,
+ toStatus: 'SUBMITTED',
+ operatorUserId: actor.userId,
+ reasonCode: 'FOLLOW_UP_CONTINUATION_CREATED',
+ },
+ });
+ }
+ if (parent?.status === 'FOLLOW_UP_PENDING') {
+ await transaction.serviceApplication.update({
+ where: { id: parent.id },
+ data: { status: 'CLOSED', closedAt: new Date() },
+ });
+ await transaction.serviceApplicationStatusHistory.create({
+ data: {
+ applicationId: parent.id,
+ fromStatus: 'FOLLOW_UP_PENDING',
+ toStatus: 'CLOSED',
+ operatorUserId: actor.userId,
+ reasonCode: input.continuation ? 'CONTINUATION_CREATED' : 'FOLLOW_UP_COMPLETED',
+ },
+ });
+ }
+ await this.audit.record(
+ request,
+ {
+ action: 'FOLLOW_UP_COMPLETED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'FOLLOW_UP_TASK',
+ resourcePublicId: followUp.publicId,
+ metadata: {
+ resultCode: input.resultCode,
+ continuationCreated: Boolean(input.continuation),
+ },
+ },
+ transaction,
+ );
+ return transaction.followUpTask.findUniqueOrThrow({
+ where: { id: followUp.id },
+ include: followUpInclude,
+ });
+ });
+ }
+}
diff --git a/apps/api/src/follow-ups/follow-ups.service.ts b/apps/api/src/follow-ups/follow-ups.service.ts
new file mode 100644
index 0000000..5cab8e5
--- /dev/null
+++ b/apps/api/src/follow-ups/follow-ups.service.ts
@@ -0,0 +1,427 @@
+import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { randomBytes, randomUUID } from 'node:crypto';
+
+import {
+ API_ERROR_CODES,
+ FOLLOW_UP_RESULT_LABELS,
+ FOLLOW_UP_STATUS_LABELS,
+ FOLLOW_UP_TYPE_LABELS,
+ PERMISSION_CODES,
+ type CompleteServiceCaseResponse,
+ type FollowUpDetail,
+ type FollowUpDetailResponse,
+ type FollowUpListItem,
+ type FollowUpMutationResponse,
+ type FollowUpWorkbenchSummary,
+ type PaginatedFollowUpsResponse,
+} from '@lehuo/contracts';
+
+import { ApplicationError } from '../common/application-error';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+import type {
+ CompleteFollowUpDto,
+ CompleteServiceCaseDto,
+ CreateFollowUpDto,
+ FollowUpQueryDto,
+} from './dto/follow-up.dto';
+import { FollowUpsRepository, type StoredFollowUp } from './follow-ups.repository';
+
+function shanghaiDatePrefix(date: Date): string {
+ const parts = new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(date);
+ const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
+ return `${value.year}${value.month}${value.day}`;
+}
+
+function shanghaiDayStart(now: Date): Date {
+ const date = new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).format(now);
+ return new Date(`${date}T00:00:00+08:00`);
+}
+
+function isUniqueConstraintError(error: unknown): error is { code: 'P2002' } {
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
+}
+
+@Injectable()
+export class FollowUpsService {
+ constructor(
+ @Inject(FollowUpsRepository) private readonly repository: FollowUpsRepository,
+ @Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
+ ) {}
+
+ async completeCase(
+ publicId: string,
+ input: CompleteServiceCaseDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const normalized = {
+ resultSummary: input.resultSummary.trim(),
+ followUpPlan: input.followUpPlan.trim(),
+ followUpDueAt: new Date(input.followUpDueAt).toISOString(),
+ };
+ const fingerprint = this.sensitiveData.lookupHash(
+ 'service-case-completion',
+ JSON.stringify(normalized),
+ );
+ const serviceCase = await this.repository.findCase(publicId);
+ if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务案件');
+ if (serviceCase.completeRequestId) {
+ if (
+ serviceCase.completeRequestId !== input.clientRequestId ||
+ serviceCase.completeRequestFingerprint !== fingerprint ||
+ !serviceCase.followUpTasks[0]
+ ) {
+ throw this.duplicateRequest();
+ }
+ return this.caseCompletionResponse(serviceCase.followUpTasks[0], true);
+ }
+ if (serviceCase.status !== 'IN_SERVICE' || serviceCase.ownerStaffId !== actor.userId) {
+ throw this.invalidTransition();
+ }
+ if (serviceCase._count.serviceRecords < 1) {
+ throw this.validation('至少记录一次实际服务后才能完成案件');
+ }
+ const dueAt = new Date(normalized.followUpDueAt);
+ if (dueAt <= new Date()) throw this.validation('回访时间必须晚于当前时间');
+ const now = new Date();
+ try {
+ const followUp = await this.repository.completeCase(
+ serviceCase,
+ {
+ requestId: input.clientRequestId,
+ fingerprint,
+ resultSummaryEncrypted: this.sensitiveData.encrypt(normalized.resultSummary),
+ followUpPlanEncrypted: this.sensitiveData.encrypt(normalized.followUpPlan),
+ followUpDueAt: dueAt,
+ followUpPublicId: randomUUID(),
+ followUpNo: `HF${shanghaiDatePrefix(now)}${randomBytes(5).toString('hex').toUpperCase()}`,
+ followUpReasonEncrypted: this.sensitiveData.encrypt(normalized.followUpPlan),
+ },
+ actor,
+ request,
+ );
+ return this.caseCompletionResponse(followUp, false);
+ } catch (error) {
+ if (isUniqueConstraintError(error)) {
+ const replay = await this.repository.findCase(publicId);
+ if (
+ replay?.completeRequestId === input.clientRequestId &&
+ replay.completeRequestFingerprint === fingerprint &&
+ replay.followUpTasks[0]
+ ) {
+ return this.caseCompletionResponse(replay.followUpTasks[0], true);
+ }
+ throw this.duplicateRequest();
+ }
+ if (error instanceof Error && error.message === 'CASE_REQUIRES_RECORD') {
+ throw this.validation('至少记录一次实际服务后才能完成案件');
+ }
+ if (
+ error instanceof Error &&
+ ['CASE_COMPLETE_STATE_CONFLICT', 'APPLICATION_STATE_CONFLICT'].includes(error.message)
+ ) {
+ throw this.invalidTransition();
+ }
+ throw error;
+ }
+ }
+
+ async create(
+ input: CreateFollowUpDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const normalized = {
+ beneficiaryPublicId: input.beneficiaryPublicId,
+ ownerStaffPublicId: input.ownerStaffPublicId,
+ serviceCasePublicId: input.serviceCasePublicId ?? null,
+ followUpType: input.followUpType,
+ dueAt: new Date(input.dueAt).toISOString(),
+ reason: input.reason.trim(),
+ };
+ const fingerprint = this.sensitiveData.lookupHash(
+ 'follow-up-create',
+ JSON.stringify(normalized),
+ );
+ const replay = await this.repository.findByCreateRequest(actor.userId, input.clientRequestId);
+ if (replay) {
+ if (replay.createRequestFingerprint !== fingerprint) throw this.duplicateRequest();
+ return { followUp: this.toDetail(replay), idempotentReplay: true };
+ }
+ const [beneficiary, owner, serviceCase] = await Promise.all([
+ this.repository.findBeneficiary(input.beneficiaryPublicId),
+ this.repository.findStaff(input.ownerStaffPublicId),
+ input.serviceCasePublicId
+ ? this.repository.findCase(input.serviceCasePublicId)
+ : Promise.resolve(null),
+ ]);
+ if (!beneficiary)
+ throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案');
+ if (!owner) throw this.validation('回访负责人必须是有效工作人员');
+ if (input.serviceCasePublicId && !serviceCase)
+ throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务案件');
+ if (serviceCase && serviceCase.beneficiaryId !== beneficiary.id) {
+ throw this.validation('服务案件与回访对象不一致');
+ }
+ if (input.followUpType === 'KEY_CARE' && !beneficiary.keyCareFlag) {
+ throw this.validation('只有重点关爱对象可以创建重点关爱回访');
+ }
+ const dueAt = new Date(normalized.dueAt);
+ if (dueAt <= new Date()) throw this.validation('回访时间必须晚于当前时间');
+ const now = new Date();
+ try {
+ const created = await this.repository.create(
+ {
+ requestId: input.clientRequestId,
+ fingerprint,
+ publicId: randomUUID(),
+ followUpNo: `HF${shanghaiDatePrefix(now)}${randomBytes(5).toString('hex').toUpperCase()}`,
+ beneficiaryId: beneficiary.id,
+ caseId: serviceCase?.id,
+ ownerStaffId: owner.id,
+ dueAt,
+ followUpType: input.followUpType,
+ reasonEncrypted: this.sensitiveData.encrypt(normalized.reason),
+ },
+ actor,
+ request,
+ );
+ return { followUp: this.toDetail(created), idempotentReplay: false };
+ } catch (error) {
+ if (isUniqueConstraintError(error)) {
+ const existing = await this.repository.findByCreateRequest(
+ actor.userId,
+ input.clientRequestId,
+ );
+ if (existing?.createRequestFingerprint === fingerprint) {
+ return { followUp: this.toDetail(existing), idempotentReplay: true };
+ }
+ throw this.duplicateRequest();
+ }
+ throw error;
+ }
+ }
+
+ async list(query: FollowUpQueryDto, actor: AuthContext): Promise {
+ const result = await this.repository.list(
+ query,
+ actor.userId,
+ actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL),
+ query.page,
+ query.pageSize,
+ new Date(),
+ );
+ return {
+ items: result.items.map((item) => this.toListItem(item)),
+ page: query.page,
+ pageSize: query.pageSize,
+ total: result.total,
+ };
+ }
+
+ async summary(actor: AuthContext): Promise {
+ const now = new Date();
+ const start = shanghaiDayStart(now);
+ const tomorrow = new Date(start.getTime() + 24 * 60 * 60 * 1000);
+ const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
+ return this.repository.summary(
+ actor.userId,
+ actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL),
+ start,
+ tomorrow,
+ now,
+ weekEnd,
+ );
+ }
+
+ async get(publicId: string, actor: AuthContext): Promise {
+ const followUp = await this.repository.find(publicId);
+ if (!followUp) throw this.notFound(API_ERROR_CODES.FOLLOW_UP_NOT_FOUND, '未找到该回访任务');
+ this.assertVisible(followUp, actor);
+ return { followUp: this.toDetail(followUp) };
+ }
+
+ async complete(
+ publicId: string,
+ input: CompleteFollowUpDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const normalized = {
+ resultCode: input.resultCode,
+ resultNote: input.resultNote.trim(),
+ continuationConsentConfirmed: input.continuationConsentConfirmed === true,
+ continuationDescription: input.continuationDescription?.trim() ?? null,
+ };
+ if (input.resultCode === 'NEED_MORE_HELP') {
+ if (input.continuationConsentConfirmed !== true || !normalized.continuationDescription) {
+ throw this.validation('继续帮扶必须确认服务对象或监护人授权,并填写新的帮扶需求');
+ }
+ } else if (input.continuationConsentConfirmed || input.continuationDescription) {
+ throw this.validation('只有“需要继续帮扶”才能创建关联申请');
+ }
+ const fingerprint = this.sensitiveData.lookupHash(
+ 'follow-up-completion',
+ JSON.stringify(normalized),
+ );
+ const followUp = await this.repository.find(publicId);
+ if (!followUp) throw this.notFound(API_ERROR_CODES.FOLLOW_UP_NOT_FOUND, '未找到该回访任务');
+ if (followUp.completeRequestId) {
+ if (
+ followUp.completeRequestId !== input.clientRequestId ||
+ followUp.completeRequestFingerprint !== fingerprint
+ ) {
+ throw this.duplicateRequest();
+ }
+ return { followUp: this.toDetail(followUp), idempotentReplay: true };
+ }
+ if (followUp.status !== 'PENDING' || followUp.ownerStaffId !== actor.userId) {
+ throw this.invalidTransition();
+ }
+ if (input.resultCode === 'NEED_MORE_HELP' && !followUp.serviceCase?.application) {
+ throw this.validation('该回访没有可关联的原服务申请,不能创建继续帮扶申请');
+ }
+ const now = new Date();
+ try {
+ const completed = await this.repository.complete(
+ followUp,
+ {
+ requestId: input.clientRequestId,
+ fingerprint,
+ resultCode: input.resultCode,
+ resultNoteEncrypted: this.sensitiveData.encrypt(normalized.resultNote),
+ continuation:
+ input.resultCode === 'NEED_MORE_HELP' && normalized.continuationDescription
+ ? {
+ publicId: randomUUID(),
+ applicationNo: `SQ${shanghaiDatePrefix(now)}${randomBytes(5).toString('hex').toUpperCase()}`,
+ requestFingerprint: this.sensitiveData.lookupHash(
+ 'service-continuation',
+ JSON.stringify({ followUpPublicId: publicId, ...normalized }),
+ ),
+ consentPublicId: randomUUID(),
+ descriptionEncrypted: this.sensitiveData.encrypt(
+ normalized.continuationDescription,
+ ),
+ }
+ : undefined,
+ },
+ actor,
+ request,
+ );
+ return { followUp: this.toDetail(completed), idempotentReplay: false };
+ } catch (error) {
+ if (isUniqueConstraintError(error)) {
+ const replay = await this.repository.find(publicId);
+ if (
+ replay?.completeRequestId === input.clientRequestId &&
+ replay.completeRequestFingerprint === fingerprint
+ ) {
+ return { followUp: this.toDetail(replay), idempotentReplay: true };
+ }
+ throw this.duplicateRequest();
+ }
+ if (error instanceof Error && error.message === 'FOLLOW_UP_STATE_CONFLICT') {
+ throw this.invalidTransition();
+ }
+ throw error;
+ }
+ }
+
+ private caseCompletionResponse(
+ followUp: StoredFollowUp,
+ idempotentReplay: boolean,
+ ): CompleteServiceCaseResponse {
+ return {
+ serviceCasePublicId: followUp.serviceCase?.publicId ?? '',
+ serviceCaseStatus: 'SERVICE_COMPLETED',
+ applicationStatus: 'FOLLOW_UP_PENDING',
+ followUp: this.toListItem(followUp),
+ idempotentReplay,
+ };
+ }
+
+ private toListItem(followUp: StoredFollowUp): FollowUpListItem {
+ return {
+ publicId: followUp.publicId,
+ followUpNo: followUp.followUpNo,
+ beneficiaryPublicId: followUp.beneficiary.publicId,
+ beneficiaryDisplayName: this.sensitiveData.decrypt(followUp.beneficiary.displayNameEncrypted),
+ beneficiaryKeyCareFlag: followUp.beneficiary.keyCareFlag,
+ category: followUp.beneficiary.primaryCategory,
+ serviceCasePublicId: followUp.serviceCase?.publicId ?? null,
+ serviceCaseNo: followUp.serviceCase?.caseNo ?? null,
+ ownerStaffPublicId: followUp.owner.publicId,
+ ownerDisplayName: followUp.owner.staffProfile?.displayName ?? '工作人员',
+ dueAt: followUp.dueAt.toISOString(),
+ followUpType: followUp.followUpType,
+ followUpTypeLabel: FOLLOW_UP_TYPE_LABELS[followUp.followUpType],
+ status: followUp.status,
+ statusLabel: FOLLOW_UP_STATUS_LABELS[followUp.status],
+ resultCode: followUp.resultCode,
+ resultLabel: followUp.resultCode ? FOLLOW_UP_RESULT_LABELS[followUp.resultCode] : null,
+ createdAt: followUp.createdAt.toISOString(),
+ completedAt: followUp.completedAt?.toISOString() ?? null,
+ };
+ }
+
+ private toDetail(followUp: StoredFollowUp): FollowUpDetail {
+ return {
+ ...this.toListItem(followUp),
+ reason: this.sensitiveData.decrypt(followUp.reasonEncrypted),
+ resultNote: followUp.resultNoteEncrypted
+ ? this.sensitiveData.decrypt(followUp.resultNoteEncrypted)
+ : null,
+ continuedApplicationPublicId: followUp.continuedApplication?.publicId ?? null,
+ continuedApplicationNo: followUp.continuedApplication?.applicationNo ?? null,
+ };
+ }
+
+ private assertVisible(followUp: StoredFollowUp, actor: AuthContext): void {
+ if (
+ followUp.ownerStaffId !== actor.userId &&
+ !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
+ ) {
+ throw new ApplicationError(
+ API_ERROR_CODES.FORBIDDEN,
+ '无权访问该回访任务',
+ HttpStatus.FORBIDDEN,
+ );
+ }
+ }
+
+ private invalidTransition(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.INVALID_STATE_TRANSITION,
+ '当前状态不能执行此操作,请刷新后重试',
+ HttpStatus.CONFLICT,
+ );
+ }
+
+ private duplicateRequest(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.DUPLICATE_REQUEST,
+ '本次提交标识已用于其他内容,请重新操作',
+ HttpStatus.CONFLICT,
+ );
+ }
+
+ private validation(message: string): ApplicationError {
+ return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
+ }
+
+ private notFound(code: string, message: string): ApplicationError {
+ return new ApplicationError(code as never, message, HttpStatus.NOT_FOUND);
+ }
+}
diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts
new file mode 100644
index 0000000..cf54e94
--- /dev/null
+++ b/apps/api/src/health/health.controller.ts
@@ -0,0 +1,27 @@
+import { Controller, Get, Inject } from '@nestjs/common';
+
+import type { HealthResponse } from '@lehuo/contracts';
+
+import { Public } from '../common/decorators/public.decorator';
+import { RawResponse } from '../common/decorators/raw-response.decorator';
+import { HealthService } from './health.service';
+
+@Controller('health')
+@Public()
+@RawResponse()
+export class HealthController {
+ constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
+
+ @Get()
+ getHealth(): HealthResponse {
+ return this.healthService.getHealth();
+ }
+ @Get('live')
+ liveness(): HealthResponse {
+ return this.healthService.getHealth();
+ }
+ @Get('ready')
+ readiness() {
+ return this.healthService.readiness();
+ }
+}
diff --git a/apps/api/src/health/health.service.spec.ts b/apps/api/src/health/health.service.spec.ts
new file mode 100644
index 0000000..1818e82
--- /dev/null
+++ b/apps/api/src/health/health.service.spec.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest';
+
+import { HealthService } from './health.service';
+
+describe('HealthService', () => {
+ it('returns a healthy API response with an ISO timestamp', () => {
+ const response = new HealthService().getHealth();
+
+ expect(response.status).toBe('ok');
+ expect(response.service).toBe('api');
+ expect(Number.isNaN(Date.parse(response.timestamp))).toBe(false);
+ });
+});
diff --git a/apps/api/src/health/health.service.ts b/apps/api/src/health/health.service.ts
new file mode 100644
index 0000000..1adc6e1
--- /dev/null
+++ b/apps/api/src/health/health.service.ts
@@ -0,0 +1,29 @@
+import { Inject, Injectable } from '@nestjs/common';
+
+import { HEALTH_STATUS, type HealthResponse } from '@lehuo/contracts';
+import { ServiceUnavailableException } from '@nestjs/common';
+import { PrismaService } from '../database/prisma.service';
+
+@Injectable()
+export class HealthService {
+ constructor(@Inject(PrismaService) private readonly prisma?: PrismaService) {}
+ getHealth(): HealthResponse {
+ return {
+ status: HEALTH_STATUS,
+ service: 'api',
+ timestamp: new Date().toISOString(),
+ };
+ }
+ async readiness() {
+ try {
+ if (this.prisma) await this.prisma.$queryRaw`SELECT 1`;
+ return {
+ status: 'ready' as const,
+ database: 'up' as const,
+ timestamp: new Date().toISOString(),
+ };
+ } catch {
+ throw new ServiceUnavailableException('Database readiness check failed');
+ }
+ }
+}
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
new file mode 100644
index 0000000..9084e3a
--- /dev/null
+++ b/apps/api/src/main.ts
@@ -0,0 +1,35 @@
+import 'reflect-metadata';
+
+import { ValidationPipe } from '@nestjs/common';
+import { NestFactory } from '@nestjs/core';
+import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
+import cookieParser from 'cookie-parser';
+
+import { AppModule } from './app.module';
+import { AppConfigService } from './config/app-config.service';
+
+async function bootstrap(): Promise {
+ const app = await NestFactory.create(AppModule);
+ const config = app.get(AppConfigService);
+
+ app.use(cookieParser());
+ app.enableCors({ origin: config.adminWebOrigin, credentials: true });
+ app.useGlobalPipes(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ }),
+ );
+ const openApi = new DocumentBuilder()
+ .setTitle('乐活公益暖心服务平台 API')
+ .setVersion('1.0')
+ .addBearerAuth()
+ .addCookieAuth('lehuo_admin_session')
+ .build();
+ SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, openApi));
+ app.enableShutdownHooks();
+ await app.listen(config.apiPort, '0.0.0.0');
+}
+
+void bootstrap();
diff --git a/apps/api/src/participation/activities.service.ts b/apps/api/src/participation/activities.service.ts
new file mode 100644
index 0000000..d691481
--- /dev/null
+++ b/apps/api/src/participation/activities.service.ts
@@ -0,0 +1,987 @@
+import { ForbiddenException, HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { randomBytes, randomUUID } from 'node:crypto';
+
+import {
+ ACTIVITY_STATUS_LABELS,
+ API_ERROR_CODES,
+ CHECK_IN_STATUS_LABELS,
+ PERMISSION_CODES,
+ REGISTRATION_STATUS_LABELS,
+ type ActivityDetail,
+ type ActivityDetailResponse,
+ type ActivityListItem,
+ type ActivityRegistrationItem,
+ type ActivityRegistrationResponse,
+ type PaginatedActivitiesResponse,
+ type PaginatedActivityRegistrationsResponse,
+} from '@lehuo/contracts';
+
+import { AuditService } from '../audit/audit.service';
+import { ApplicationError } from '../common/application-error';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { PrismaService } from '../database/prisma.service';
+import type { Prisma } from '../generated/prisma/client';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+import type {
+ ActivityQueryDto,
+ CheckInRegistrationDto,
+ CreateActivityDto,
+ PublicActivityQueryDto,
+ RecordServiceMinutesDto,
+ RegisterActivityDto,
+ RegistrationQueryDto,
+ ReviewRegistrationDto,
+ TransitionActivityDto,
+ UpdateActivityDto,
+} from './dto/participation.dto';
+import { MediaService } from './media.service';
+import { NotificationsService } from '../content-notifications/notifications.service';
+
+const activityInclude = {
+ coverFile: true,
+ project: true,
+ statusHistory: { orderBy: { createdAt: 'desc' as const } },
+ registrations: {
+ where: { status: { in: ['PENDING' as const, 'APPROVED' as const] } },
+ select: { id: true },
+ },
+} satisfies Prisma.ActivityInclude;
+
+const registrationInclude = {
+ activity: true,
+ volunteer: true,
+} satisfies Prisma.ActivityRegistrationInclude;
+
+type StoredActivity = Prisma.ActivityGetPayload<{ include: typeof activityInclude }>;
+type StoredRegistration = Prisma.ActivityRegistrationGetPayload<{
+ include: typeof registrationInclude;
+}>;
+
+function dateNoPrefix(date: Date): string {
+ const parts = new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(date);
+ const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
+ return `${value.year}${value.month}${value.day}`;
+}
+
+function maskMobile(value: string): string {
+ return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
+}
+
+@Injectable()
+export class ActivitiesService {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ @Inject(MediaService) private readonly media: MediaService,
+ @Inject(NotificationsService) private readonly notifications: NotificationsService,
+ ) {}
+
+ listAdmin(query: ActivityQueryDto, actor: AuthContext): Promise {
+ const scoped: Prisma.ActivityWhereInput = actor.permissions.includes(
+ PERMISSION_CODES.RESOURCES_ACCESS_ALL,
+ )
+ ? {}
+ : { OR: [{ createdById: actor.userId }, { project: { managerUserId: actor.userId } }] };
+ return this.list(
+ {
+ ...scoped,
+ ...(query.status ? { status: query.status as never } : {}),
+ ...(query.projectPublicId
+ ? {
+ project: {
+ publicId: query.projectPublicId,
+ ...(!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
+ ? { managerUserId: actor.userId }
+ : {}),
+ },
+ }
+ : {}),
+ },
+ query.page,
+ query.pageSize,
+ );
+ }
+
+ listPublic(query: PublicActivityQueryDto): Promise {
+ return this.list(
+ {
+ status: { in: ['PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED'] },
+ ...(query.projectPublicId
+ ? { project: { publicId: query.projectPublicId, isPublic: true } }
+ : {}),
+ OR: [
+ { projectId: null },
+ { project: { isPublic: true, status: { in: ['PUBLISHED', 'COMPLETED'] } } },
+ ],
+ },
+ query.page,
+ query.pageSize,
+ );
+ }
+
+ async getAdmin(publicId: string, actor: AuthContext): Promise {
+ const activity = await this.find(publicId);
+ this.assertAccess(activity, actor);
+ return { activity: await this.detail(activity), currentUserRegistration: null };
+ }
+
+ async getPublic(publicId: string): Promise {
+ const activity = await this.find(publicId);
+ if (activity.status === 'DRAFT' || (activity.project && !activity.project.isPublic))
+ throw this.notFound();
+ return { activity: await this.detail(activity), currentUserRegistration: null };
+ }
+
+ async create(
+ input: CreateActivityDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const values = await this.prepare(input, actor);
+ const fingerprint = this.fingerprint('activity-create', {
+ ...input,
+ title: input.title.trim(),
+ summary: input.summary.trim(),
+ content: input.content.trim(),
+ locationText: input.locationText.trim(),
+ });
+ const replay = await this.prisma.activity.findUnique({
+ where: {
+ createdById_clientRequestId: {
+ createdById: actor.userId,
+ clientRequestId: input.clientRequestId,
+ },
+ },
+ include: activityInclude,
+ });
+ if (replay) {
+ if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
+ return {
+ activity: await this.detail(replay),
+ currentUserRegistration: null,
+ idempotentReplay: true,
+ };
+ }
+ const activity = await this.prisma.$transaction(async (transaction) => {
+ const created = await transaction.activity.create({
+ data: {
+ publicId: randomUUID(),
+ activityNo: `HD${dateNoPrefix(new Date())}${randomBytes(4).toString('hex').toUpperCase()}`,
+ clientRequestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ ...values,
+ createdById: actor.userId,
+ updatedById: actor.userId,
+ },
+ });
+ await transaction.activityStatusHistory.create({
+ data: {
+ activityId: created.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ toStatus: 'DRAFT',
+ operatorUserId: actor.userId,
+ reasonCode: 'CREATED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'ACTIVITY_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'ACTIVITY',
+ resourcePublicId: created.publicId,
+ metadata: { status: 'DRAFT' },
+ },
+ transaction,
+ );
+ return transaction.activity.findUniqueOrThrow({
+ where: { id: created.id },
+ include: activityInclude,
+ });
+ });
+ return {
+ activity: await this.detail(activity),
+ currentUserRegistration: null,
+ idempotentReplay: false,
+ };
+ }
+
+ async update(
+ publicId: string,
+ input: UpdateActivityDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const activity = await this.find(publicId);
+ this.assertAccess(activity, actor);
+ if (activity.status !== 'DRAFT') throw this.invalidTransition('只有草稿活动可以编辑');
+ if (activity.revision !== input.expectedRevision) throw this.conflict();
+ const values = await this.prepare(input, actor);
+ const updated = await this.prisma
+ .$transaction(async (transaction) => {
+ const changed = await transaction.activity.updateMany({
+ where: { id: activity.id, status: 'DRAFT', revision: input.expectedRevision },
+ data: { ...values, updatedById: actor.userId, revision: { increment: 1 } },
+ });
+ if (changed.count !== 1) throw new Error('ACTIVITY_CONFLICT');
+ await this.audit.record(
+ request,
+ {
+ action: 'ACTIVITY_UPDATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'ACTIVITY',
+ resourcePublicId: publicId,
+ },
+ transaction,
+ );
+ return transaction.activity.findUniqueOrThrow({
+ where: { id: activity.id },
+ include: activityInclude,
+ });
+ })
+ .catch((error: unknown) => {
+ if (error instanceof Error && error.message === 'ACTIVITY_CONFLICT') throw this.conflict();
+ throw error;
+ });
+ return { activity: await this.detail(updated), currentUserRegistration: null };
+ }
+
+ async transition(
+ publicId: string,
+ input: TransitionActivityDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const activity = await this.find(publicId);
+ this.assertAccess(activity, actor);
+ const fingerprint = this.fingerprint('activity-transition', {
+ publicId,
+ targetStatus: input.targetStatus,
+ });
+ const replay = await this.prisma.activityStatusHistory.findUnique({
+ where: { requestId: input.clientRequestId },
+ include: { activity: { include: activityInclude } },
+ });
+ if (replay) {
+ if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
+ return {
+ activity: await this.detail(replay.activity),
+ currentUserRegistration: null,
+ idempotentReplay: true,
+ };
+ }
+ const allowed: Record = {
+ DRAFT: ['PUBLISHED', 'CANCELLED'],
+ PUBLISHED: ['REGISTRATION_CLOSED', 'CANCELLED'],
+ REGISTRATION_CLOSED: ['IN_PROGRESS', 'CANCELLED'],
+ IN_PROGRESS: ['COMPLETED'],
+ };
+ if (!allowed[activity.status]?.includes(input.targetStatus)) throw this.invalidTransition();
+ if (input.targetStatus === 'PUBLISHED' && activity.registrationDeadline <= new Date()) {
+ throw this.validation('报名截止时间必须晚于当前时间');
+ }
+ const updated = await this.prisma
+ .$transaction(async (transaction) => {
+ const changed = await transaction.activity.updateMany({
+ where: { id: activity.id, status: activity.status },
+ data: {
+ status: input.targetStatus as never,
+ publishedAt: input.targetStatus === 'PUBLISHED' ? new Date() : activity.publishedAt,
+ updatedById: actor.userId,
+ revision: { increment: 1 },
+ },
+ });
+ if (changed.count !== 1) throw new Error('ACTIVITY_STATE_CONFLICT');
+ await transaction.activityStatusHistory.create({
+ data: {
+ activityId: activity.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ fromStatus: activity.status,
+ toStatus: input.targetStatus as never,
+ operatorUserId: actor.userId,
+ reasonCode: input.targetStatus,
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'ACTIVITY_STATUS_CHANGED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'ACTIVITY',
+ resourcePublicId: publicId,
+ metadata: { fromStatus: activity.status, toStatus: input.targetStatus },
+ },
+ transaction,
+ );
+ return transaction.activity.findUniqueOrThrow({
+ where: { id: activity.id },
+ include: activityInclude,
+ });
+ })
+ .catch((error: unknown) => {
+ if (error instanceof Error && error.message === 'ACTIVITY_STATE_CONFLICT')
+ throw this.invalidTransition();
+ throw error;
+ });
+ return {
+ activity: await this.detail(updated),
+ currentUserRegistration: null,
+ idempotentReplay: false,
+ };
+ }
+
+ async register(
+ activityPublicId: string,
+ input: RegisterActivityDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const fingerprint = this.fingerprint('activity-registration', { activityPublicId });
+ const replay = await this.prisma.activityRegistration.findUnique({
+ where: {
+ userId_clientRequestId: { userId: actor.userId, clientRequestId: input.clientRequestId },
+ },
+ include: registrationInclude,
+ });
+ if (replay) {
+ if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
+ return { registration: this.registration(replay), idempotentReplay: true };
+ }
+ const volunteer = await this.prisma.volunteer.findUnique({ where: { userId: actor.userId } });
+ if (!volunteer || volunteer.status !== 'APPROVED') {
+ throw new ApplicationError(
+ API_ERROR_CODES.VOLUNTEER_NOT_APPROVED,
+ '仅已认证志愿者可以报名',
+ HttpStatus.FORBIDDEN,
+ );
+ }
+ try {
+ const created = await this.prisma.$transaction(async (transaction) => {
+ const rows = await transaction.$queryRaw<
+ Array<{ id: bigint }>
+ >`SELECT id FROM activities WHERE public_id = ${activityPublicId} FOR UPDATE`;
+ if (!rows.length) throw new Error('ACTIVITY_NOT_FOUND');
+ const activity = await transaction.activity.findUniqueOrThrow({
+ where: { id: rows[0]!.id },
+ });
+ if (activity.status !== 'PUBLISHED' || activity.registrationDeadline <= new Date())
+ throw new Error('REGISTRATION_CLOSED');
+ const active = await transaction.activityRegistration.count({
+ where: { activityId: activity.id, status: { in: ['PENDING', 'APPROVED'] } },
+ });
+ if (activity.capacity !== null && active >= activity.capacity)
+ throw new Error('ACTIVITY_FULL');
+ const registration = await transaction.activityRegistration.create({
+ data: {
+ publicId: randomUUID(),
+ activityId: activity.id,
+ userId: actor.userId,
+ volunteerId: volunteer.id,
+ clientRequestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ },
+ });
+ await transaction.activityRegistrationHistory.create({
+ data: {
+ registrationId: registration.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ toStatus: 'PENDING',
+ operatorUserId: actor.userId,
+ actionCode: 'REGISTERED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'ACTIVITY_REGISTRATION_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'ACTIVITY_REGISTRATION',
+ resourcePublicId: registration.publicId,
+ metadata: { activityPublicId },
+ },
+ transaction,
+ );
+ return transaction.activityRegistration.findUniqueOrThrow({
+ where: { id: registration.id },
+ include: registrationInclude,
+ });
+ });
+ return { registration: this.registration(created), idempotentReplay: false };
+ } catch (error) {
+ if (error instanceof Error && error.message === 'ACTIVITY_NOT_FOUND') throw this.notFound();
+ if (error instanceof Error && error.message === 'REGISTRATION_CLOSED')
+ throw this.registrationClosed();
+ if (error instanceof Error && error.message === 'ACTIVITY_FULL') throw this.full();
+ if (this.uniqueError(error)) {
+ const existing = await this.prisma.activityRegistration.findFirst({
+ where: { activity: { publicId: activityPublicId }, userId: actor.userId },
+ include: registrationInclude,
+ });
+ if (
+ existing?.clientRequestId === input.clientRequestId &&
+ existing.requestFingerprint === fingerprint
+ ) {
+ return { registration: this.registration(existing), idempotentReplay: true };
+ }
+ throw this.duplicate('您已报名该活动');
+ }
+ throw error;
+ }
+ }
+
+ async myRegistrations(
+ query: RegistrationQueryDto,
+ actor: AuthContext,
+ ): Promise {
+ return this.listRegistrations(
+ {
+ userId: actor.userId,
+ ...(query.status ? { status: query.status as never } : {}),
+ ...(query.activityPublicId ? { activity: { publicId: query.activityPublicId } } : {}),
+ },
+ query.page,
+ query.pageSize,
+ );
+ }
+
+ async adminRegistrations(
+ query: RegistrationQueryDto,
+ ): Promise {
+ return this.listRegistrations(
+ {
+ ...(query.status ? { status: query.status as never } : {}),
+ ...(query.activityPublicId ? { activity: { publicId: query.activityPublicId } } : {}),
+ },
+ query.page,
+ query.pageSize,
+ );
+ }
+
+ async cancel(
+ publicId: string,
+ input: RegisterActivityDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const registration = await this.findRegistration(publicId);
+ if (registration.userId !== actor.userId) throw new ForbiddenException();
+ const replay = await this.replayRegistration(
+ input.clientRequestId,
+ this.fingerprint('registration-status', {
+ publicId,
+ targetStatus: 'CANCELLED',
+ note: '',
+ }),
+ );
+ if (replay) return { registration: this.registration(replay), idempotentReplay: true };
+ if (
+ !['PENDING', 'APPROVED'].includes(registration.status) ||
+ registration.activity.startAt <= new Date()
+ )
+ throw this.invalidTransition();
+ return this.changeRegistrationStatus(
+ registration,
+ input.clientRequestId,
+ 'CANCELLED',
+ '',
+ actor,
+ request,
+ 'REGISTRATION_CANCELLED',
+ );
+ }
+
+ async reviewRegistration(
+ publicId: string,
+ input: ReviewRegistrationDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const registration = await this.findRegistration(publicId);
+ const replay = await this.replayRegistration(
+ input.clientRequestId,
+ this.fingerprint('registration-status', {
+ publicId,
+ targetStatus: input.targetStatus,
+ note: input.note.trim(),
+ }),
+ );
+ if (replay) return { registration: this.registration(replay), idempotentReplay: true };
+ if (registration.status !== 'PENDING') throw this.invalidTransition();
+ return this.changeRegistrationStatus(
+ registration,
+ input.clientRequestId,
+ input.targetStatus,
+ input.note,
+ actor,
+ request,
+ 'REGISTRATION_REVIEWED',
+ );
+ }
+
+ async checkIn(
+ publicId: string,
+ input: CheckInRegistrationDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const registration = await this.findRegistration(publicId);
+ const fingerprint = this.fingerprint('registration-check-in', {
+ publicId,
+ targetStatus: input.targetStatus,
+ });
+ const replay = await this.replayRegistration(input.clientRequestId, fingerprint);
+ if (replay) return { registration: this.registration(replay), idempotentReplay: true };
+ if (
+ registration.status !== 'APPROVED' ||
+ registration.checkInStatus !== 'NOT_CHECKED_IN' ||
+ registration.activity.status !== 'IN_PROGRESS'
+ ) {
+ throw this.invalidTransition();
+ }
+ const updated = await this.prisma
+ .$transaction(async (transaction) => {
+ const changed = await transaction.activityRegistration.updateMany({
+ where: { id: registration.id, status: 'APPROVED', checkInStatus: 'NOT_CHECKED_IN' },
+ data: {
+ checkInStatus: input.targetStatus,
+ checkedInAt: input.targetStatus === 'CHECKED_IN' ? new Date() : null,
+ checkedInById: actor.userId,
+ },
+ });
+ if (changed.count !== 1) throw new Error('REGISTRATION_STATE_CONFLICT');
+ await transaction.activityRegistrationHistory.create({
+ data: {
+ registrationId: registration.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ fromCheckInStatus: 'NOT_CHECKED_IN',
+ toCheckInStatus: input.targetStatus,
+ operatorUserId: actor.userId,
+ actionCode: 'CHECK_IN_RECORDED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'ACTIVITY_CHECK_IN_RECORDED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'ACTIVITY_REGISTRATION',
+ resourcePublicId: publicId,
+ metadata: { checkInStatus: input.targetStatus },
+ },
+ transaction,
+ );
+ return transaction.activityRegistration.findUniqueOrThrow({
+ where: { id: registration.id },
+ include: registrationInclude,
+ });
+ })
+ .catch((error: unknown) => {
+ if (error instanceof Error && error.message === 'REGISTRATION_STATE_CONFLICT')
+ throw this.invalidTransition();
+ throw error;
+ });
+ return { registration: this.registration(updated), idempotentReplay: false };
+ }
+
+ async recordMinutes(
+ publicId: string,
+ input: RecordServiceMinutesDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const registration = await this.findRegistration(publicId);
+ if (
+ registration.status !== 'APPROVED' ||
+ registration.checkInStatus !== 'CHECKED_IN' ||
+ registration.activity.status !== 'COMPLETED'
+ ) {
+ throw this.invalidTransition();
+ }
+ const durationMinutes = Math.ceil(
+ (registration.activity.endAt.getTime() - registration.activity.startAt.getTime()) / 60_000,
+ );
+ if (input.serviceMinutes > durationMinutes)
+ throw this.validation('服务时长不能超过活动持续时间');
+ const fingerprint = this.fingerprint('registration-service-minutes', {
+ publicId,
+ serviceMinutes: input.serviceMinutes,
+ });
+ const replay = await this.replayRegistration(input.clientRequestId, fingerprint);
+ if (replay) return { registration: this.registration(replay), idempotentReplay: true };
+ const updated = await this.prisma.$transaction(async (transaction) => {
+ const current = await transaction.activityRegistration.findUniqueOrThrow({
+ where: { id: registration.id },
+ });
+ await transaction.activityRegistration.update({
+ where: { id: registration.id },
+ data: {
+ serviceMinutes: input.serviceMinutes,
+ serviceMinutesRecordedAt: new Date(),
+ serviceMinutesRecordedById: actor.userId,
+ },
+ });
+ await transaction.activityRegistrationHistory.create({
+ data: {
+ registrationId: registration.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ serviceMinutesBefore: current.serviceMinutes,
+ serviceMinutesAfter: input.serviceMinutes,
+ operatorUserId: actor.userId,
+ actionCode: 'SERVICE_MINUTES_RECORDED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'VOLUNTEER_SERVICE_MINUTES_RECORDED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'ACTIVITY_REGISTRATION',
+ resourcePublicId: publicId,
+ metadata: {
+ serviceMinutesBefore: current.serviceMinutes,
+ serviceMinutesAfter: input.serviceMinutes,
+ },
+ },
+ transaction,
+ );
+ return transaction.activityRegistration.findUniqueOrThrow({
+ where: { id: registration.id },
+ include: registrationInclude,
+ });
+ });
+ return { registration: this.registration(updated), idempotentReplay: false };
+ }
+
+ private async changeRegistrationStatus(
+ registration: StoredRegistration,
+ requestId: string,
+ targetStatus: 'APPROVED' | 'REJECTED' | 'CANCELLED',
+ note: string,
+ actor: AuthContext,
+ request: RequestContext,
+ action: string,
+ ): Promise {
+ const fingerprint = this.fingerprint('registration-status', {
+ publicId: registration.publicId,
+ targetStatus,
+ note: note.trim(),
+ });
+ const replay = await this.replayRegistration(requestId, fingerprint);
+ if (replay) return { registration: this.registration(replay), idempotentReplay: true };
+ const updated = await this.prisma
+ .$transaction(async (transaction) => {
+ const changed = await transaction.activityRegistration.updateMany({
+ where: { id: registration.id, status: registration.status },
+ data: {
+ status: targetStatus,
+ reviewedById: targetStatus === 'CANCELLED' ? null : actor.userId,
+ reviewedAt: targetStatus === 'CANCELLED' ? null : new Date(),
+ reviewNoteEncrypted: note ? this.sensitive.encrypt(note.trim()) : null,
+ },
+ });
+ if (changed.count !== 1) throw new Error('REGISTRATION_STATE_CONFLICT');
+ await transaction.activityRegistrationHistory.create({
+ data: {
+ registrationId: registration.id,
+ requestId,
+ requestFingerprint: fingerprint,
+ fromStatus: registration.status,
+ toStatus: targetStatus,
+ operatorUserId: actor.userId,
+ actionCode: action,
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action,
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'ACTIVITY_REGISTRATION',
+ resourcePublicId: registration.publicId,
+ metadata: { fromStatus: registration.status, toStatus: targetStatus },
+ },
+ transaction,
+ );
+ if (targetStatus !== 'CANCELLED')
+ await this.notifications.create(transaction, {
+ userId: registration.userId,
+ eventKey: `registration:${registration.publicId}:${targetStatus}`,
+ type: 'ACTIVITY_REGISTRATION_RESULT',
+ title: targetStatus === 'APPROVED' ? '活动报名已通过' : '活动报名未通过',
+ body:
+ targetStatus === 'APPROVED'
+ ? '您的活动报名已通过,请留意活动时间和地点。'
+ : '很抱歉,本次活动报名未通过,您可以继续关注其他活动。',
+ resourceType: 'ACTIVITY_REGISTRATION',
+ resourcePublicId: registration.publicId,
+ });
+ return transaction.activityRegistration.findUniqueOrThrow({
+ where: { id: registration.id },
+ include: registrationInclude,
+ });
+ })
+ .catch((error: unknown) => {
+ if (error instanceof Error && error.message === 'REGISTRATION_STATE_CONFLICT')
+ throw this.invalidTransition();
+ throw error;
+ });
+ return { registration: this.registration(updated), idempotentReplay: false };
+ }
+
+ private async prepare(input: CreateActivityDto | UpdateActivityDto, actor: AuthContext) {
+ const startAt = new Date(input.startAt),
+ endAt = new Date(input.endAt),
+ registrationDeadline = new Date(input.registrationDeadline);
+ if (endAt <= startAt) throw this.validation('活动结束时间必须晚于开始时间');
+ if (registrationDeadline >= startAt) throw this.validation('报名截止时间必须早于活动开始时间');
+ let projectId: bigint | undefined;
+ if (input.projectPublicId) {
+ const project = await this.prisma.project.findUnique({
+ where: { publicId: input.projectPublicId },
+ });
+ if (!project || project.status === 'COMPLETED') throw this.validation('关联项目无效或已完成');
+ if (
+ project.managerUserId !== actor.userId &&
+ !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
+ )
+ throw new ForbiddenException();
+ projectId = project.id;
+ }
+ const cover = await this.media.requireCover(input.coverFilePublicId, 'ACTIVITY_COVER', actor);
+ return {
+ projectId,
+ title: input.title.trim(),
+ summary: input.summary.trim(),
+ content: input.content.trim(),
+ coverFileId: cover?.id,
+ startAt,
+ endAt,
+ registrationDeadline,
+ locationText: input.locationText.trim(),
+ capacity: input.capacity,
+ };
+ }
+
+ private async list(
+ where: Prisma.ActivityWhereInput,
+ page: number,
+ pageSize: number,
+ ): Promise {
+ const [total, activities] = await this.prisma.$transaction([
+ this.prisma.activity.count({ where }),
+ this.prisma.activity.findMany({
+ where,
+ include: activityInclude,
+ orderBy: { startAt: 'desc' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ ]);
+ return {
+ items: await Promise.all(activities.map((activity) => this.item(activity))),
+ page,
+ pageSize,
+ total,
+ };
+ }
+
+ private async listRegistrations(
+ where: Prisma.ActivityRegistrationWhereInput,
+ page: number,
+ pageSize: number,
+ ): Promise {
+ const [total, registrations] = await this.prisma.$transaction([
+ this.prisma.activityRegistration.count({ where }),
+ this.prisma.activityRegistration.findMany({
+ where,
+ include: registrationInclude,
+ orderBy: { registeredAt: 'desc' },
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ ]);
+ return { items: registrations.map((item) => this.registration(item)), page, pageSize, total };
+ }
+
+ private async find(publicId: string): Promise {
+ const activity = await this.prisma.activity.findUnique({
+ where: { publicId },
+ include: activityInclude,
+ });
+ if (!activity) throw this.notFound();
+ return activity;
+ }
+
+ private async findRegistration(publicId: string): Promise {
+ const registration = await this.prisma.activityRegistration.findUnique({
+ where: { publicId },
+ include: registrationInclude,
+ });
+ if (!registration)
+ throw new ApplicationError(
+ API_ERROR_CODES.REGISTRATION_NOT_FOUND,
+ '未找到该报名记录',
+ HttpStatus.NOT_FOUND,
+ );
+ return registration;
+ }
+
+ private replayRegistration(
+ requestId: string,
+ fingerprint: string,
+ ): Promise {
+ return this.prisma.activityRegistrationHistory
+ .findUnique({
+ where: { requestId },
+ include: { registration: { include: registrationInclude } },
+ })
+ .then((history) => {
+ if (!history) return null;
+ if (history.requestFingerprint !== fingerprint) throw this.duplicate();
+ return history.registration;
+ });
+ }
+
+ private assertAccess(activity: StoredActivity, actor: AuthContext): void {
+ if (
+ activity.createdById !== actor.userId &&
+ activity.project?.managerUserId !== actor.userId &&
+ !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
+ ) {
+ throw new ForbiddenException();
+ }
+ }
+
+ private async item(activity: StoredActivity): Promise {
+ const active = activity.registrations.length;
+ return {
+ publicId: activity.publicId,
+ activityNo: activity.activityNo,
+ projectPublicId: activity.project?.publicId ?? null,
+ projectName: activity.project?.name ?? null,
+ title: activity.title,
+ summary: activity.summary,
+ coverImageUrl: await this.media.publicUrl(activity.coverFile),
+ startAt: activity.startAt.toISOString(),
+ endAt: activity.endAt.toISOString(),
+ registrationDeadline: activity.registrationDeadline.toISOString(),
+ locationText: activity.locationText,
+ capacity: activity.capacity,
+ activeRegistrationCount: active,
+ remainingCapacity:
+ activity.capacity === null ? null : Math.max(0, activity.capacity - active),
+ status: activity.status,
+ statusLabel: ACTIVITY_STATUS_LABELS[activity.status],
+ canRegister:
+ activity.status === 'PUBLISHED' &&
+ activity.registrationDeadline > new Date() &&
+ (activity.capacity === null || active < activity.capacity),
+ revision: activity.revision,
+ createdAt: activity.createdAt.toISOString(),
+ };
+ }
+
+ private async detail(activity: StoredActivity): Promise {
+ return {
+ ...(await this.item(activity)),
+ content: activity.content,
+ coverFilePublicId: activity.coverFile?.publicId ?? null,
+ history: activity.statusHistory.map((history) => ({
+ status: history.toStatus,
+ statusLabel: ACTIVITY_STATUS_LABELS[history.toStatus],
+ reasonLabel:
+ (
+ {
+ CREATED: '创建活动',
+ PUBLISHED: '发布活动',
+ REGISTRATION_CLOSED: '关闭报名',
+ IN_PROGRESS: '开始活动',
+ COMPLETED: '完成活动',
+ CANCELLED: '取消活动',
+ } as Record
+ )[history.reasonCode] ?? '状态更新',
+ createdAt: history.createdAt.toISOString(),
+ })),
+ };
+ }
+
+ private registration(item: StoredRegistration): ActivityRegistrationItem {
+ const mobile = this.sensitive.decrypt(item.volunteer.mobileEncrypted);
+ return {
+ publicId: item.publicId,
+ activityPublicId: item.activity.publicId,
+ activityTitle: item.activity.title,
+ volunteerPublicId: item.volunteer.publicId,
+ volunteerName: this.sensitive.decrypt(item.volunteer.realNameEncrypted),
+ mobileMasked: maskMobile(mobile),
+ status: item.status,
+ statusLabel: REGISTRATION_STATUS_LABELS[item.status],
+ checkInStatus: item.checkInStatus,
+ checkInStatusLabel: CHECK_IN_STATUS_LABELS[item.checkInStatus],
+ serviceMinutes: item.serviceMinutes,
+ registeredAt: item.registeredAt.toISOString(),
+ };
+ }
+
+ private fingerprint(domain: string, value: object): string {
+ return this.sensitive.lookupHash(domain, JSON.stringify(value));
+ }
+ private uniqueError(error: unknown): error is { code: 'P2002' } {
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
+ }
+ private validation(message: string): ApplicationError {
+ return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
+ }
+ private notFound(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.ACTIVITY_NOT_FOUND,
+ '未找到该公益活动',
+ HttpStatus.NOT_FOUND,
+ );
+ }
+ private conflict(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.RESOURCE_CONFLICT,
+ '活动已更新,请刷新后重试',
+ HttpStatus.CONFLICT,
+ );
+ }
+ private invalidTransition(message = '当前状态不能执行该操作'): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.INVALID_STATE_TRANSITION,
+ message,
+ HttpStatus.CONFLICT,
+ );
+ }
+ private duplicate(message = '请求标识已用于其他操作'): ApplicationError {
+ return new ApplicationError(API_ERROR_CODES.DUPLICATE_REQUEST, message, HttpStatus.CONFLICT);
+ }
+ private registrationClosed(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.REGISTRATION_CLOSED,
+ '活动报名已截止',
+ HttpStatus.CONFLICT,
+ );
+ }
+ private full(): ApplicationError {
+ return new ApplicationError(API_ERROR_CODES.ACTIVITY_FULL, '活动名额已满', HttpStatus.CONFLICT);
+ }
+}
diff --git a/apps/api/src/participation/admin-participation.controller.ts b/apps/api/src/participation/admin-participation.controller.ts
new file mode 100644
index 0000000..31ae0d2
--- /dev/null
+++ b/apps/api/src/participation/admin-participation.controller.ts
@@ -0,0 +1,294 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Inject,
+ Param,
+ Patch,
+ Post,
+ Put,
+ Query,
+ Req,
+ ValidationPipe,
+} from '@nestjs/common';
+
+import {
+ PERMISSION_CODES,
+ type ActivityDetailResponse,
+ type ActivityRegistrationResponse,
+ type AssigneeOption,
+ type AdminVolunteerDetailResponse,
+ type ConfirmUploadResponse,
+ type CreateUploadIntentResponse,
+ type PaginatedActivitiesResponse,
+ type PaginatedActivityRegistrationsResponse,
+ type PaginatedProjectsResponse,
+ type PaginatedVolunteersResponse,
+ type ProjectDetailResponse,
+} from '@lehuo/contracts';
+
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SessionChannel } from '../generated/prisma/enums';
+import { ActivitiesService } from './activities.service';
+import {
+ ActivityQueryDto,
+ CheckInRegistrationDto,
+ CreateActivityDto,
+ CreateMediaUploadIntentDto,
+ CreateProjectDto,
+ ProjectQueryDto,
+ RecordServiceMinutesDto,
+ RegistrationQueryDto,
+ ReplaceProjectCasesDto,
+ RestoreVolunteerDto,
+ ReviewRegistrationDto,
+ ReviewVolunteerDto,
+ TransitionActivityDto,
+ TransitionProjectDto,
+ UpdateActivityDto,
+ UpdateProjectDto,
+ VolunteerQueryDto,
+} from './dto/participation.dto';
+import { MediaService } from './media.service';
+import { ProjectsService } from './projects.service';
+import { VolunteersService } from './volunteers.service';
+
+const strict = (expectedType: new () => object) =>
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType,
+ });
+
+@Controller('api/v1/admin')
+@RequireSessionChannel(SessionChannel.ADMIN)
+export class AdminParticipationController {
+ constructor(
+ @Inject(VolunteersService) private readonly volunteers: VolunteersService,
+ @Inject(ProjectsService) private readonly projects: ProjectsService,
+ @Inject(ActivitiesService) private readonly activities: ActivitiesService,
+ @Inject(MediaService) private readonly media: MediaService,
+ ) {}
+
+ @Get('volunteers')
+ @RequirePermissions(PERMISSION_CODES.VOLUNTEERS_READ)
+ volunteersList(
+ @Query(strict(VolunteerQueryDto)) query: VolunteerQueryDto,
+ ): Promise {
+ return this.volunteers.list(query);
+ }
+
+ @Get('volunteers/:publicId')
+ @RequirePermissions(PERMISSION_CODES.VOLUNTEERS_READ)
+ volunteer(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.volunteers.get(publicId, actor, request);
+ }
+
+ @Post('volunteers/:publicId/review')
+ @RequirePermissions(PERMISSION_CODES.VOLUNTEERS_REVIEW)
+ reviewVolunteer(
+ @Param('publicId') publicId: string,
+ @Body(strict(ReviewVolunteerDto)) input: ReviewVolunteerDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.volunteers.review(publicId, input, actor, request);
+ }
+
+ @Post('volunteers/:publicId/restore')
+ @RequirePermissions(PERMISSION_CODES.VOLUNTEERS_REVIEW)
+ restoreVolunteer(
+ @Param('publicId') publicId: string,
+ @Body(strict(RestoreVolunteerDto)) input: RestoreVolunteerDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.volunteers.review(publicId, input, actor, request);
+ }
+
+ @Get('projects')
+ @RequirePermissions(PERMISSION_CODES.PROJECTS_READ)
+ projectsList(
+ @Query(strict(ProjectQueryDto)) query: ProjectQueryDto,
+ @CurrentAuth() actor: AuthContext,
+ ): Promise {
+ return this.projects.listAdmin(query, actor);
+ }
+
+ @Get('project-manager-options')
+ @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
+ projectManagerOptions(): Promise {
+ return this.projects.managerOptions();
+ }
+
+ @Post('projects')
+ @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
+ createProject(
+ @Body(strict(CreateProjectDto)) input: CreateProjectDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.projects.create(input, actor, request);
+ }
+
+ @Get('projects/:publicId')
+ @RequirePermissions(PERMISSION_CODES.PROJECTS_READ)
+ project(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ ): Promise {
+ return this.projects.getAdmin(publicId, actor);
+ }
+
+ @Patch('projects/:publicId')
+ @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
+ updateProject(
+ @Param('publicId') publicId: string,
+ @Body(strict(UpdateProjectDto)) input: UpdateProjectDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.projects.update(publicId, input, actor, request);
+ }
+
+ @Post('projects/:publicId/transition')
+ @RequirePermissions(PERMISSION_CODES.PROJECTS_PUBLISH)
+ transitionProject(
+ @Param('publicId') publicId: string,
+ @Body(strict(TransitionProjectDto)) input: TransitionProjectDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.projects.transition(publicId, input, actor, request);
+ }
+
+ @Put('projects/:publicId/service-cases')
+ @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
+ replaceProjectCases(
+ @Param('publicId') publicId: string,
+ @Body(strict(ReplaceProjectCasesDto)) input: ReplaceProjectCasesDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.projects.replaceCases(publicId, input, actor, request);
+ }
+
+ @Get('activities')
+ @RequirePermissions(PERMISSION_CODES.ACTIVITIES_READ)
+ activitiesList(
+ @Query(strict(ActivityQueryDto)) query: ActivityQueryDto,
+ @CurrentAuth() actor: AuthContext,
+ ): Promise {
+ return this.activities.listAdmin(query, actor);
+ }
+
+ @Post('activities')
+ @RequirePermissions(PERMISSION_CODES.ACTIVITIES_MANAGE)
+ createActivity(
+ @Body(strict(CreateActivityDto)) input: CreateActivityDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.activities.create(input, actor, request);
+ }
+
+ @Get('activities/:publicId')
+ @RequirePermissions(PERMISSION_CODES.ACTIVITIES_READ)
+ activity(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ ): Promise {
+ return this.activities.getAdmin(publicId, actor);
+ }
+
+ @Patch('activities/:publicId')
+ @RequirePermissions(PERMISSION_CODES.ACTIVITIES_MANAGE)
+ updateActivity(
+ @Param('publicId') publicId: string,
+ @Body(strict(UpdateActivityDto)) input: UpdateActivityDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.activities.update(publicId, input, actor, request);
+ }
+
+ @Post('activities/:publicId/transition')
+ @RequirePermissions(PERMISSION_CODES.ACTIVITIES_PUBLISH)
+ transitionActivity(
+ @Param('publicId') publicId: string,
+ @Body(strict(TransitionActivityDto)) input: TransitionActivityDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.activities.transition(publicId, input, actor, request);
+ }
+
+ @Get('activity-registrations')
+ @RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
+ registrationList(
+ @Query(strict(RegistrationQueryDto)) query: RegistrationQueryDto,
+ ): Promise {
+ return this.activities.adminRegistrations(query);
+ }
+
+ @Post('activity-registrations/:publicId/review')
+ @RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
+ reviewRegistration(
+ @Param('publicId') publicId: string,
+ @Body(strict(ReviewRegistrationDto)) input: ReviewRegistrationDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.activities.reviewRegistration(publicId, input, actor, request);
+ }
+
+ @Post('activity-registrations/:publicId/check-in')
+ @RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
+ checkIn(
+ @Param('publicId') publicId: string,
+ @Body(strict(CheckInRegistrationDto)) input: CheckInRegistrationDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.activities.checkIn(publicId, input, actor, request);
+ }
+
+ @Post('activity-registrations/:publicId/service-minutes')
+ @RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
+ serviceMinutes(
+ @Param('publicId') publicId: string,
+ @Body(strict(RecordServiceMinutesDto)) input: RecordServiceMinutesDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.activities.recordMinutes(publicId, input, actor, request);
+ }
+
+ @Post('media/upload-intents')
+ @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
+ mediaIntent(
+ @Body(strict(CreateMediaUploadIntentDto)) input: CreateMediaUploadIntentDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.media.createIntent(input, actor, request);
+ }
+
+ @Post('media/:publicId/confirm')
+ @RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
+ confirmMedia(
+ @Param('publicId') publicId: string,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.media.confirm(publicId, actor, request);
+ }
+}
diff --git a/apps/api/src/participation/dto/participation.dto.ts b/apps/api/src/participation/dto/participation.dto.ts
new file mode 100644
index 0000000..1608c0a
--- /dev/null
+++ b/apps/api/src/participation/dto/participation.dto.ts
@@ -0,0 +1,169 @@
+import { Type } from 'class-transformer';
+import { OmitType } from '@nestjs/swagger';
+import {
+ ArrayMaxSize,
+ IsArray,
+ IsBoolean,
+ IsIn,
+ IsInt,
+ IsISO8601,
+ IsOptional,
+ IsString,
+ IsUUID,
+ Length,
+ Matches,
+ Max,
+ MaxLength,
+ Min,
+} from 'class-validator';
+
+import {
+ ACTIVITY_STATUSES,
+ CHECK_IN_STATUSES,
+ PROJECT_STATUSES,
+ REGISTRATION_STATUSES,
+ SERVICE_CATEGORIES,
+ VOLUNTEER_SKILL_CODES,
+ VOLUNTEER_STATUSES,
+} from '@lehuo/contracts';
+
+import { PaginationDto } from '../../case-management/dto/case-management.dto';
+
+const MOBILE_PATTERN = /^1[3-9]\d{9}$/;
+const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+
+export class ApplyVolunteerDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsOptional() @Type(() => Number) @IsInt() @Min(1) expectedRevision?: number;
+ @IsString() @Length(2, 50) realName!: string;
+ @Matches(MOBILE_PATTERN) mobile!: string;
+ @IsString() @Length(2, 100) regionCode!: string;
+ @IsString() @Length(2, 500) availabilityNote!: string;
+ @IsArray() @ArrayMaxSize(12) @IsIn(VOLUNTEER_SKILL_CODES, { each: true }) skillCodes!: string[];
+ @IsArray()
+ @ArrayMaxSize(12)
+ @IsIn(SERVICE_CATEGORIES, { each: true })
+ interestCategories!: string[];
+ @IsIn([true]) privacyConsent!: true;
+}
+
+export class VolunteerQueryDto extends PaginationDto {
+ @IsOptional() @IsIn(VOLUNTEER_STATUSES) status?: string;
+ @IsOptional() @IsString() @MaxLength(100) regionCode?: string;
+}
+
+export class ReviewVolunteerDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsIn(['APPROVED', 'REJECTED', 'SUSPENDED']) targetStatus!: 'APPROVED' | 'REJECTED' | 'SUSPENDED';
+ @IsString() @Length(2, 500) note!: string;
+}
+
+export class RestoreVolunteerDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsIn(['APPROVED']) targetStatus!: 'APPROVED';
+ @IsString() @Length(2, 500) note!: string;
+}
+
+export class ProjectQueryDto extends PaginationDto {
+ @IsOptional() @IsIn(PROJECT_STATUSES) status?: string;
+ @IsOptional() @IsIn(SERVICE_CATEGORIES) category?: string;
+}
+
+export class PublicProjectQueryDto extends PaginationDto {
+ @IsOptional() @IsIn(SERVICE_CATEGORIES) category?: string;
+}
+
+export class CreateProjectDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsString() @Length(2, 200) name!: string;
+ @IsIn(SERVICE_CATEGORIES) category!: string;
+ @IsString() @Length(10, 500) summary!: string;
+ @IsString() @Length(20, 20000) content!: string;
+ @IsOptional() @IsUUID('4') coverFilePublicId?: string;
+ @IsUUID('4') managerUserPublicId!: string;
+ @Matches(DATE_PATTERN) startDate!: string;
+ @Matches(DATE_PATTERN) endDate!: string;
+ @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1_000_000) targetPeople?: number;
+ @IsBoolean() isPublic!: boolean;
+ @Type(() => Number) @IsInt() @Min(-10_000) @Max(10_000) sortOrder!: number;
+}
+
+export class UpdateProjectDto extends OmitType(CreateProjectDto, ['clientRequestId'] as const) {
+ @Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
+}
+
+export class TransitionProjectDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsIn(['PUBLISHED', 'COMPLETED']) targetStatus!: 'PUBLISHED' | 'COMPLETED';
+}
+
+export class ReplaceProjectCasesDto {
+ @IsArray() @ArrayMaxSize(500) @IsUUID('4', { each: true }) serviceCasePublicIds!: string[];
+}
+
+export class ActivityQueryDto extends PaginationDto {
+ @IsOptional() @IsIn(ACTIVITY_STATUSES) status?: string;
+ @IsOptional() @IsUUID('4') projectPublicId?: string;
+}
+
+export class PublicActivityQueryDto extends PaginationDto {
+ @IsOptional() @IsUUID('4') projectPublicId?: string;
+}
+
+export class CreateActivityDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsOptional() @IsUUID('4') projectPublicId?: string;
+ @IsString() @Length(2, 200) title!: string;
+ @IsString() @Length(10, 500) summary!: string;
+ @IsString() @Length(20, 20000) content!: string;
+ @IsOptional() @IsUUID('4') coverFilePublicId?: string;
+ @IsISO8601({ strict: true }) startAt!: string;
+ @IsISO8601({ strict: true }) endAt!: string;
+ @IsISO8601({ strict: true }) registrationDeadline!: string;
+ @IsString() @Length(2, 300) locationText!: string;
+ @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100_000) capacity?: number;
+}
+
+export class UpdateActivityDto extends OmitType(CreateActivityDto, ['clientRequestId'] as const) {
+ @Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
+}
+
+export class TransitionActivityDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsIn(ACTIVITY_STATUSES.filter((status) => status !== 'DRAFT')) targetStatus!: string;
+}
+
+export class RegisterActivityDto {
+ @IsUUID('4') clientRequestId!: string;
+}
+
+export class RegistrationQueryDto extends PaginationDto {
+ @IsOptional() @IsIn(REGISTRATION_STATUSES) status?: string;
+ @IsOptional() @IsUUID('4') activityPublicId?: string;
+}
+
+export class ReviewRegistrationDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsIn(['APPROVED', 'REJECTED']) targetStatus!: 'APPROVED' | 'REJECTED';
+ @IsString() @Length(2, 500) note!: string;
+}
+
+export class CheckInRegistrationDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsIn(CHECK_IN_STATUSES.filter((status) => status !== 'NOT_CHECKED_IN')) targetStatus!:
+ 'CHECKED_IN' | 'ABSENT';
+}
+
+export class RecordServiceMinutesDto {
+ @IsUUID('4') clientRequestId!: string;
+ @Type(() => Number) @IsInt() @Min(1) @Max(100_800) serviceMinutes!: number;
+}
+
+export class CreateMediaUploadIntentDto {
+ @IsUUID('4') clientRequestId!: string;
+ @IsIn(['PROJECT_COVER', 'ACTIVITY_COVER']) businessType!: 'PROJECT_COVER' | 'ACTIVITY_COVER';
+ @IsString() @Length(1, 255) filename!: string;
+ @IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!: string;
+ @Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
+ @IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
+}
diff --git a/apps/api/src/participation/me-participation.controller.ts b/apps/api/src/participation/me-participation.controller.ts
new file mode 100644
index 0000000..f7c0809
--- /dev/null
+++ b/apps/api/src/participation/me-participation.controller.ts
@@ -0,0 +1,89 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Inject,
+ Param,
+ Post,
+ Put,
+ Query,
+ Req,
+ ValidationPipe,
+} from '@nestjs/common';
+
+import type {
+ ActivityRegistrationResponse,
+ PaginatedActivityRegistrationsResponse,
+ VolunteerProfileResponse,
+} from '@lehuo/contracts';
+
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SessionChannel } from '../generated/prisma/enums';
+import { ActivitiesService } from './activities.service';
+import {
+ ApplyVolunteerDto,
+ RegisterActivityDto,
+ RegistrationQueryDto,
+} from './dto/participation.dto';
+import { VolunteersService } from './volunteers.service';
+
+const strict = (expectedType: new () => object) =>
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType,
+ });
+
+@Controller('api/v1')
+@RequireSessionChannel(SessionChannel.WECHAT)
+export class MeParticipationController {
+ constructor(
+ @Inject(VolunteersService) private readonly volunteers: VolunteersService,
+ @Inject(ActivitiesService) private readonly activities: ActivitiesService,
+ ) {}
+
+ @Get('me/volunteer')
+ mine(@CurrentAuth() actor: AuthContext): Promise {
+ return this.volunteers.mine(actor);
+ }
+
+ @Put('me/volunteer')
+ apply(
+ @Body(strict(ApplyVolunteerDto)) input: ApplyVolunteerDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.volunteers.apply(input, actor, request);
+ }
+
+ @Get('me/activity-registrations')
+ registrations(
+ @Query(strict(RegistrationQueryDto)) query: RegistrationQueryDto,
+ @CurrentAuth() actor: AuthContext,
+ ): Promise {
+ return this.activities.myRegistrations(query, actor);
+ }
+
+ @Post('activities/:publicId/registrations')
+ register(
+ @Param('publicId') publicId: string,
+ @Body(strict(RegisterActivityDto)) input: RegisterActivityDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.activities.register(publicId, input, actor, request);
+ }
+
+ @Post('me/activity-registrations/:publicId/cancel')
+ cancel(
+ @Param('publicId') publicId: string,
+ @Body(strict(RegisterActivityDto)) input: RegisterActivityDto,
+ @CurrentAuth() actor: AuthContext,
+ @Req() request: RequestContext,
+ ): Promise {
+ return this.activities.cancel(publicId, input, actor, request);
+ }
+}
diff --git a/apps/api/src/participation/media.service.ts b/apps/api/src/participation/media.service.ts
new file mode 100644
index 0000000..6f7c014
--- /dev/null
+++ b/apps/api/src/participation/media.service.ts
@@ -0,0 +1,234 @@
+import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
+
+import {
+ API_ERROR_CODES,
+ type ConfirmUploadResponse,
+ type CreateUploadIntentResponse,
+ type ServiceRecordAttachment,
+} from '@lehuo/contracts';
+
+import { ApplicationError } from '../common/application-error';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { FilesRepository } from '../files/files.repository';
+import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
+import type { File, FileBusinessType } from '../generated/prisma/client';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+import type { CreateMediaUploadIntentDto } from './dto/participation.dto';
+
+const EXTENSIONS: Record = {
+ 'image/jpeg': 'jpg',
+ 'image/png': 'png',
+ 'image/webp': 'webp',
+};
+
+@Injectable()
+export class MediaService {
+ constructor(
+ @Inject(FilesRepository) private readonly files: FilesRepository,
+ @Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter,
+ @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
+ ) {}
+
+ async createIntent(
+ input: CreateMediaUploadIntentDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ this.assertConfigured();
+ const filename = this.safeFilename(input.filename);
+ const extension = EXTENSIONS[input.mimeType];
+ if (!extension || !this.extensionMatches(filename, extension)) {
+ throw this.validation('文件扩展名与图片类型不一致');
+ }
+ const fingerprint = this.sensitive.lookupHash(
+ 'cover-upload-intent',
+ JSON.stringify({ ...input, filename }),
+ );
+ const previous = await this.files.findByIdempotency(actor.userId, input.clientRequestId);
+ if (previous) {
+ if (
+ previous.requestFingerprint !== fingerprint ||
+ previous.businessType !== input.businessType
+ ) {
+ throw this.duplicate();
+ }
+ return this.uploadResponse(previous, true);
+ }
+ const now = new Date();
+ const objectKey = `public-covers/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
+ const file = await this.files.createPending(
+ {
+ publicId: randomUUID(),
+ businessType: input.businessType,
+ clientRequestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ provider: this.storage.providerName,
+ bucket: this.storage.bucket ?? '',
+ objectKey,
+ filenameEncrypted: this.sensitive.encrypt(filename),
+ mimeType: input.mimeType,
+ size: BigInt(input.size),
+ sha256: input.sha256,
+ },
+ actor,
+ request,
+ );
+ return this.uploadResponse(file, false);
+ }
+
+ async confirm(
+ publicId: string,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ this.assertConfigured();
+ const file = await this.files.findByPublicId(publicId);
+ if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId)
+ throw this.notFound();
+ if (file.businessType !== 'PROJECT_COVER' && file.businessType !== 'ACTIVITY_COVER') {
+ throw this.notFound();
+ }
+ if (file.status === 'AVAILABLE') return { file: this.attachment(file) };
+ if (file.status !== 'UPLOAD_PENDING') throw this.notReady();
+ let metadata;
+ try {
+ metadata = await this.storage.headObject(file.objectKey);
+ } catch {
+ throw this.storageUnavailable();
+ }
+ if (!metadata) throw this.notReady();
+ if (
+ metadata.contentLength !== Number(file.size) ||
+ metadata.contentType !== file.mimeType ||
+ (file.sha256 !== null && metadata.sha256 !== file.sha256)
+ ) {
+ await this.files.quarantine(publicId, actor, request, 'OBJECT_METADATA_MISMATCH');
+ throw this.validation('图片校验未通过,请重新选择文件');
+ }
+ return { file: this.attachment(await this.files.markAvailable(publicId, actor, request)) };
+ }
+
+ async requireCover(
+ publicId: string | undefined,
+ businessType: FileBusinessType,
+ actor: AuthContext,
+ ): Promise {
+ if (!publicId) return null;
+ const file = await this.files.findByPublicId(publicId);
+ if (
+ !file ||
+ file.status !== 'AVAILABLE' ||
+ file.businessType !== businessType ||
+ file.uploaderUserId !== actor.userId ||
+ file.caseId !== null
+ ) {
+ throw this.validation('封面图片无效或尚未确认上传');
+ }
+ return file;
+ }
+
+ async publicUrl(file: Pick | null): Promise {
+ if (!file || file.status !== 'AVAILABLE' || !this.storage.configured) return null;
+ try {
+ return await this.storage.createDownloadUrl(file.objectKey, 600);
+ } catch {
+ return null;
+ }
+ }
+
+ private async uploadResponse(
+ file: File,
+ idempotentReplay: boolean,
+ ): Promise {
+ const expiresIn = 600;
+ let uploadUrl: string;
+ try {
+ uploadUrl = await this.storage.createUploadUrl(
+ file.objectKey,
+ file.mimeType,
+ file.sha256 ?? undefined,
+ expiresIn,
+ );
+ } catch {
+ throw this.storageUnavailable();
+ }
+ return {
+ file: this.attachment(file),
+ uploadUrl,
+ uploadHeaders: {
+ 'Content-Type': file.mimeType,
+ ...(file.sha256 ? { 'x-amz-meta-sha256': file.sha256 } : {}),
+ },
+ expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
+ idempotentReplay,
+ };
+ }
+
+ private attachment(file: File): ServiceRecordAttachment {
+ return {
+ publicId: file.publicId,
+ filename: this.sensitive.decrypt(file.originalFilenameEncrypted),
+ mimeType: file.mimeType,
+ size: Number(file.size),
+ };
+ }
+
+ private safeFilename(value: string): string {
+ const filename = value.trim();
+ if (
+ !filename ||
+ filename.includes('/') ||
+ filename.includes('\\') ||
+ Array.from(filename).some((character) => character.charCodeAt(0) <= 31)
+ ) {
+ throw this.validation('文件名不合法');
+ }
+ return filename;
+ }
+
+ private extensionMatches(filename: string, extension: string): boolean {
+ const lower = filename.toLowerCase();
+ return lower.endsWith(`.${extension}`) || (extension === 'jpg' && lower.endsWith('.jpeg'));
+ }
+
+ private assertConfigured(): void {
+ if (!this.storage.configured || !this.storage.bucket) throw this.storageUnavailable();
+ }
+
+ private validation(message: string): ApplicationError {
+ return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
+ }
+
+ private duplicate(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.DUPLICATE_REQUEST,
+ '请求标识已用于其他文件',
+ HttpStatus.CONFLICT,
+ );
+ }
+
+ private notFound(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.FILE_NOT_FOUND,
+ '未找到该图片',
+ HttpStatus.NOT_FOUND,
+ );
+ }
+
+ private notReady(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.UPLOAD_NOT_READY,
+ '尚未收到上传文件,请完成上传后重试',
+ HttpStatus.CONFLICT,
+ );
+ }
+
+ private storageUnavailable(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.STORAGE_UNAVAILABLE,
+ '图片服务尚未配置或暂时不可用',
+ HttpStatus.SERVICE_UNAVAILABLE,
+ );
+ }
+}
diff --git a/apps/api/src/participation/participation.module.ts b/apps/api/src/participation/participation.module.ts
new file mode 100644
index 0000000..958747d
--- /dev/null
+++ b/apps/api/src/participation/participation.module.ts
@@ -0,0 +1,23 @@
+import { Module } from '@nestjs/common';
+
+import { AuditModule } from '../audit/audit.module';
+import { ContentNotificationsModule } from '../content-notifications/content-notifications.module';
+import { FilesModule } from '../files/files.module';
+import { ActivitiesService } from './activities.service';
+import { AdminParticipationController } from './admin-participation.controller';
+import { MeParticipationController } from './me-participation.controller';
+import { MediaService } from './media.service';
+import { ProjectsService } from './projects.service';
+import { PublicParticipationController } from './public-participation.controller';
+import { VolunteersService } from './volunteers.service';
+
+@Module({
+ imports: [AuditModule, FilesModule, ContentNotificationsModule],
+ controllers: [
+ PublicParticipationController,
+ MeParticipationController,
+ AdminParticipationController,
+ ],
+ providers: [VolunteersService, ProjectsService, ActivitiesService, MediaService],
+})
+export class ParticipationModule {}
diff --git a/apps/api/src/participation/projects.service.ts b/apps/api/src/participation/projects.service.ts
new file mode 100644
index 0000000..f198b0a
--- /dev/null
+++ b/apps/api/src/participation/projects.service.ts
@@ -0,0 +1,496 @@
+import { ForbiddenException, HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { randomBytes, randomUUID } from 'node:crypto';
+
+import {
+ API_ERROR_CODES,
+ PERMISSION_CODES,
+ PROJECT_STATUS_LABELS,
+ type PaginatedProjectsResponse,
+ type ProjectDetail,
+ type ProjectDetailResponse,
+ type ProjectListItem,
+} from '@lehuo/contracts';
+
+import { AuditService } from '../audit/audit.service';
+import { ApplicationError } from '../common/application-error';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { PrismaService } from '../database/prisma.service';
+import type { Prisma } from '../generated/prisma/client';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+import type {
+ CreateProjectDto,
+ ProjectQueryDto,
+ PublicProjectQueryDto,
+ ReplaceProjectCasesDto,
+ TransitionProjectDto,
+ UpdateProjectDto,
+} from './dto/participation.dto';
+import { MediaService } from './media.service';
+
+const projectInclude = {
+ coverFile: true,
+ manager: { include: { staffProfile: true } },
+ statusHistory: { orderBy: { createdAt: 'desc' as const } },
+ _count: { select: { activities: true, serviceCases: true } },
+} satisfies Prisma.ProjectInclude;
+
+type StoredProject = Prisma.ProjectGetPayload<{ include: typeof projectInclude }>;
+
+function dateNoPrefix(date: Date): string {
+ const parts = new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(date);
+ const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
+ return `${value.year}${value.month}${value.day}`;
+}
+
+@Injectable()
+export class ProjectsService {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ @Inject(MediaService) private readonly media: MediaService,
+ ) {}
+
+ async managerOptions() {
+ const users = await this.prisma.user.findMany({
+ where: {
+ status: 'ACTIVE',
+ staffProfile: { is: { status: 'ACTIVE' } },
+ userRoles: { some: { role: { code: 'PROJECT_MANAGER' } } },
+ },
+ include: { staffProfile: true },
+ orderBy: { staffProfile: { displayName: 'asc' } },
+ });
+ return users.map((user) => ({
+ userPublicId: user.publicId,
+ displayName: user.staffProfile?.displayName ?? '项目负责人',
+ type: 'STAFF' as const,
+ department: user.staffProfile?.department ?? null,
+ }));
+ }
+
+ async listAdmin(query: ProjectQueryDto, actor: AuthContext): Promise {
+ const where: Prisma.ProjectWhereInput = {
+ ...(query.status ? { status: query.status as never } : {}),
+ ...(query.category ? { category: query.category as never } : {}),
+ ...(!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
+ ? { managerUserId: actor.userId }
+ : {}),
+ };
+ return this.list(where, query.page, query.pageSize);
+ }
+
+ async listPublic(query: PublicProjectQueryDto): Promise {
+ return this.list(
+ {
+ isPublic: true,
+ status: { in: ['PUBLISHED', 'COMPLETED'] },
+ ...(query.category ? { category: query.category as never } : {}),
+ },
+ query.page,
+ query.pageSize,
+ );
+ }
+
+ async getAdmin(publicId: string, actor: AuthContext): Promise {
+ const project = await this.find(publicId);
+ this.assertAccess(project, actor);
+ return { project: await this.detail(project) };
+ }
+
+ async getPublic(publicId: string): Promise {
+ const project = await this.find(publicId);
+ if (!project.isPublic || !['PUBLISHED', 'COMPLETED'].includes(project.status))
+ throw this.notFound();
+ return { project: await this.detail(project) };
+ }
+
+ async create(
+ input: CreateProjectDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const values = await this.prepare(input, actor);
+ const fingerprint = this.fingerprint('project-create', {
+ ...input,
+ name: input.name.trim(),
+ summary: input.summary.trim(),
+ content: input.content.trim(),
+ });
+ const replay = await this.prisma.project.findUnique({
+ where: {
+ createdById_clientRequestId: {
+ createdById: actor.userId,
+ clientRequestId: input.clientRequestId,
+ },
+ },
+ include: projectInclude,
+ });
+ if (replay) {
+ if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
+ return { project: await this.detail(replay), idempotentReplay: true };
+ }
+ const project = await this.prisma.$transaction(async (transaction) => {
+ const created = await transaction.project.create({
+ data: {
+ publicId: randomUUID(),
+ projectNo: `XM${dateNoPrefix(new Date())}${randomBytes(4).toString('hex').toUpperCase()}`,
+ clientRequestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ ...values,
+ createdById: actor.userId,
+ updatedById: actor.userId,
+ },
+ });
+ await transaction.projectStatusHistory.create({
+ data: {
+ projectId: created.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ toStatus: 'DRAFT',
+ operatorUserId: actor.userId,
+ reasonCode: 'CREATED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'PROJECT_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'PROJECT',
+ resourcePublicId: created.publicId,
+ metadata: { status: 'DRAFT' },
+ },
+ transaction,
+ );
+ return transaction.project.findUniqueOrThrow({
+ where: { id: created.id },
+ include: projectInclude,
+ });
+ });
+ return { project: await this.detail(project), idempotentReplay: false };
+ }
+
+ async update(
+ publicId: string,
+ input: UpdateProjectDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const project = await this.find(publicId);
+ this.assertAccess(project, actor);
+ if (project.status !== 'DRAFT') throw this.invalidTransition('只有草稿项目可以编辑');
+ if (project.revision !== input.expectedRevision) throw this.conflict();
+ const values = await this.prepare(input, actor);
+ const updated = await this.prisma
+ .$transaction(async (transaction) => {
+ const result = await transaction.project.updateMany({
+ where: { id: project.id, revision: input.expectedRevision, status: 'DRAFT' },
+ data: { ...values, updatedById: actor.userId, revision: { increment: 1 } },
+ });
+ if (result.count !== 1) throw new Error('PROJECT_CONFLICT');
+ await this.audit.record(
+ request,
+ {
+ action: 'PROJECT_UPDATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'PROJECT',
+ resourcePublicId: publicId,
+ },
+ transaction,
+ );
+ return transaction.project.findUniqueOrThrow({
+ where: { id: project.id },
+ include: projectInclude,
+ });
+ })
+ .catch((error: unknown) => {
+ if (error instanceof Error && error.message === 'PROJECT_CONFLICT') throw this.conflict();
+ throw error;
+ });
+ return { project: await this.detail(updated) };
+ }
+
+ async transition(
+ publicId: string,
+ input: TransitionProjectDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const project = await this.find(publicId);
+ this.assertAccess(project, actor);
+ const fingerprint = this.fingerprint('project-transition', {
+ publicId,
+ targetStatus: input.targetStatus,
+ });
+ const replay = await this.prisma.projectStatusHistory.findUnique({
+ where: { requestId: input.clientRequestId },
+ include: { project: { include: projectInclude } },
+ });
+ if (replay) {
+ if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
+ return { project: await this.detail(replay.project), idempotentReplay: true };
+ }
+ const expected = input.targetStatus === 'PUBLISHED' ? 'DRAFT' : 'PUBLISHED';
+ if (project.status !== expected) throw this.invalidTransition();
+ const updated = await this.prisma
+ .$transaction(async (transaction) => {
+ const result = await transaction.project.updateMany({
+ where: { id: project.id, status: expected },
+ data: {
+ status: input.targetStatus,
+ updatedById: actor.userId,
+ revision: { increment: 1 },
+ },
+ });
+ if (result.count !== 1) throw new Error('PROJECT_STATE_CONFLICT');
+ await transaction.projectStatusHistory.create({
+ data: {
+ projectId: project.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ fromStatus: expected,
+ toStatus: input.targetStatus,
+ operatorUserId: actor.userId,
+ reasonCode: input.targetStatus === 'PUBLISHED' ? 'PUBLISHED' : 'COMPLETED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'PROJECT_STATUS_CHANGED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'PROJECT',
+ resourcePublicId: publicId,
+ metadata: { fromStatus: expected, toStatus: input.targetStatus },
+ },
+ transaction,
+ );
+ return transaction.project.findUniqueOrThrow({
+ where: { id: project.id },
+ include: projectInclude,
+ });
+ })
+ .catch((error: unknown) => {
+ if (error instanceof Error && error.message === 'PROJECT_STATE_CONFLICT')
+ throw this.invalidTransition();
+ throw error;
+ });
+ return { project: await this.detail(updated), idempotentReplay: false };
+ }
+
+ async replaceCases(
+ publicId: string,
+ input: ReplaceProjectCasesDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const project = await this.find(publicId);
+ this.assertAccess(project, actor);
+ if (project.status === 'COMPLETED') throw this.invalidTransition('已完成项目不能调整服务工单');
+ if (new Set(input.serviceCasePublicIds).size !== input.serviceCasePublicIds.length) {
+ throw this.validation('服务工单不能重复');
+ }
+ const cases = await this.prisma.serviceCase.findMany({
+ where: { publicId: { in: input.serviceCasePublicIds } },
+ select: { id: true, projectId: true },
+ });
+ if (cases.length !== input.serviceCasePublicIds.length)
+ throw this.validation('包含无效的服务工单');
+ if (
+ !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) &&
+ cases.some(
+ (serviceCase) => serviceCase.projectId !== null && serviceCase.projectId !== project.id,
+ )
+ ) {
+ throw new ForbiddenException();
+ }
+ const updated = await this.prisma.$transaction(async (transaction) => {
+ await transaction.serviceCase.updateMany({
+ where: { projectId: project.id },
+ data: { projectId: null },
+ });
+ if (cases.length) {
+ await transaction.serviceCase.updateMany({
+ where: { id: { in: cases.map((item) => item.id) } },
+ data: { projectId: project.id },
+ });
+ }
+ await this.audit.record(
+ request,
+ {
+ action: 'PROJECT_CASES_REPLACED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'PROJECT',
+ resourcePublicId: publicId,
+ metadata: { serviceCaseCount: cases.length },
+ },
+ transaction,
+ );
+ return transaction.project.findUniqueOrThrow({
+ where: { id: project.id },
+ include: projectInclude,
+ });
+ });
+ return { project: await this.detail(updated) };
+ }
+
+ private async prepare(input: CreateProjectDto | UpdateProjectDto, actor: AuthContext) {
+ const startDate = new Date(`${input.startDate}T00:00:00.000Z`);
+ const endDate = new Date(`${input.endDate}T00:00:00.000Z`);
+ if (endDate < startDate) throw this.validation('项目结束日期不能早于开始日期');
+ const manager = await this.prisma.user.findFirst({
+ where: {
+ publicId: input.managerUserPublicId,
+ status: 'ACTIVE',
+ staffProfile: { isNot: null },
+ userRoles: { some: { role: { code: 'PROJECT_MANAGER' } } },
+ },
+ });
+ if (!manager) throw this.validation('负责人必须是有效的项目管理员');
+ const cover = await this.media.requireCover(input.coverFilePublicId, 'PROJECT_COVER', actor);
+ return {
+ name: input.name.trim(),
+ category: input.category as never,
+ summary: input.summary.trim(),
+ content: input.content.trim(),
+ coverFileId: cover?.id,
+ managerUserId: manager.id,
+ startDate,
+ endDate,
+ targetPeople: input.targetPeople,
+ isPublic: input.isPublic,
+ sortOrder: input.sortOrder,
+ };
+ }
+
+ private async list(
+ where: Prisma.ProjectWhereInput,
+ page: number,
+ pageSize: number,
+ ): Promise {
+ const [total, projects] = await this.prisma.$transaction([
+ this.prisma.project.count({ where }),
+ this.prisma.project.findMany({
+ where,
+ include: projectInclude,
+ orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }],
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ }),
+ ]);
+ return {
+ items: await Promise.all(projects.map((project) => this.item(project))),
+ page,
+ pageSize,
+ total,
+ };
+ }
+
+ private async find(publicId: string): Promise {
+ const project = await this.prisma.project.findUnique({
+ where: { publicId },
+ include: projectInclude,
+ });
+ if (!project) throw this.notFound();
+ return project;
+ }
+
+ private assertAccess(project: StoredProject, actor: AuthContext): void {
+ if (
+ project.managerUserId !== actor.userId &&
+ !actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
+ ) {
+ throw new ForbiddenException();
+ }
+ }
+
+ private async item(project: StoredProject): Promise {
+ return {
+ publicId: project.publicId,
+ projectNo: project.projectNo,
+ name: project.name,
+ category: project.category,
+ summary: project.summary,
+ coverImageUrl: await this.media.publicUrl(project.coverFile),
+ managerUserPublicId: project.manager.publicId,
+ managerDisplayName:
+ project.manager.staffProfile?.displayName ?? project.manager.nickname ?? '项目负责人',
+ startDate: project.startDate.toISOString().slice(0, 10),
+ endDate: project.endDate.toISOString().slice(0, 10),
+ targetPeople: project.targetPeople,
+ status: project.status,
+ statusLabel: PROJECT_STATUS_LABELS[project.status],
+ isPublic: project.isPublic,
+ sortOrder: project.sortOrder,
+ revision: project.revision,
+ activityCount: project._count.activities,
+ serviceCaseCount: project._count.serviceCases,
+ createdAt: project.createdAt.toISOString(),
+ };
+ }
+
+ private async detail(project: StoredProject): Promise {
+ return {
+ ...(await this.item(project)),
+ content: project.content,
+ coverFilePublicId: project.coverFile?.publicId ?? null,
+ history: project.statusHistory.map((history) => ({
+ status: history.toStatus,
+ statusLabel: PROJECT_STATUS_LABELS[history.toStatus],
+ reasonLabel:
+ (
+ { CREATED: '创建项目', PUBLISHED: '发布项目', COMPLETED: '完成项目' } as Record<
+ string,
+ string
+ >
+ )[history.reasonCode] ?? '状态更新',
+ createdAt: history.createdAt.toISOString(),
+ })),
+ };
+ }
+
+ private fingerprint(domain: string, value: object): string {
+ return this.sensitive.lookupHash(domain, JSON.stringify(value));
+ }
+ private validation(message: string): ApplicationError {
+ return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
+ }
+ private notFound(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.PROJECT_NOT_FOUND,
+ '未找到该公益项目',
+ HttpStatus.NOT_FOUND,
+ );
+ }
+ private conflict(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.RESOURCE_CONFLICT,
+ '项目已更新,请刷新后重试',
+ HttpStatus.CONFLICT,
+ );
+ }
+ private invalidTransition(message = '当前项目状态不能执行该操作'): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.INVALID_STATE_TRANSITION,
+ message,
+ HttpStatus.CONFLICT,
+ );
+ }
+ private duplicate(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.DUPLICATE_REQUEST,
+ '请求标识已用于其他操作',
+ HttpStatus.CONFLICT,
+ );
+ }
+}
diff --git a/apps/api/src/participation/public-participation.controller.ts b/apps/api/src/participation/public-participation.controller.ts
new file mode 100644
index 0000000..c71b8e6
--- /dev/null
+++ b/apps/api/src/participation/public-participation.controller.ts
@@ -0,0 +1,54 @@
+import { Controller, Get, Inject, Param, Query, ValidationPipe } from '@nestjs/common';
+
+import type {
+ ActivityDetailResponse,
+ PaginatedActivitiesResponse,
+ PaginatedProjectsResponse,
+ ProjectDetailResponse,
+} from '@lehuo/contracts';
+
+import { Public } from '../common/decorators/public.decorator';
+import { ActivitiesService } from './activities.service';
+import { PublicActivityQueryDto, PublicProjectQueryDto } from './dto/participation.dto';
+import { ProjectsService } from './projects.service';
+
+const strict = (expectedType: new () => object) =>
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType,
+ });
+
+@Public()
+@Controller('api/v1/public')
+export class PublicParticipationController {
+ constructor(
+ @Inject(ProjectsService) private readonly projects: ProjectsService,
+ @Inject(ActivitiesService) private readonly activities: ActivitiesService,
+ ) {}
+
+ @Get('projects')
+ projectsList(
+ @Query(strict(PublicProjectQueryDto)) query: PublicProjectQueryDto,
+ ): Promise {
+ return this.projects.listPublic(query);
+ }
+
+ @Get('projects/:publicId')
+ project(@Param('publicId') publicId: string): Promise {
+ return this.projects.getPublic(publicId);
+ }
+
+ @Get('activities')
+ activitiesList(
+ @Query(strict(PublicActivityQueryDto)) query: PublicActivityQueryDto,
+ ): Promise {
+ return this.activities.listPublic(query);
+ }
+
+ @Get('activities/:publicId')
+ activity(@Param('publicId') publicId: string): Promise {
+ return this.activities.getPublic(publicId);
+ }
+}
diff --git a/apps/api/src/participation/volunteers.service.ts b/apps/api/src/participation/volunteers.service.ts
new file mode 100644
index 0000000..413b1c5
--- /dev/null
+++ b/apps/api/src/participation/volunteers.service.ts
@@ -0,0 +1,474 @@
+import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { randomUUID } from 'node:crypto';
+
+import {
+ API_ERROR_CODES,
+ VOLUNTEER_SKILL_LABELS,
+ VOLUNTEER_STATUS_LABELS,
+ type AdminVolunteerDetailResponse,
+ type PaginatedVolunteersResponse,
+ type VolunteerProfile,
+ type VolunteerProfileResponse,
+ type VolunteerSkillCode,
+ type VolunteerStatus,
+} from '@lehuo/contracts';
+
+import { AuditService } from '../audit/audit.service';
+import { ApplicationError } from '../common/application-error';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { PrismaService } from '../database/prisma.service';
+import type { Prisma } from '../generated/prisma/client';
+import { SensitiveDataService } from '../security/sensitive-data.service';
+import type {
+ ApplyVolunteerDto,
+ RestoreVolunteerDto,
+ ReviewVolunteerDto,
+ VolunteerQueryDto,
+} from './dto/participation.dto';
+
+const volunteerInclude = {
+ skills: true,
+ interests: true,
+ statusHistory: { orderBy: { createdAt: 'desc' as const } },
+ registrations: {
+ where: { status: 'APPROVED' as const, serviceMinutes: { not: null } },
+ select: { serviceMinutes: true },
+ },
+} satisfies Prisma.VolunteerInclude;
+
+type StoredVolunteer = Prisma.VolunteerGetPayload<{ include: typeof volunteerInclude }>;
+
+function maskMobile(value: string): string {
+ return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
+}
+
+@Injectable()
+export class VolunteersService {
+ constructor(
+ @Inject(PrismaService) private readonly prisma: PrismaService,
+ @Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
+ @Inject(AuditService) private readonly audit: AuditService,
+ ) {}
+
+ async mine(actor: AuthContext): Promise {
+ const volunteer = await this.prisma.volunteer.findUnique({
+ where: { userId: actor.userId },
+ include: volunteerInclude,
+ });
+ return { volunteer: volunteer ? this.profile(volunteer) : null };
+ }
+
+ async apply(
+ input: ApplyVolunteerDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ this.assertUnique(input.skillCodes, '技能不能重复');
+ this.assertUnique(input.interestCategories, '服务意向不能重复');
+ const normalized = {
+ realName: input.realName.trim(),
+ mobile: input.mobile,
+ regionCode: input.regionCode.trim(),
+ availabilityNote: input.availabilityNote.trim(),
+ skillCodes: [...input.skillCodes].sort(),
+ interestCategories: [...input.interestCategories].sort(),
+ expectedRevision: input.expectedRevision ?? null,
+ privacyConsent: input.privacyConsent,
+ };
+ const fingerprint = this.sensitive.lookupHash(
+ 'volunteer-application',
+ JSON.stringify(normalized),
+ );
+ const existing = await this.prisma.volunteer.findUnique({
+ where: { userId: actor.userId },
+ include: volunteerInclude,
+ });
+ const replay = await this.prisma.volunteerStatusHistory.findUnique({
+ where: { requestId: input.clientRequestId },
+ include: { volunteer: { include: volunteerInclude } },
+ });
+ if (replay) {
+ if (replay.operatorUserId !== actor.userId || replay.requestFingerprint !== fingerprint) {
+ throw this.duplicate();
+ }
+ return { volunteer: this.profile(replay.volunteer), idempotentReplay: true };
+ }
+ if (existing && input.expectedRevision !== existing.revision) {
+ throw this.conflict('志愿者资料已更新,请刷新后重试');
+ }
+ if (!existing && input.expectedRevision !== undefined) {
+ throw this.validation('首次申请不应包含资料版本');
+ }
+
+ const identityChanged = Boolean(
+ existing &&
+ (existing.realNameHash !== this.sensitive.lookupHash('volunteer-name', normalized.realName) ||
+ existing.mobileHash !== this.sensitive.lookupHash('volunteer-mobile', normalized.mobile)),
+ );
+ const nextStatus: VolunteerStatus =
+ existing?.status === 'REJECTED' || existing?.status === 'INACTIVE'
+ ? 'APPLIED'
+ : existing?.status === 'APPROVED' && identityChanged
+ ? 'APPLIED'
+ : (existing?.status ?? 'APPLIED');
+ const created = await this.prisma
+ .$transaction(async (transaction) => {
+ const consent = await transaction.consent.create({
+ data: {
+ publicId: randomUUID(),
+ userId: actor.userId,
+ consentType: 'VOLUNTEER_APPLICATION',
+ version: 'volunteer-application-v1',
+ grantedBy: 'SELF',
+ grantedAt: new Date(),
+ evidenceJson: { affirmativeAction: true },
+ ipHash: request.ip ? this.sensitive.auditIpHash(request.ip) : null,
+ },
+ });
+ let volunteer;
+ if (existing) {
+ const updated = await transaction.volunteer.updateMany({
+ where: { id: existing.id, revision: existing.revision },
+ data: {
+ consentId: consent.id,
+ realNameEncrypted: this.sensitive.encrypt(normalized.realName),
+ realNameHash: this.sensitive.lookupHash('volunteer-name', normalized.realName),
+ mobileEncrypted: this.sensitive.encrypt(normalized.mobile),
+ mobileHash: this.sensitive.lookupHash('volunteer-mobile', normalized.mobile),
+ regionCode: normalized.regionCode,
+ availabilityNoteEncrypted: this.sensitive.encrypt(normalized.availabilityNote),
+ status: nextStatus,
+ updatedById: actor.userId,
+ revision: { increment: 1 },
+ },
+ });
+ if (updated.count !== 1) throw new Error('REVISION_CONFLICT');
+ await transaction.volunteerSkill.deleteMany({ where: { volunteerId: existing.id } });
+ await transaction.volunteerInterest.deleteMany({ where: { volunteerId: existing.id } });
+ volunteer = await transaction.volunteer.findUniqueOrThrow({ where: { id: existing.id } });
+ } else {
+ volunteer = await transaction.volunteer.create({
+ data: {
+ publicId: randomUUID(),
+ userId: actor.userId,
+ consentId: consent.id,
+ realNameEncrypted: this.sensitive.encrypt(normalized.realName),
+ realNameHash: this.sensitive.lookupHash('volunteer-name', normalized.realName),
+ mobileEncrypted: this.sensitive.encrypt(normalized.mobile),
+ mobileHash: this.sensitive.lookupHash('volunteer-mobile', normalized.mobile),
+ regionCode: normalized.regionCode,
+ availabilityNoteEncrypted: this.sensitive.encrypt(normalized.availabilityNote),
+ status: nextStatus,
+ createdById: actor.userId,
+ updatedById: actor.userId,
+ },
+ });
+ }
+ if (normalized.skillCodes.length) {
+ await transaction.volunteerSkill.createMany({
+ data: normalized.skillCodes.map((skillCode) => ({
+ volunteerId: volunteer.id,
+ skillCode,
+ })),
+ });
+ }
+ if (normalized.interestCategories.length) {
+ await transaction.volunteerInterest.createMany({
+ data: normalized.interestCategories.map((category) => ({
+ volunteerId: volunteer.id,
+ category: category as never,
+ })),
+ });
+ }
+ if (existing?.status === 'APPROVED' && nextStatus === 'APPLIED') {
+ const role = await transaction.role.findUniqueOrThrow({
+ where: { code: 'VOLUNTEER' },
+ });
+ await transaction.userRole.deleteMany({
+ where: { userId: actor.userId, roleId: role.id },
+ });
+ }
+ await transaction.volunteerStatusHistory.create({
+ data: {
+ volunteerId: volunteer.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ fromStatus: existing?.status,
+ toStatus: nextStatus,
+ operatorUserId: actor.userId,
+ reasonCode: existing
+ ? nextStatus === 'APPLIED'
+ ? identityChanged
+ ? 'IDENTITY_CHANGED'
+ : 'REAPPLIED'
+ : 'PROFILE_UPDATED'
+ : 'APPLIED',
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: existing ? 'VOLUNTEER_PROFILE_UPDATED' : 'VOLUNTEER_APPLICATION_CREATED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'VOLUNTEER',
+ resourcePublicId: volunteer.publicId,
+ metadata: { status: nextStatus },
+ },
+ transaction,
+ );
+ return transaction.volunteer.findUniqueOrThrow({
+ where: { id: volunteer.id },
+ include: volunteerInclude,
+ });
+ })
+ .catch((error: unknown) => {
+ if (error instanceof Error && error.message === 'REVISION_CONFLICT') {
+ throw this.conflict('志愿者资料已更新,请刷新后重试');
+ }
+ throw error;
+ });
+ return { volunteer: this.profile(created), idempotentReplay: false };
+ }
+
+ async list(query: VolunteerQueryDto): Promise {
+ const where = {
+ ...(query.status ? { status: query.status as never } : {}),
+ ...(query.regionCode ? { regionCode: query.regionCode } : {}),
+ };
+ const [total, volunteers] = await this.prisma.$transaction([
+ this.prisma.volunteer.count({ where }),
+ this.prisma.volunteer.findMany({
+ where,
+ include: volunteerInclude,
+ orderBy: { createdAt: 'desc' },
+ skip: (query.page - 1) * query.pageSize,
+ take: query.pageSize,
+ }),
+ ]);
+ return {
+ items: volunteers.map((volunteer) => {
+ const item = this.profile(volunteer);
+ return {
+ publicId: item.publicId,
+ realName: item.realName,
+ mobileMasked: item.mobileMasked,
+ regionCode: item.regionCode,
+ skillLabels: item.skillLabels,
+ interestCategories: item.interestCategories,
+ status: item.status,
+ statusLabel: item.statusLabel,
+ totalServiceMinutes: item.totalServiceMinutes,
+ completedActivityCount: item.completedActivityCount,
+ createdAt: item.createdAt,
+ };
+ }),
+ page: query.page,
+ pageSize: query.pageSize,
+ total,
+ };
+ }
+
+ async get(
+ publicId: string,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const volunteer = await this.prisma.volunteer.findUnique({
+ where: { publicId },
+ include: volunteerInclude,
+ });
+ if (!volunteer) throw this.notFound();
+ await this.audit.record(request, {
+ action: 'VOLUNTEER_PROFILE_VIEWED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'VOLUNTEER',
+ resourcePublicId: publicId,
+ });
+ return { volunteer: this.profile(volunteer) };
+ }
+
+ review(
+ publicId: string,
+ input: ReviewVolunteerDto | RestoreVolunteerDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ return this.changeStatus(publicId, input, actor, request);
+ }
+
+ private async changeStatus(
+ publicId: string,
+ input: ReviewVolunteerDto | RestoreVolunteerDto,
+ actor: AuthContext,
+ request: RequestContext,
+ ): Promise {
+ const fingerprint = this.sensitive.lookupHash(
+ 'volunteer-review',
+ JSON.stringify({ publicId, targetStatus: input.targetStatus, note: input.note.trim() }),
+ );
+ const replay = await this.prisma.volunteerStatusHistory.findUnique({
+ where: { requestId: input.clientRequestId },
+ include: { volunteer: { include: volunteerInclude } },
+ });
+ if (replay) {
+ if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
+ return { volunteer: this.profile(replay.volunteer) };
+ }
+ const volunteer = await this.prisma.volunteer.findUnique({ where: { publicId } });
+ if (!volunteer) throw this.notFound();
+ const allowed: Record = {
+ APPLIED: ['APPROVED', 'REJECTED'],
+ APPROVED: ['SUSPENDED'],
+ SUSPENDED: ['APPROVED'],
+ };
+ if (!allowed[volunteer.status]?.includes(input.targetStatus)) throw this.invalidTransition();
+ const result = await this.prisma
+ .$transaction(async (transaction) => {
+ const changed = await transaction.volunteer.updateMany({
+ where: { id: volunteer.id, status: volunteer.status },
+ data: {
+ status: input.targetStatus,
+ approvedById: input.targetStatus === 'APPROVED' ? actor.userId : volunteer.approvedById,
+ approvedAt: input.targetStatus === 'APPROVED' ? new Date() : volunteer.approvedAt,
+ updatedById: actor.userId,
+ revision: { increment: 1 },
+ },
+ });
+ if (changed.count !== 1) throw new Error('STATE_CONFLICT');
+ const role = await transaction.role.findUniqueOrThrow({ where: { code: 'VOLUNTEER' } });
+ if (input.targetStatus === 'APPROVED') {
+ await transaction.userRole.upsert({
+ where: { userId_roleId: { userId: volunteer.userId, roleId: role.id } },
+ create: { userId: volunteer.userId, roleId: role.id, assignedBy: actor.userId },
+ update: { assignedBy: actor.userId, assignedAt: new Date() },
+ });
+ } else {
+ await transaction.userRole.deleteMany({
+ where: { userId: volunteer.userId, roleId: role.id },
+ });
+ }
+ await transaction.volunteerStatusHistory.create({
+ data: {
+ volunteerId: volunteer.id,
+ requestId: input.clientRequestId,
+ requestFingerprint: fingerprint,
+ fromStatus: volunteer.status,
+ toStatus: input.targetStatus,
+ operatorUserId: actor.userId,
+ reasonCode: `REVIEW_${input.targetStatus}`,
+ noteEncrypted: this.sensitive.encrypt(input.note.trim()),
+ },
+ });
+ await this.audit.record(
+ request,
+ {
+ action: 'VOLUNTEER_STATUS_CHANGED',
+ result: 'SUCCESS',
+ actor,
+ resourceType: 'VOLUNTEER',
+ resourcePublicId: publicId,
+ metadata: { fromStatus: volunteer.status, toStatus: input.targetStatus },
+ },
+ transaction,
+ );
+ return transaction.volunteer.findUniqueOrThrow({
+ where: { id: volunteer.id },
+ include: volunteerInclude,
+ });
+ })
+ .catch((error: unknown) => {
+ if (error instanceof Error && error.message === 'STATE_CONFLICT')
+ throw this.invalidTransition();
+ throw error;
+ });
+ return { volunteer: this.profile(result) };
+ }
+
+ private profile(volunteer: StoredVolunteer): VolunteerProfile {
+ const mobile = this.sensitive.decrypt(volunteer.mobileEncrypted);
+ const serviceMinutes = volunteer.registrations.reduce(
+ (sum, item) => sum + (item.serviceMinutes ?? 0),
+ 0,
+ );
+ return {
+ publicId: volunteer.publicId,
+ realName: this.sensitive.decrypt(volunteer.realNameEncrypted),
+ mobile,
+ mobileMasked: maskMobile(mobile),
+ regionCode: volunteer.regionCode,
+ availabilityNote: this.sensitive.decrypt(volunteer.availabilityNoteEncrypted),
+ skillCodes: volunteer.skills.map((skill) => skill.skillCode as VolunteerSkillCode),
+ skillLabels: volunteer.skills.map(
+ (skill) => VOLUNTEER_SKILL_LABELS[skill.skillCode as VolunteerSkillCode] ?? skill.skillCode,
+ ),
+ interestCategories: volunteer.interests.map((interest) => interest.category),
+ status: volunteer.status,
+ statusLabel: VOLUNTEER_STATUS_LABELS[volunteer.status],
+ revision: volunteer.revision,
+ totalServiceMinutes: serviceMinutes,
+ completedActivityCount: volunteer.registrations.length,
+ history: volunteer.statusHistory.map((history) => ({
+ status: history.toStatus,
+ statusLabel: VOLUNTEER_STATUS_LABELS[history.toStatus],
+ reasonLabel: this.reasonLabel(history.reasonCode),
+ note: history.noteEncrypted ? this.sensitive.decrypt(history.noteEncrypted) : null,
+ createdAt: history.createdAt.toISOString(),
+ })),
+ createdAt: volunteer.createdAt.toISOString(),
+ };
+ }
+
+ private reasonLabel(code: string): string {
+ return (
+ (
+ {
+ APPLIED: '提交申请',
+ REAPPLIED: '重新申请',
+ IDENTITY_CHANGED: '身份信息变更,重新审核',
+ PROFILE_UPDATED: '更新资料',
+ REVIEW_APPROVED: '审核通过',
+ REVIEW_REJECTED: '审核未通过',
+ REVIEW_SUSPENDED: '暂停志愿者资格',
+ } as Record
+ )[code] ?? '状态更新'
+ );
+ }
+
+ private assertUnique(values: string[], message: string): void {
+ if (new Set(values).size !== values.length) throw this.validation(message);
+ }
+
+ private validation(message: string): ApplicationError {
+ return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
+ }
+
+ private notFound(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.VOLUNTEER_NOT_FOUND,
+ '未找到志愿者资料',
+ HttpStatus.NOT_FOUND,
+ );
+ }
+
+ private invalidTransition(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.INVALID_STATE_TRANSITION,
+ '当前状态不能执行该操作',
+ HttpStatus.CONFLICT,
+ );
+ }
+
+ private conflict(message: string): ApplicationError {
+ return new ApplicationError(API_ERROR_CODES.RESOURCE_CONFLICT, message, HttpStatus.CONFLICT);
+ }
+
+ private duplicate(): ApplicationError {
+ return new ApplicationError(
+ API_ERROR_CODES.DUPLICATE_REQUEST,
+ '请求标识已用于其他操作',
+ HttpStatus.CONFLICT,
+ );
+ }
+}
diff --git a/apps/api/src/permissions/permission.guard.ts b/apps/api/src/permissions/permission.guard.ts
new file mode 100644
index 0000000..30c1d42
--- /dev/null
+++ b/apps/api/src/permissions/permission.guard.ts
@@ -0,0 +1,34 @@
+import {
+ ForbiddenException,
+ Inject,
+ Injectable,
+ type CanActivate,
+ type ExecutionContext,
+} from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+
+import type { PermissionCode } from '@lehuo/contracts';
+
+import { REQUIRED_PERMISSIONS_KEY } from '../common/decorators/require-permissions.decorator';
+import type { RequestContext } from '../common/request-context';
+
+@Injectable()
+export class PermissionGuard implements CanActivate {
+ constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
+
+ canActivate(context: ExecutionContext): boolean {
+ const required = this.reflector.getAllAndOverride(REQUIRED_PERMISSIONS_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+ if (!required?.length) return true;
+ const request = context.switchToHttp().getRequest();
+ if (
+ !request.auth ||
+ required.some((permission) => !request.auth?.permissions.includes(permission))
+ ) {
+ throw new ForbiddenException();
+ }
+ return true;
+ }
+}
diff --git a/apps/api/src/permissions/permissions.module.ts b/apps/api/src/permissions/permissions.module.ts
new file mode 100644
index 0000000..f5346b2
--- /dev/null
+++ b/apps/api/src/permissions/permissions.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+
+import { ResourcePolicyService } from './resource-policy.service';
+
+@Module({
+ providers: [ResourcePolicyService],
+ exports: [ResourcePolicyService],
+})
+export class PermissionsModule {}
diff --git a/apps/api/src/permissions/resource-policy.service.spec.ts b/apps/api/src/permissions/resource-policy.service.spec.ts
new file mode 100644
index 0000000..42fc509
--- /dev/null
+++ b/apps/api/src/permissions/resource-policy.service.spec.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+
+import { PERMISSION_CODES } from '@lehuo/contracts';
+
+import type { AuthContext } from '../common/request-context';
+import { ResourcePolicyService } from './resource-policy.service';
+
+function actor(userId: bigint, permissions: AuthContext['permissions'] = []): AuthContext {
+ return {
+ userId,
+ userPublicId: '37c96323-8d3d-4529-b143-5fa245708fbb',
+ status: 'ACTIVE',
+ userType: 'STAFF',
+ displayName: '测试工作人员',
+ roles: ['STAFF'],
+ permissions,
+ sessionId: 1n,
+ channel: 'ADMIN',
+ rawToken: 'test-token',
+ };
+}
+
+describe('ResourcePolicyService', () => {
+ const service = new ResourcePolicyService();
+
+ it('allows owners to access their own resource', () => {
+ expect(
+ service.canAccessOwnedResource(actor(10n), 10n, PERMISSION_CODES.RESOURCES_ACCESS_ALL),
+ ).toBe(true);
+ });
+
+ it('denies a different owner without elevated permission', () => {
+ expect(
+ service.canAccessOwnedResource(actor(10n), 11n, PERMISSION_CODES.RESOURCES_ACCESS_ALL),
+ ).toBe(false);
+ });
+
+ it('allows explicitly elevated cross-resource access', () => {
+ expect(
+ service.canAccessOwnedResource(
+ actor(10n, [PERMISSION_CODES.RESOURCES_ACCESS_ALL]),
+ 11n,
+ PERMISSION_CODES.RESOURCES_ACCESS_ALL,
+ ),
+ ).toBe(true);
+ });
+
+ it('enforces strict ownership for public self-service resources', () => {
+ expect(() =>
+ service.assertResourceOwner(actor(10n, [PERMISSION_CODES.RESOURCES_ACCESS_ALL]), 11n),
+ ).toThrow();
+ expect(() => service.assertResourceOwner(actor(10n), 10n)).not.toThrow();
+ });
+});
diff --git a/apps/api/src/permissions/resource-policy.service.ts b/apps/api/src/permissions/resource-policy.service.ts
new file mode 100644
index 0000000..0be6109
--- /dev/null
+++ b/apps/api/src/permissions/resource-policy.service.ts
@@ -0,0 +1,32 @@
+import { ForbiddenException, Injectable } from '@nestjs/common';
+
+import type { PermissionCode } from '@lehuo/contracts';
+
+import type { AuthContext } from '../common/request-context';
+
+@Injectable()
+export class ResourcePolicyService {
+ assertResourceOwner(actor: AuthContext, ownerUserId: bigint): void {
+ if (actor.userId !== ownerUserId) {
+ throw new ForbiddenException();
+ }
+ }
+
+ canAccessOwnedResource(
+ actor: AuthContext,
+ ownerUserId: bigint,
+ elevatedPermission: PermissionCode,
+ ): boolean {
+ return actor.userId === ownerUserId || actor.permissions.includes(elevatedPermission);
+ }
+
+ assertOwnedResource(
+ actor: AuthContext,
+ ownerUserId: bigint,
+ elevatedPermission: PermissionCode,
+ ): void {
+ if (!this.canAccessOwnedResource(actor, ownerUserId, elevatedPermission)) {
+ throw new ForbiddenException();
+ }
+ }
+}
diff --git a/apps/api/src/scripts/bootstrap-admin.ts b/apps/api/src/scripts/bootstrap-admin.ts
new file mode 100644
index 0000000..a414b81
--- /dev/null
+++ b/apps/api/src/scripts/bootstrap-admin.ts
@@ -0,0 +1,18 @@
+import 'reflect-metadata';
+
+import { NestFactory } from '@nestjs/core';
+
+import { AppModule } from '../app.module';
+import { BootstrapAdminService } from '../users/bootstrap-admin.service';
+
+async function main(): Promise {
+ const app = await NestFactory.createApplicationContext(AppModule, { logger: false });
+ try {
+ const publicId = await app.get(BootstrapAdminService).createInitialAdmin();
+ process.stdout.write(`Initial administrator created: ${publicId}\n`);
+ } finally {
+ await app.close();
+ }
+}
+
+void main();
diff --git a/apps/api/src/security/security.module.ts b/apps/api/src/security/security.module.ts
new file mode 100644
index 0000000..a026143
--- /dev/null
+++ b/apps/api/src/security/security.module.ts
@@ -0,0 +1,10 @@
+import { Global, Module } from '@nestjs/common';
+
+import { SensitiveDataService } from './sensitive-data.service';
+
+@Global()
+@Module({
+ providers: [SensitiveDataService],
+ exports: [SensitiveDataService],
+})
+export class SecurityModule {}
diff --git a/apps/api/src/security/sensitive-data.service.spec.ts b/apps/api/src/security/sensitive-data.service.spec.ts
new file mode 100644
index 0000000..d416184
--- /dev/null
+++ b/apps/api/src/security/sensitive-data.service.spec.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+
+import type { AppConfigService } from '../config/app-config.service';
+import { SensitiveDataService } from './sensitive-data.service';
+
+const config = {
+ fieldEncryptionKey: Buffer.alloc(32, 3),
+ sessionTokenPepper: 'test-session-token-pepper',
+ auditIpHashKey: 'test-audit-ip-hash-key',
+} as AppConfigService;
+
+describe('SensitiveDataService', () => {
+ it('encrypts sensitive values with randomized authenticated ciphertext', () => {
+ const service = new SensitiveDataService(config);
+ const first = service.encrypt('sensitive-openid');
+ const second = service.encrypt('sensitive-openid');
+
+ expect(first).not.toContain('sensitive-openid');
+ expect(first).not.toBe(second);
+ expect(service.decrypt(first)).toBe('sensitive-openid');
+ });
+
+ it('creates stable domain-separated lookup hashes', () => {
+ const service = new SensitiveDataService(config);
+ expect(service.lookupHash('openid', 'value')).toBe(service.lookupHash('openid', 'value'));
+ expect(service.lookupHash('openid', 'value')).not.toBe(service.lookupHash('unionid', 'value'));
+ });
+});
diff --git a/apps/api/src/security/sensitive-data.service.ts b/apps/api/src/security/sensitive-data.service.ts
new file mode 100644
index 0000000..c42c6e7
--- /dev/null
+++ b/apps/api/src/security/sensitive-data.service.ts
@@ -0,0 +1,53 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto';
+
+import { AppConfigService } from '../config/app-config.service';
+
+@Injectable()
+export class SensitiveDataService {
+ constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {}
+
+ encrypt(value: string): string {
+ const iv = randomBytes(12);
+ const cipher = createCipheriv('aes-256-gcm', this.config.fieldEncryptionKey, iv);
+ const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ return [
+ 'v1',
+ iv.toString('base64url'),
+ tag.toString('base64url'),
+ ciphertext.toString('base64url'),
+ ].join('.');
+ }
+
+ decrypt(value: string): string {
+ const [version, ivValue, tagValue, ciphertextValue] = value.split('.');
+ if (version !== 'v1' || !ivValue || !tagValue || !ciphertextValue) {
+ throw new Error('Unsupported encrypted value format');
+ }
+ const decipher = createDecipheriv(
+ 'aes-256-gcm',
+ this.config.fieldEncryptionKey,
+ Buffer.from(ivValue, 'base64url'),
+ );
+ decipher.setAuthTag(Buffer.from(tagValue, 'base64url'));
+ return Buffer.concat([
+ decipher.update(Buffer.from(ciphertextValue, 'base64url')),
+ decipher.final(),
+ ]).toString('utf8');
+ }
+
+ lookupHash(domain: string, value: string): string {
+ return createHmac('sha256', this.config.fieldEncryptionKey)
+ .update(`${domain}\0${value}`)
+ .digest('hex');
+ }
+
+ sessionTokenHash(token: string): string {
+ return createHmac('sha256', this.config.sessionTokenPepper).update(token).digest('hex');
+ }
+
+ auditIpHash(ip: string): string {
+ return createHmac('sha256', this.config.auditIpHashKey).update(ip).digest('hex');
+ }
+}
diff --git a/apps/api/src/service-applications/admin-service-applications.controller.ts b/apps/api/src/service-applications/admin-service-applications.controller.ts
new file mode 100644
index 0000000..c31d3f5
--- /dev/null
+++ b/apps/api/src/service-applications/admin-service-applications.controller.ts
@@ -0,0 +1,49 @@
+import { Controller, Get, Inject, Param, Query, Req, ValidationPipe } from '@nestjs/common';
+
+import {
+ PERMISSION_CODES,
+ type PaginatedServiceApplicationsResponse,
+ type ServiceApplicationDetailResponse,
+} from '@lehuo/contracts';
+
+import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
+import { CurrentAuth } from '../common/decorators/current-auth.decorator';
+import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
+import type { AuthContext, RequestContext } from '../common/request-context';
+import { SessionChannel } from '../generated/prisma/enums';
+import { AdminServiceApplicationQueryDto } from './dto/service-application-query.dto';
+import { ServiceApplicationsService } from './service-applications.service';
+
+@Controller('api/v1/admin/service-applications')
+@RequireSessionChannel(SessionChannel.ADMIN)
+@RequirePermissions(PERMISSION_CODES.APPLICATIONS_READ)
+export class AdminServiceApplicationsController {
+ constructor(
+ @Inject(ServiceApplicationsService)
+ private readonly applications: ServiceApplicationsService,
+ ) {}
+
+ @Get()
+ list(
+ @Query(
+ new ValidationPipe({
+ transform: true,
+ whitelist: true,
+ forbidNonWhitelisted: true,
+ expectedType: AdminServiceApplicationQueryDto,
+ }),
+ )
+ query: AdminServiceApplicationQueryDto,
+ ): Promise