feat: complete V1 phases 0-7

This commit is contained in:
2026-08-12 14:35:12 +08:00
commit c3dd9be674
311 changed files with 38750 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.github
**/node_modules
**/dist
coverage
.env
*.log
docs
*.png
+43
View File
@@ -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=
+82
View File
@@ -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
+34
View File
@@ -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
+16
View File
@@ -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/
+9
View File
@@ -0,0 +1,9 @@
**/dist
**/node_modules
**/miniprogram_npm
coverage
pnpm-lock.yaml
docs
乐活公益小程序_Codex开发总文档_V1.0.md
docs/ui_reference.png
*.png
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"printWidth": 100
}
+23
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+37
View File
@@ -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`。
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000
+5
View File
@@ -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 配置配套。
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="乐活公益暖心服务平台管理后台" />
<title>乐活公益管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+25
View File
@@ -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"
}
}
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ApiClientError } from './api/api-client';
import { ADMIN_APP_TITLE } from './app-info';
import { validateLoginForm } from './login-form';
import { useAuthStore } from './stores/auth';
import ServiceApplicationsView from './views/ServiceApplicationsView.vue';
import BeneficiariesView from './views/BeneficiariesView.vue';
import ServiceCasesView from './views/ServiceCasesView.vue';
import FollowUpsView from './views/FollowUpsView.vue';
import VolunteersView from './views/VolunteersView.vue';
import ProjectsView from './views/ProjectsView.vue';
import ActivitiesView from './views/ActivitiesView.vue';
import ContentView from './views/ContentView.vue';
import NotificationDeliveriesView from './views/NotificationDeliveriesView.vue';
import StatisticsView from './views/StatisticsView.vue';
const auth = useAuthStore();
const form = reactive({ username: '', password: '' });
const errors = reactive<{ username?: string; password?: string }>({});
const submitting = ref(false);
const showPassword = ref(false);
const message = ref('');
const activeView = ref<
| 'applications'
| 'beneficiaries'
| 'cases'
| 'follow-ups'
| 'volunteers'
| 'projects'
| 'activities'
| 'content'
| 'notifications'
| 'statistics'
>('applications');
onMounted(() => auth.restore());
async function submit(): Promise<void> {
message.value = '';
errors.username = undefined;
errors.password = undefined;
const validation = validateLoginForm(form.username, form.password);
Object.assign(errors, validation);
if (Object.keys(validation).length) return;
submitting.value = true;
try {
await auth.login(form.username.trim().toLowerCase(), form.password);
form.password = '';
} catch (error) {
message.value =
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '登录失败,请稍后重试';
} finally {
submitting.value = false;
}
}
async function logout(): Promise<void> {
submitting.value = true;
message.value = '';
try {
await auth.logout();
} catch (error) {
message.value = error instanceof Error ? error.message : '退出失败,请稍后重试';
} finally {
submitting.value = false;
}
}
</script>
<template>
<main v-if="auth.checking" class="app-shell app-shell--centered" aria-live="polite">
<section class="welcome-card">
<p class="eyebrow">乐活公益 · 暖心服务平台</p>
<h1>{{ ADMIN_APP_TITLE }}</h1>
<p>正在确认登录状态,请稍候…</p>
</section>
</main>
<main v-else-if="!auth.authenticated" class="app-shell app-shell--centered">
<section class="login-card" aria-labelledby="app-title">
<p class="eyebrow">乐活公益 · 暖心服务平台</p>
<h1 id="app-title">{{ ADMIN_APP_TITLE }}</h1>
<p class="intro">仅限经授权的工作人员使用。登录操作将记录安全审计。</p>
<form novalidate @submit.prevent="submit">
<label for="username">登录账号</label>
<input
id="username"
v-model="form.username"
name="username"
autocomplete="username"
:aria-invalid="Boolean(errors.username)"
:aria-describedby="errors.username ? 'username-error' : undefined"
/>
<p v-if="errors.username" id="username-error" class="field-error" role="alert">
{{ errors.username }}
</p>
<label for="password">登录密码</label>
<div class="password-field">
<input
id="password"
v-model="form.password"
name="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
:aria-invalid="Boolean(errors.password)"
:aria-describedby="errors.password ? 'password-error' : undefined"
/>
<button
type="button"
class="password-toggle"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
{{ showPassword ? '隐藏' : '显示' }}
</button>
</div>
<p v-if="errors.password" id="password-error" class="field-error" role="alert">
{{ errors.password }}
</p>
<p v-if="message" class="form-message" role="alert">{{ message }}</p>
<button class="primary-button" type="submit" :disabled="submitting">
{{ submitting ? '正在安全登录…' : '登录管理后台' }}
</button>
</form>
</section>
</main>
<div v-else class="admin-layout">
<aside class="admin-sidebar" aria-label="管理后台导航">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true"></span>
<div><strong>乐活公益</strong><small>管理平台</small></div>
</div>
<nav>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'applications' }]"
type="button"
:aria-current="activeView === 'applications' ? 'page' : undefined"
@click="activeView = 'applications'"
>
服务申请
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'statistics' }]"
type="button"
:aria-current="activeView === 'statistics' ? 'page' : undefined"
@click="activeView = 'statistics'"
>
服务统计
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'content' }]"
type="button"
:aria-current="activeView === 'content' ? 'page' : undefined"
@click="activeView = 'content'"
>
内容发布
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'notifications' }]"
type="button"
:aria-current="activeView === 'notifications' ? 'page' : undefined"
@click="activeView = 'notifications'"
>
通知投递
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'beneficiaries' }]"
type="button"
:aria-current="activeView === 'beneficiaries' ? 'page' : undefined"
@click="activeView = 'beneficiaries'"
>
服务对象
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'cases' }]"
type="button"
:aria-current="activeView === 'cases' ? 'page' : undefined"
@click="activeView = 'cases'"
>
服务工单
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'follow-ups' }]"
type="button"
:aria-current="activeView === 'follow-ups' ? 'page' : undefined"
@click="activeView = 'follow-ups'"
>
回访工作台
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'volunteers' }]"
type="button"
:aria-current="activeView === 'volunteers' ? 'page' : undefined"
@click="activeView = 'volunteers'"
>
志愿者审核
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'projects' }]"
type="button"
:aria-current="activeView === 'projects' ? 'page' : undefined"
@click="activeView = 'projects'"
>
公益项目
</button>
<button
:class="['nav-item', { 'nav-item--active': activeView === 'activities' }]"
type="button"
:aria-current="activeView === 'activities' ? 'page' : undefined"
@click="activeView = 'activities'"
>
活动与报名
</button>
</nav>
<p class="phase-note">Phase 6:内容审核、站内消息与微信订阅通知</p>
</aside>
<div class="admin-main">
<header class="admin-topbar">
<div>
<strong>{{ auth.user?.displayName ?? '工作人员' }}</strong>
<span>已安全登录</span>
</div>
<button class="text-button" type="button" :disabled="submitting" @click="logout">
{{ submitting ? '正在退出…' : '安全退出' }}
</button>
</header>
<p v-if="message" class="global-message" role="alert">{{ message }}</p>
<ServiceApplicationsView v-if="activeView === 'applications'" />
<BeneficiariesView v-else-if="activeView === 'beneficiaries'" />
<ServiceCasesView v-else-if="activeView === 'cases'" />
<FollowUpsView v-else-if="activeView === 'follow-ups'" />
<VolunteersView v-else-if="activeView === 'volunteers'" />
<ProjectsView v-else-if="activeView === 'projects'" />
<ActivitiesView v-else-if="activeView === 'activities'" />
<ContentView v-else-if="activeView === 'content'" />
<NotificationDeliveriesView v-else-if="activeView === 'notifications'" />
<StatisticsView v-else />
</div>
</div>
</template>
+42
View File
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<T> | 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;
}
+172
View File
@@ -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, string | number | boolean | undefined>): 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<AcceptServiceApplicationResponse> {
return apiRequest(`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}/accept`, {
method: 'POST',
body: JSON.stringify(input),
});
}
export function fetchBeneficiaryTags(): Promise<BeneficiaryTagOption[]> {
return apiRequest('/api/v1/admin/beneficiaries/tags');
}
export function fetchBeneficiaries(query: {
page: number;
pageSize: number;
primaryCategory?: ServiceCategory;
status?: string;
keyCareFlag?: boolean;
}): Promise<PaginatedBeneficiariesResponse> {
return apiRequest(`/api/v1/admin/beneficiaries?${queryString(query)}`);
}
export function fetchBeneficiary(publicId: string): Promise<BeneficiaryDetailResponse> {
return apiRequest(`/api/v1/admin/beneficiaries/${encodeURIComponent(publicId)}`);
}
export function updateBeneficiary(
publicId: string,
input: UpdateBeneficiaryRequest,
): Promise<BeneficiaryDetailResponse> {
return apiRequest(`/api/v1/admin/beneficiaries/${encodeURIComponent(publicId)}`, {
method: 'PUT',
body: JSON.stringify(input),
});
}
export function fetchAssigneeOptions(): Promise<AssigneeOption[]> {
return apiRequest('/api/v1/admin/service-cases/assignee-options');
}
export function fetchServiceCases(query: {
page: number;
pageSize: number;
status?: ServiceCaseStatus;
category?: ServiceCategory;
caseNo?: string;
}): Promise<PaginatedServiceCasesResponse> {
return apiRequest(`/api/v1/admin/service-cases?${queryString(query)}`);
}
export function fetchServiceCase(publicId: string): Promise<ServiceCaseDetailResponse> {
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}`);
}
export function startServiceCase(publicId: string): Promise<ServiceCaseDetailResponse> {
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/start`, {
method: 'POST',
body: '{}',
});
}
export function completeServiceCase(
publicId: string,
input: CompleteServiceCaseRequest,
): Promise<CompleteServiceCaseResponse> {
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/complete`, {
method: 'POST',
body: JSON.stringify(input),
});
}
export function replaceCaseAssignees(
publicId: string,
input: ReplaceCaseAssigneesRequest,
): Promise<ServiceCaseDetailResponse> {
return apiRequest(`/api/v1/admin/service-cases/${encodeURIComponent(publicId)}/assignees`, {
method: 'PUT',
body: JSON.stringify(input),
});
}
export function createServiceRecord(
publicId: string,
input: CreateServiceRecordRequest,
): Promise<ServiceRecordResponse> {
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<ServiceRecordResponse> {
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<CreateUploadIntentResponse> {
return apiRequest(
`/api/v1/service-cases/${encodeURIComponent(casePublicId)}/files/upload-intents`,
{ method: 'POST', body: JSON.stringify(input) },
);
}
export function confirmUpload(publicId: string): Promise<ConfirmUploadResponse> {
return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/confirm`, {
method: 'POST',
body: '{}',
});
}
export function fetchDownloadUrl(publicId: string): Promise<DownloadUrlResponse> {
return apiRequest(`/api/v1/files/${encodeURIComponent(publicId)}/download-url`);
}
@@ -0,0 +1,37 @@
import type {
ContentPostDetailResponse,
CreateContentPostRequest,
PaginatedContentPostsResponse,
PaginatedNotificationDeliveriesResponse,
TransitionContentPostRequest,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
const query = (v: Record<string, string | number | undefined>) => {
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<PaginatedContentPostsResponse>(`/api/v1/admin/content?${query(v)}`);
export const createContent = (input: CreateContentPostRequest) =>
apiRequest<ContentPostDetailResponse>('/api/v1/admin/content', {
method: 'POST',
body: JSON.stringify(input),
});
export const transitionContent = (id: string, input: TransitionContentPostRequest) =>
apiRequest<ContentPostDetailResponse>(`/api/v1/admin/content/${id}/transition`, {
method: 'POST',
body: JSON.stringify(input),
});
export const fetchDeliveries = (v: { page: number; pageSize: number; status?: string }) =>
apiRequest<PaginatedNotificationDeliveriesResponse>(
`/api/v1/admin/notification-deliveries?${query(v)}`,
);
export const retryDelivery = (id: string) =>
apiRequest<{ queued: true }>(`/api/v1/admin/notification-deliveries/${id}/retry`, {
method: 'POST',
});
+55
View File
@@ -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, string | number | boolean | undefined>): 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<FollowUpWorkbenchSummary> {
return apiRequest('/api/v1/admin/follow-ups/summary');
}
export function fetchFollowUps(query: {
page: number;
pageSize: number;
status?: FollowUpStatus;
followUpType?: FollowUpType;
overdue?: boolean;
}): Promise<PaginatedFollowUpsResponse> {
return apiRequest(`/api/v1/admin/follow-ups?${queryString(query)}`);
}
export function fetchFollowUp(publicId: string): Promise<FollowUpDetailResponse> {
return apiRequest(`/api/v1/admin/follow-ups/${encodeURIComponent(publicId)}`);
}
export function createFollowUp(input: CreateFollowUpRequest): Promise<FollowUpMutationResponse> {
return apiRequest('/api/v1/admin/follow-ups', {
method: 'POST',
body: JSON.stringify(input),
});
}
export function completeFollowUp(
publicId: string,
input: CompleteFollowUpRequest,
): Promise<FollowUpMutationResponse> {
return apiRequest(`/api/v1/admin/follow-ups/${encodeURIComponent(publicId)}/complete`, {
method: 'POST',
body: JSON.stringify(input),
});
}
+89
View File
@@ -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<string, string | number | undefined>) => {
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<PaginatedVolunteersResponse>(`/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<PaginatedProjectsResponse>(`/api/v1/admin/projects?${query(values)}`);
export const fetchProjectManagerOptions = () =>
apiRequest<AssigneeOption[]>('/api/v1/admin/project-manager-options');
export const createProject = (input: CreateProjectRequest) =>
apiRequest<ProjectDetailResponse>('/api/v1/admin/projects', {
method: 'POST',
body: JSON.stringify(input),
});
export const transitionProject = (publicId: string, input: TransitionProjectRequest) =>
apiRequest<ProjectDetailResponse>(`/api/v1/admin/projects/${publicId}/transition`, {
method: 'POST',
body: JSON.stringify(input),
});
export const fetchActivities = (values: { page: number; pageSize: number; status?: string }) =>
apiRequest<PaginatedActivitiesResponse>(`/api/v1/admin/activities?${query(values)}`);
export const createActivity = (input: CreateActivityRequest) =>
apiRequest<ActivityDetailResponse>('/api/v1/admin/activities', {
method: 'POST',
body: JSON.stringify(input),
});
export const transitionActivity = (publicId: string, input: TransitionActivityRequest) =>
apiRequest<ActivityDetailResponse>(`/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<PaginatedActivityRegistrationsResponse>(
`/api/v1/admin/activity-registrations?${query(values)}`,
);
export const reviewRegistration = (publicId: string, input: ReviewActivityRegistrationRequest) =>
apiRequest<ActivityRegistrationResponse>(
`/api/v1/admin/activity-registrations/${publicId}/review`,
{ method: 'POST', body: JSON.stringify(input) },
);
export const checkInRegistration = (publicId: string, input: CheckInActivityRegistrationRequest) =>
apiRequest<ActivityRegistrationResponse>(
`/api/v1/admin/activity-registrations/${publicId}/check-in`,
{ method: 'POST', body: JSON.stringify(input) },
);
export const recordServiceMinutes = (publicId: string, input: RecordServiceMinutesRequest) =>
apiRequest<ActivityRegistrationResponse>(
`/api/v1/admin/activity-registrations/${publicId}/service-minutes`,
{ method: 'POST', body: JSON.stringify(input) },
);
@@ -0,0 +1,25 @@
import type {
AdminServiceApplicationQuery,
PaginatedServiceApplicationsResponse,
ServiceApplicationDetailResponse,
} from '@lehuo/contracts';
import { apiRequest } from './api-client';
export function fetchAdminServiceApplications(
query: AdminServiceApplicationQuery,
): Promise<PaginatedServiceApplicationsResponse> {
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<ServiceApplicationDetailResponse> {
return apiRequest(`/api/v1/admin/service-applications/${encodeURIComponent(publicId)}`);
}
+8
View File
@@ -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<StatisticsOverview>(`/api/v1/admin/statistics/overview?${p.toString()}`);
};
+9
View File
@@ -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('乐活公益');
});
});
+2
View File
@@ -0,0 +1,2 @@
export const ADMIN_APP_TITLE = '乐活公益管理后台';
export const PHASE_ZERO_MESSAGE = '工程基线已就绪,业务功能将在后续阶段实现。';
@@ -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');
});
});
+24
View File
@@ -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';
}
+16
View File
@@ -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 个字符',
});
});
});
+15
View File
@@ -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;
}
+7
View File
@@ -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');
+39
View File
@@ -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<AuthUser | null>(null);
const checking = ref(true);
const authenticated = computed(() => user.value !== null);
async function restore(): Promise<void> {
checking.value = true;
try {
const result = await apiRequest<CurrentUserResponse>('/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<void> {
const result = await apiRequest<AdminLoginResponse>('/api/v1/admin/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
user.value = result.user;
}
async function logout(): Promise<void> {
await apiRequest('/api/v1/admin/auth/logout', { method: 'POST', body: '{}' });
user.value = null;
}
return { user, checking, authenticated, restore, login, logout };
});
+803
View File
@@ -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;
}
}
+370
View File
@@ -0,0 +1,370 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
ACTIVITY_STATUSES,
ACTIVITY_STATUS_LABELS,
type ActivityListItem,
type ActivityRegistrationItem,
type ActivityStatus,
type ProjectListItem,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
checkInRegistration,
createActivity,
fetchActivities,
fetchProjects,
fetchRegistrations,
recordServiceMinutes,
reviewRegistration,
transitionActivity,
} from '../api/participation';
const activities = ref<ActivityListItem[]>([]),
projects = ref<ProjectListItem[]>([]),
registrations = ref<ActivityRegistrationItem[]>([]);
const loading = ref(true),
busy = ref(false),
showCreate = ref(false),
message = ref('');
const page = ref(1),
total = ref(0),
pageSize = 20,
totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const filters = reactive<{ status: ActivityStatus | '' }>({ status: '' });
const form = reactive({
projectPublicId: '',
title: '',
summary: '',
content: '',
startAt: '',
endAt: '',
registrationDeadline: '',
locationText: '',
capacity: undefined as number | undefined,
});
const errorText = (error: unknown) =>
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
async function load(target = 1) {
loading.value = true;
message.value = '';
try {
const [list, regs] = await Promise.all([
fetchActivities({
page: target,
pageSize,
...(filters.status ? { status: filters.status } : {}),
}),
fetchRegistrations({ page: 1, pageSize: 100 }),
]);
activities.value = list.items;
total.value = list.total;
page.value = list.page;
registrations.value = regs.items;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function save() {
if (!form.startAt || !form.endAt || !form.registrationDeadline) {
message.value = '请完整填写活动与报名时间。';
return;
}
busy.value = true;
try {
await createActivity({
clientRequestId: crypto.randomUUID(),
...(form.projectPublicId ? { projectPublicId: form.projectPublicId } : {}),
title: form.title,
summary: form.summary,
content: form.content,
startAt: new Date(form.startAt).toISOString(),
endAt: new Date(form.endAt).toISOString(),
registrationDeadline: new Date(form.registrationDeadline).toISOString(),
locationText: form.locationText,
...(form.capacity ? { capacity: form.capacity } : {}),
});
showCreate.value = false;
Object.assign(form, {
projectPublicId: '',
title: '',
summary: '',
content: '',
startAt: '',
endAt: '',
registrationDeadline: '',
locationText: '',
capacity: undefined,
});
message.value = '活动草稿已创建。';
await load(1);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
function nextStatus(status: ActivityStatus): Exclude<ActivityStatus, 'DRAFT'> | null {
return (
(
{
DRAFT: 'PUBLISHED',
PUBLISHED: 'REGISTRATION_CLOSED',
REGISTRATION_CLOSED: 'IN_PROGRESS',
IN_PROGRESS: 'COMPLETED',
} as Partial<Record<ActivityStatus, Exclude<ActivityStatus, 'DRAFT'>>>
)[status] ?? null
);
}
async function advance(item: ActivityListItem) {
const targetStatus = nextStatus(item.status);
if (
!targetStatus ||
!window.confirm(`确认将活动推进至“${ACTIVITY_STATUS_LABELS[targetStatus]}”?`)
)
return;
busy.value = true;
try {
await transitionActivity(item.publicId, { clientRequestId: crypto.randomUUID(), targetStatus });
message.value = '活动状态已更新并记录审计。';
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function review(item: ActivityRegistrationItem, targetStatus: 'APPROVED' | 'REJECTED') {
busy.value = true;
try {
await reviewRegistration(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
note: targetStatus === 'APPROVED' ? '报名审核通过' : '报名不符合本次活动要求',
});
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function checkIn(item: ActivityRegistrationItem, targetStatus: 'CHECKED_IN' | 'ABSENT') {
busy.value = true;
try {
await checkInRegistration(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
});
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function minutes(item: ActivityRegistrationItem) {
const value = Number(window.prompt('请输入核定服务分钟数', String(item.serviceMinutes ?? 60)));
if (!Number.isInteger(value) || value <= 0) return;
busy.value = true;
try {
await recordServiceMinutes(item.publicId, {
clientRequestId: crypto.randomUUID(),
serviceMinutes: value,
});
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
onMounted(async () => {
await Promise.all([
load(),
fetchProjects({ page: 1, pageSize: 100 }).then((result) => {
projects.value = result.items.filter((item) => item.status !== 'COMPLETED');
}),
]);
});
</script>
<template>
<main class="content-area" tabindex="-1">
<header class="page-header">
<div>
<h1>公益活动与报名</h1>
<p>发布活动、控制报名容量,并记录审核、签到和服务时长事实。</p>
</div>
<button class="primary-button compact-button" @click="showCreate = !showCreate">
{{ showCreate ? '取消新建' : '新建活动' }}
</button>
</header>
<form v-if="showCreate" class="panel form-grid" @submit.prevent="save">
<label
>所属项目<select v-model="form.projectPublicId">
<option value="">独立活动</option>
<option v-for="project in projects" :key="project.publicId" :value="project.publicId">
{{ project.name }}
</option>
</select></label
><label
>活动标题<input v-model.trim="form.title" required minlength="2" maxlength="200" /></label
><label
>报名截止<input v-model="form.registrationDeadline" type="datetime-local" required /></label
><label>人数上限<input v-model.number="form.capacity" type="number" min="1" /></label
><label>开始时间<input v-model="form.startAt" type="datetime-local" required /></label
><label>结束时间<input v-model="form.endAt" type="datetime-local" required /></label
><label class="form-span"
>活动地点<input
v-model.trim="form.locationText"
required
minlength="2"
maxlength="300" /></label
><label class="form-span"
>活动摘要<textarea
v-model.trim="form.summary"
required
minlength="10"
maxlength="500"
></textarea></label
><label class="form-span"
>活动内容<textarea
v-model.trim="form.content"
required
minlength="20"
maxlength="20000"
></textarea>
</label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">
{{ busy ? '保存中…' : '保存草稿' }}
</button>
</div>
</form>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label for="activity-status">活动状态</label
><select id="activity-status" v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in ACTIVITY_STATUSES" :key="status" :value="status">
{{ ACTIVITY_STATUS_LABELS[status] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="loading" class="panel">正在加载活动与报名…</section>
<template v-else
><section class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>活动</th>
<th>时间/地点</th>
<th>报名</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in activities" :key="item.publicId">
<td>
<strong>{{ item.title }}</strong
><br />{{ item.projectName ?? '独立活动' }}
</td>
<td>
{{ new Date(item.startAt).toLocaleString('zh-CN') }}<br />{{ item.locationText }}
</td>
<td>{{ item.activeRegistrationCount }} / {{ item.capacity ?? '不限' }}</td>
<td>{{ item.statusLabel }}</td>
<td>
<button
v-if="nextStatus(item.status)"
class="link-button"
:disabled="busy"
@click="advance(item)"
>
推进至{{ ACTIVITY_STATUS_LABELS[nextStatus(item.status)!] }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
><span>第 {{ page }} / {{ totalPages }} 页</span
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
</div>
</section>
<section class="panel">
<h2>报名与现场记录</h2>
<p v-if="!registrations.length">暂无报名记录。</p>
<div v-else class="table-scroll">
<table>
<thead>
<tr>
<th>活动</th>
<th>志愿者</th>
<th>报名/签到</th>
<th>服务时长</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in registrations" :key="item.publicId">
<td>{{ item.activityTitle }}</td>
<td>{{ item.volunteerName }}<br />{{ item.mobileMasked }}</td>
<td>{{ item.statusLabel }} / {{ item.checkInStatusLabel }}</td>
<td>
{{ item.serviceMinutes === null ? '待核定' : `${item.serviceMinutes} 分钟` }}
</td>
<td>
<div class="inline-actions">
<button
v-if="item.status === 'PENDING'"
class="link-button"
@click="review(item, 'APPROVED')"
>
通过</button
><button
v-if="item.status === 'PENDING'"
class="link-button link-button--danger"
@click="review(item, 'REJECTED')"
>
拒绝</button
><button
v-if="item.status === 'APPROVED' && item.checkInStatus === 'NOT_CHECKED_IN'"
class="link-button"
@click="checkIn(item, 'CHECKED_IN')"
>
签到</button
><button
v-if="item.status === 'APPROVED' && item.checkInStatus === 'NOT_CHECKED_IN'"
class="link-button"
@click="checkIn(item, 'ABSENT')"
>
缺席</button
><button
v-if="item.checkInStatus === 'CHECKED_IN'"
class="link-button"
@click="minutes(item)"
>
核定时长
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section></template
>
</main>
</template>
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
BENEFICIARY_STATUSES,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
type BeneficiaryDetail,
type BeneficiaryListItem,
type BeneficiaryStatus,
type ServiceCategory,
type UpdateBeneficiaryRequest,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import { fetchBeneficiaries, fetchBeneficiary, updateBeneficiary } from '../api/case-management';
import { formatChinaDateTime } from '../application-format';
const items = ref<BeneficiaryListItem[]>([]);
const selected = ref<BeneficiaryDetail | null>(null);
const loading = ref(true);
const saving = ref(false);
const message = ref('');
const page = ref(1);
const total = ref(0);
const pageSize = 20;
const filters = reactive<{
primaryCategory: ServiceCategory | '';
status: BeneficiaryStatus | '';
keyCare: '' | 'true' | 'false';
}>({
primaryCategory: '',
status: '',
keyCare: '',
});
const form = reactive<UpdateBeneficiaryRequest>({
displayName: '',
regionCode: '',
keyCareFlag: false,
isMinor: false,
contacts: [],
tagCodes: [],
status: 'ACTIVE',
});
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
function showError(error: unknown): void {
message.value =
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
}
async function load(targetPage = 1): Promise<void> {
loading.value = true;
message.value = '';
selected.value = null;
try {
const result = await fetchBeneficiaries({
page: targetPage,
pageSize,
...(filters.primaryCategory ? { primaryCategory: filters.primaryCategory } : {}),
...(filters.status ? { status: filters.status } : {}),
...(filters.keyCare ? { keyCareFlag: filters.keyCare === 'true' } : {}),
});
items.value = result.items;
total.value = result.total;
page.value = result.page;
} catch (error) {
showError(error);
} finally {
loading.value = false;
}
}
async function open(publicId: string): Promise<void> {
loading.value = true;
message.value = '';
try {
const result = await fetchBeneficiary(publicId);
selected.value = result.beneficiary;
Object.assign(form, {
displayName: result.beneficiary.displayName,
gender: result.beneficiary.gender ?? undefined,
birthYear: result.beneficiary.birthYear ?? undefined,
mobile: result.beneficiary.mobile ?? undefined,
regionCode: result.beneficiary.regionCode,
addressDetail: result.beneficiary.addressDetail ?? undefined,
keyCareFlag: result.beneficiary.keyCareFlag,
isMinor: result.beneficiary.isMinor,
notes: result.beneficiary.notes ?? undefined,
contacts: result.beneficiary.contacts.map(
({ name, relation, mobile, isGuardian, isEmergencyContact }) => ({
name,
relation,
mobile,
isGuardian,
isEmergencyContact,
}),
),
tagCodes: result.beneficiary.tags.map((tag) => tag.code),
status: result.beneficiary.status,
...(result.beneficiary.isMinor ? { guardianConsentConfirmed: true as const } : {}),
});
} catch (error) {
showError(error);
} finally {
loading.value = false;
}
}
async function save(): Promise<void> {
if (!selected.value) return;
saving.value = true;
message.value = '';
try {
selected.value = (await updateBeneficiary(selected.value.publicId, form)).beneficiary;
message.value = '档案已安全保存并记录审计。';
} catch (error) {
showError(error);
} finally {
saving.value = false;
}
}
</script>
<template>
<main class="content-area" tabindex="-1">
<template v-if="selected">
<header class="page-header">
<div>
<button class="back-button" type="button" @click="selected = null">
← 返回服务对象列表
</button>
<h1>服务对象档案</h1>
<p>查看和维护提供服务所需的最少信息。</p>
</div>
<span class="status-tag status-tag--info">{{ selected.serviceCaseCount }} 个工单</span>
</header>
<div class="audit-notice">档案含敏感信息,本次查看和修改均会记录安全审计。</div>
<form class="panel form-grid" @submit.prevent="save">
<label>姓名<input v-model.trim="form.displayName" required maxlength="50" /></label>
<label
>手机号<input v-model.trim="form.mobile" inputmode="numeric" pattern="1[3-9][0-9]{9}"
/></label>
<label>所在地区<input v-model.trim="form.regionCode" required maxlength="100" /></label>
<label
>出生年份<input v-model.number="form.birthYear" type="number" min="1900" max="2200"
/></label>
<label
>档案状态<select v-model="form.status">
<option v-for="status in BENEFICIARY_STATUSES" :key="status" :value="status">
{{ status === 'ACTIVE' ? '正常' : status === 'INACTIVE' ? '暂停' : '归档' }}
</option>
</select></label
>
<label>详细地址<input v-model.trim="form.addressDetail" maxlength="300" /></label>
<label class="form-span"
><input v-model="form.keyCareFlag" type="checkbox" /> 重点关怀对象</label
>
<label class="form-span"
>备注<textarea v-model.trim="form.notes" maxlength="1000"></textarea>
</label>
<div class="form-span contact-summary">
<strong>联系人</strong>
<p v-if="!form.contacts.length">暂无联系人</p>
<p v-for="(contact, index) in form.contacts" :key="index">
{{ contact.name }}({{ contact.relation }}) · {{ contact.mobile
}}<span v-if="contact.isGuardian"> · 监护人</span>
</p>
</div>
<p v-if="message" class="global-message form-span" role="status">{{ message }}</p>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="saving">
{{ saving ? '保存中…' : '保存档案' }}
</button>
</div>
</form>
</template>
<template v-else>
<header class="page-header">
<div>
<h1>服务对象</h1>
<p>按服务类别、状态和重点关怀标记查找档案。</p>
</div>
<span class="result-count">共 {{ total }} 人</span>
</header>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label>服务类型</label
><select v-model="filters.primaryCategory">
<option value="">全部类型</option>
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
{{ SERVICE_CATEGORY_LABELS[category] }}
</option>
</select>
</div>
<div class="filter-field">
<label>档案状态</label
><select v-model="filters.status">
<option value="">全部状态</option>
<option value="ACTIVE">正常</option>
<option value="INACTIVE">暂停</option>
<option value="ARCHIVED">归档</option>
</select>
</div>
<div class="filter-field">
<label>重点关怀</label
><select v-model="filters.keyCare">
<option value="">全部</option>
<option value="true">是</option>
<option value="false">否</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<section v-if="loading" class="panel" aria-live="polite">
<div v-for="n in 6" :key="n" class="table-skeleton"></div>
</section>
<section v-else-if="message" class="empty-panel" role="alert">
<h2>服务对象加载失败</h2>
<p>{{ message }}</p>
<button class="secondary-button compact-button" @click="load(page)">重试</button>
</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无符合条件的服务对象</h2>
<p>档案将在申请通过评估后建立。</p>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>姓名</th>
<th>手机号</th>
<th>地区</th>
<th>服务类型</th>
<th>标签</th>
<th>建立时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>
{{ item.displayName }}
<span v-if="item.keyCareFlag" class="status-tag status-tag--warning">重点</span>
</td>
<td>{{ item.mobileMasked ?? '未留存' }}</td>
<td>{{ item.regionCode }}</td>
<td>{{ SERVICE_CATEGORY_LABELS[item.primaryCategory] }}</td>
<td>{{ item.tags.map((tag) => tag.name).join('、') || '—' }}</td>
<td>{{ formatChinaDateTime(item.createdAt) }}</td>
<td><button class="link-button" @click="open(item.publicId)">查看</button></td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
><span>第 {{ page }} / {{ totalPages }} 页</span
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
</div>
</section>
</template>
</main>
</template>
+242
View File
@@ -0,0 +1,242 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
CONTENT_POST_STATUSES,
CONTENT_POST_STATUS_LABELS,
CONTENT_POST_TYPES,
CONTENT_POST_TYPE_LABELS,
type ContentPostListItem,
type ContentPostStatus,
type ContentPostType,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import { createContent, fetchContent, transitionContent } from '../api/content-notifications';
const items = ref<ContentPostListItem[]>([]),
loading = ref(true),
busy = ref(false),
showCreate = ref(false),
message = ref(''),
page = ref(1),
total = ref(0),
pageSize = 20,
totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const filters = reactive<{ status: ContentPostStatus | ''; type: ContentPostType | '' }>({
status: '',
type: '',
});
const form = reactive({
type: 'PUBLIC_WELFARE_NEWS' as ContentPostType,
title: '',
summary: '',
content: '',
});
const errorText = (e: unknown) =>
e instanceof ApiClientError
? `${e.message}${e.requestId ? `(编号:${e.requestId})` : ''}`
: '操作失败,请稍后重试';
async function load(target = 1) {
loading.value = true;
try {
const r = await fetchContent({
page: target,
pageSize,
...(filters.status ? { status: filters.status } : {}),
...(filters.type ? { type: filters.type } : {}),
});
items.value = r.items;
page.value = r.page;
total.value = r.total;
} catch (e) {
message.value = errorText(e);
} finally {
loading.value = false;
}
}
async function save() {
busy.value = true;
try {
await createContent({ clientRequestId: crypto.randomUUID(), ...form });
Object.assign(form, { title: '', summary: '', content: '' });
showCreate.value = false;
message.value = '内容草稿已创建,发布前必须提交审核。';
await load(1);
} catch (e) {
message.value = errorText(e);
} finally {
busy.value = false;
}
}
async function transition(item: ContentPostListItem) {
const targetStatus =
item.status === 'DRAFT'
? 'PENDING_REVIEW'
: item.status === 'PENDING_REVIEW'
? 'PUBLISHED'
: 'ARCHIVED';
const note = window.prompt(
targetStatus === 'PENDING_REVIEW'
? '请填写送审说明'
: targetStatus === 'PUBLISHED'
? '请填写审核结论'
: '请填写归档原因',
);
if (!note) return;
busy.value = true;
try {
await transitionContent(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
note,
...(targetStatus === 'PUBLISHED' && item.type === 'SERVICE_STORY'
? { privacyReviewed: true as const }
: {}),
});
message.value = '状态已更新并写入不可变审核历史。';
await load(page.value);
} catch (e) {
message.value = errorText(e);
} finally {
busy.value = false;
}
}
async function reject(item: ContentPostListItem) {
const note = window.prompt('请填写退回修改原因');
if (!note) return;
busy.value = true;
try {
await transitionContent(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus: 'DRAFT',
note,
});
await load(page.value);
} catch (e) {
message.value = errorText(e);
} finally {
busy.value = false;
}
}
onMounted(() => load());
</script>
<template>
<main class="content-area">
<header class="page-header">
<div>
<h1>内容发布与审核</h1>
<p>公益动态、机构介绍、服务故事、公告和常见问题统一审核后公开。</p>
</div>
<button class="primary-button compact-button" @click="showCreate = !showCreate">
{{ showCreate ? '取消新建' : '新建内容' }}
</button>
</header>
<form v-if="showCreate" class="panel form-grid" @submit.prevent="save">
<label
>内容类型<select v-model="form.type">
<option v-for="t in CONTENT_POST_TYPES" :key="t" :value="t">
{{ CONTENT_POST_TYPE_LABELS[t] }}
</option>
</select></label
><label class="form-span"
>标题<input v-model.trim="form.title" required minlength="2" maxlength="200" /></label
><label class="form-span"
>摘要<textarea
v-model.trim="form.summary"
required
minlength="10"
maxlength="500"
></textarea></label
><label class="form-span"
>正文(纯文本或 Markdown)<textarea
v-model.trim="form.content"
required
minlength="20"
maxlength="50000"
></textarea>
</label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">保存草稿</button>
</div>
</form>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label>类型</label
><select v-model="filters.type">
<option value="">全部</option>
<option v-for="t in CONTENT_POST_TYPES" :key="t" :value="t">
{{ CONTENT_POST_TYPE_LABELS[t] }}
</option>
</select>
</div>
<div class="filter-field">
<label>状态</label
><select v-model="filters.status">
<option value="">全部</option>
<option v-for="s in CONTENT_POST_STATUSES" :key="s" :value="s">
{{ CONTENT_POST_STATUS_LABELS[s] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="loading" class="panel">正在加载内容…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无内容</h2>
<p>可先创建公益动态或机构介绍草稿。</p>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>内容</th>
<th>类型</th>
<th>状态</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>
<strong>{{ item.title }}</strong
><br />{{ item.summary }}
</td>
<td>{{ CONTENT_POST_TYPE_LABELS[item.type] }}</td>
<td>{{ CONTENT_POST_STATUS_LABELS[item.status] }}</td>
<td>{{ new Date(item.updatedAt).toLocaleString('zh-CN') }}</td>
<td>
<button
v-if="item.status !== 'ARCHIVED'"
class="link-button"
:disabled="busy"
@click="transition(item)"
>
{{
item.status === 'DRAFT'
? '提交审核'
: item.status === 'PENDING_REVIEW'
? '审核发布'
: '归档'
}}</button
><button
v-if="item.status === 'PENDING_REVIEW'"
class="link-button"
:disabled="busy"
@click="reject(item)"
>
退回
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
><span>第 {{ page }} / {{ totalPages }} 页</span
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
</div>
</section>
</main>
</template>
+469
View File
@@ -0,0 +1,469 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
FOLLOW_UP_RESULT_CODES,
FOLLOW_UP_RESULT_LABELS,
FOLLOW_UP_STATUSES,
FOLLOW_UP_STATUS_LABELS,
FOLLOW_UP_TYPES,
FOLLOW_UP_TYPE_LABELS,
SERVICE_CATEGORY_LABELS,
type AssigneeOption,
type BeneficiaryListItem,
type FollowUpDetail,
type FollowUpListItem,
type FollowUpResultCode,
type FollowUpStatus,
type FollowUpType,
type FollowUpWorkbenchSummary,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import { fetchAssigneeOptions, fetchBeneficiaries } from '../api/case-management';
import {
completeFollowUp,
createFollowUp,
fetchFollowUp,
fetchFollowUps,
fetchFollowUpSummary,
} from '../api/follow-ups';
import { formatChinaDateTime } from '../application-format';
const items = ref<FollowUpListItem[]>([]);
const selected = ref<FollowUpDetail | null>(null);
const summary = ref<FollowUpWorkbenchSummary>({
dueToday: 0,
overdue: 0,
dueThisWeek: 0,
keyCarePending: 0,
});
const beneficiaries = ref<BeneficiaryListItem[]>([]);
const assignees = ref<AssigneeOption[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const showCreate = ref(false);
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const filters = reactive<{
status: FollowUpStatus | '';
followUpType: FollowUpType | '';
overdue: boolean;
}>({ status: 'PENDING', followUpType: '', overdue: false });
const createForm = reactive({
beneficiaryPublicId: '',
ownerStaffPublicId: '',
followUpType: 'MANUAL' as 'KEY_CARE' | 'MANUAL',
dueAt: '',
reason: '',
});
const completeForm = reactive({
resultCode: 'SATISFIED' as FollowUpResultCode,
resultNote: '',
continuationConsentConfirmed: false,
continuationDescription: '',
});
function errorText(error: unknown): string {
return error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
}
async function load(targetPage = 1): Promise<void> {
loading.value = true;
message.value = '';
try {
const [list, counts] = await Promise.all([
fetchFollowUps({
page: targetPage,
pageSize,
...(filters.status ? { status: filters.status } : {}),
...(filters.followUpType ? { followUpType: filters.followUpType } : {}),
...(filters.overdue ? { overdue: true } : {}),
}),
fetchFollowUpSummary(),
]);
items.value = list.items;
total.value = list.total;
page.value = list.page;
summary.value = counts;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function loadOptions(): Promise<void> {
try {
const [beneficiaryResult, assigneeResult] = await Promise.all([
fetchBeneficiaries({ page: 1, pageSize: 100, status: 'ACTIVE' }),
fetchAssigneeOptions(),
]);
beneficiaries.value = beneficiaryResult.items;
assignees.value = assigneeResult.filter((item) => item.type === 'STAFF');
} catch (error) {
message.value = errorText(error);
}
}
async function open(publicId: string): Promise<void> {
loading.value = true;
message.value = '';
try {
selected.value = (await fetchFollowUp(publicId)).followUp;
Object.assign(completeForm, {
resultCode: 'SATISFIED',
resultNote: '',
continuationConsentConfirmed: false,
continuationDescription: '',
});
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function saveCreate(): Promise<void> {
if (
!createForm.beneficiaryPublicId ||
!createForm.ownerStaffPublicId ||
!createForm.dueAt ||
createForm.reason.trim().length < 2
) {
message.value = '请完整选择服务对象、负责人并填写回访安排。';
return;
}
busy.value = true;
message.value = '';
try {
await createFollowUp({
clientRequestId: crypto.randomUUID(),
beneficiaryPublicId: createForm.beneficiaryPublicId,
ownerStaffPublicId: createForm.ownerStaffPublicId,
followUpType: createForm.followUpType,
dueAt: new Date(createForm.dueAt).toISOString(),
reason: createForm.reason.trim(),
});
showCreate.value = false;
Object.assign(createForm, {
beneficiaryPublicId: '',
ownerStaffPublicId: '',
followUpType: 'MANUAL',
dueAt: '',
reason: '',
});
await load(1);
message.value = '回访任务已创建并记录审计。';
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function saveCompletion(): Promise<void> {
if (!selected.value || completeForm.resultNote.trim().length < 2) {
message.value = '请填写回访结果说明。';
return;
}
if (
completeForm.resultCode === 'NEED_MORE_HELP' &&
(!completeForm.continuationConsentConfirmed ||
completeForm.continuationDescription.trim().length < 10)
) {
message.value = '继续帮扶需要确认授权,并填写不少于 10 个字的新需求。';
return;
}
busy.value = true;
message.value = '';
try {
const result = await completeFollowUp(selected.value.publicId, {
clientRequestId: crypto.randomUUID(),
resultCode: completeForm.resultCode,
resultNote: completeForm.resultNote.trim(),
...(completeForm.resultCode === 'NEED_MORE_HELP'
? {
continuationConsentConfirmed: true as const,
continuationDescription: completeForm.continuationDescription.trim(),
}
: {}),
});
selected.value = result.followUp;
await load(page.value);
message.value = result.followUp.continuedApplicationNo
? `回访已完成,继续帮扶申请 ${result.followUp.continuedApplicationNo} 已创建。`
: '回访已完成,原服务申请已闭环。';
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
function showOverdue(): void {
filters.status = 'PENDING';
filters.overdue = true;
void load(1);
}
function isOverdue(item: FollowUpListItem): boolean {
return item.status === 'PENDING' && new Date(item.dueAt).getTime() < Date.now();
}
onMounted(async () => {
await Promise.all([load(), loadOptions()]);
});
</script>
<template>
<main class="content-area" tabindex="-1">
<template v-if="selected">
<header class="page-header">
<div>
<button class="back-button" @click="selected = null">← 返回回访工作台</button>
<h1>{{ selected.followUpNo }}</h1>
<p>
{{ selected.beneficiaryDisplayName }} · {{ selected.followUpTypeLabel }} ·
{{ selected.ownerDisplayName }}
</p>
</div>
<span
:class="[
'status-tag',
selected.status === 'COMPLETED' ? 'status-tag--success' : 'status-tag--warning',
]"
>{{ selected.statusLabel }}</span
>
</header>
<div class="audit-notice">
回访原因与结论属于敏感服务信息,仅限履职使用;查看与提交均会留痕。
</div>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section class="detail-grid">
<article class="panel">
<h2>回访安排</h2>
<dl class="detail-list">
<div>
<dt>服务对象</dt>
<dd>{{ selected.beneficiaryDisplayName }}</dd>
</div>
<div>
<dt>服务类型</dt>
<dd>{{ SERVICE_CATEGORY_LABELS[selected.category] }}</dd>
</div>
<div>
<dt>计划时间</dt>
<dd>{{ formatChinaDateTime(selected.dueAt) }}</dd>
</div>
<div>
<dt>关联工单</dt>
<dd>{{ selected.serviceCaseNo ?? '独立回访' }}</dd>
</div>
</dl>
<h3>回访原因</h3>
<p class="description-text">{{ selected.reason }}</p>
</article>
<article class="panel">
<h2>回访结果</h2>
<template v-if="selected.status === 'COMPLETED'">
<p>
<strong>{{ selected.resultLabel }}</strong>
</p>
<p class="description-text">{{ selected.resultNote }}</p>
<p v-if="selected.continuedApplicationNo" class="consent-note">
已创建继续帮扶申请:{{ selected.continuedApplicationNo }}
</p>
</template>
<form v-else class="form-grid" @submit.prevent="saveCompletion">
<label class="form-span"
>回访结论<select v-model="completeForm.resultCode">
<option v-for="code in FOLLOW_UP_RESULT_CODES" :key="code" :value="code">
{{ FOLLOW_UP_RESULT_LABELS[code] }}
</option>
</select></label
><label class="form-span"
>结果说明<textarea
v-model="completeForm.resultNote"
maxlength="5000"
required
></textarea></label
><template v-if="completeForm.resultCode === 'NEED_MORE_HELP'">
<label class="form-span"
>新的帮扶需求<textarea
v-model="completeForm.continuationDescription"
minlength="10"
maxlength="5000"
required
></textarea></label
><label class="consent-check form-span"
><input v-model="completeForm.continuationConsentConfirmed" type="checkbox" />
已取得服务对象或监护人对创建继续帮扶申请的明确授权</label
>
</template>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">
{{ busy ? '正在提交…' : '完成回访' }}
</button>
</div>
</form>
</article>
</section>
</template>
<template v-else>
<header class="page-header">
<div>
<h1>回访工作台</h1>
<p>优先处理逾期、今日到期和重点关爱对象的回访任务。</p>
</div>
<button class="primary-button compact-button" @click="showCreate = !showCreate">
{{ showCreate ? '收起新建表单' : '新建回访' }}
</button>
</header>
<section class="summary-grid" aria-label="回访任务概览">
<button class="summary-card summary-card--danger" type="button" @click="showOverdue">
<span>已逾期</span><strong>{{ summary.overdue }}</strong
><small>优先处理</small>
</button>
<article class="summary-card">
<span>今日到期</span><strong>{{ summary.dueToday }}</strong
><small>按时联系</small>
</article>
<article class="summary-card">
<span>未来 7 日</span><strong>{{ summary.dueThisWeek }}</strong
><small>提前安排</small>
</article>
<article class="summary-card">
<span>重点关爱待办</span><strong>{{ summary.keyCarePending }}</strong
><small>持续关注</small>
</article>
</section>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="showCreate" class="panel workflow-panel">
<h2>新建人工回访</h2>
<form class="form-grid" @submit.prevent="saveCreate">
<label
>服务对象<select v-model="createForm.beneficiaryPublicId" required>
<option value="">请选择</option>
<option v-for="item in beneficiaries" :key="item.publicId" :value="item.publicId">
{{ item.displayName }}{{ item.keyCareFlag ? ' · 重点关爱' : '' }}
</option>
</select></label
><label
>负责人<select v-model="createForm.ownerStaffPublicId" required>
<option value="">请选择</option>
<option v-for="item in assignees" :key="item.userPublicId" :value="item.userPublicId">
{{ item.displayName }}
</option>
</select></label
><label
>回访类型<select v-model="createForm.followUpType">
<option value="MANUAL">人工回访</option>
<option value="KEY_CARE">重点关爱回访</option>
</select></label
><label>计划时间<input v-model="createForm.dueAt" type="datetime-local" required /></label
><label class="form-span"
>回访原因<textarea v-model="createForm.reason" maxlength="2000" required></textarea>
</label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">
{{ busy ? '正在创建…' : '创建回访任务' }}
</button>
</div>
</form>
</section>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label>状态</label
><select v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in FOLLOW_UP_STATUSES" :key="status" :value="status">
{{ FOLLOW_UP_STATUS_LABELS[status] }}
</option>
</select>
</div>
<div class="filter-field">
<label>类型</label
><select v-model="filters.followUpType">
<option value="">全部类型</option>
<option v-for="type in FOLLOW_UP_TYPES" :key="type" :value="type">
{{ FOLLOW_UP_TYPE_LABELS[type] }}
</option>
</select>
</div>
<div class="filter-field">
<label class="consent-check"
><input v-model="filters.overdue" type="checkbox" /> 仅看逾期待办</label
>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<section v-if="loading" class="panel">
<div v-for="n in 6" :key="n" class="table-skeleton"></div>
</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无符合条件的回访</h2>
<p>服务完成或工作人员新建回访后,任务会显示在这里。</p>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>回访编号</th>
<th>服务对象</th>
<th>类型</th>
<th>计划时间</th>
<th>负责人</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>{{ item.followUpNo }}</td>
<td>
{{ item.beneficiaryDisplayName }}
<span v-if="item.beneficiaryKeyCareFlag" class="status-tag status-tag--warning"
>重点关爱</span
>
</td>
<td>{{ item.followUpTypeLabel }}</td>
<td :class="{ 'danger-text': isOverdue(item) }">
{{ formatChinaDateTime(item.dueAt)
}}<small v-if="isOverdue(item)" class="block-note">已逾期</small>
</td>
<td>{{ item.ownerDisplayName }}</td>
<td>
<span
:class="[
'status-tag',
item.status === 'COMPLETED'
? 'status-tag--success'
: isOverdue(item)
? 'status-tag--danger'
: 'status-tag--info',
]"
>{{ item.statusLabel }}</span
>
</td>
<td><button class="link-button" @click="open(item.publicId)">查看处理</button></td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
><span>第 {{ page }} / {{ totalPages }} 页</span
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
</div>
</section>
</template>
</main>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { NotificationDeliveryItem } from '@lehuo/contracts';
import { fetchDeliveries, retryDelivery } from '../api/content-notifications';
const items = ref<NotificationDeliveryItem[]>([]),
loading = ref(true),
message = ref('');
async function load() {
loading.value = true;
try {
items.value = (await fetchDeliveries({ page: 1, pageSize: 50 })).items;
} catch (e) {
message.value = e instanceof Error ? e.message : '加载失败';
} finally {
loading.value = false;
}
}
async function retry(id: string) {
await retryDelivery(id);
message.value = '已重新加入安全投递队列。';
await load();
}
onMounted(load);
</script>
<template>
<main class="content-area">
<header class="page-header">
<div>
<h1>通知投递</h1>
<p>站内消息独立持久化;微信失败不会回滚业务,可审计重试。</p>
</div>
</header>
<p v-if="message" class="global-message">{{ message }}</p>
<section v-if="loading" class="panel">加载中…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无微信投递</h2>
<p>用户授权订阅且模板配置完成后会生成投递任务。</p>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>类型</th>
<th>状态</th>
<th>尝试</th>
<th>下次尝试</th>
<th>错误码</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="i in items" :key="i.publicId">
<td>{{ i.type }}</td>
<td>{{ i.status }}</td>
<td>{{ i.attemptCount }}</td>
<td>{{ new Date(i.nextAttemptAt).toLocaleString('zh-CN') }}</td>
<td>{{ i.errorCode ?? '—' }}</td>
<td>
<button
v-if="i.status === 'FAILED' || i.status === 'DEAD'"
class="link-button"
@click="retry(i.publicId)"
>
重试
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
</template>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
PROJECT_STATUSES,
PROJECT_STATUS_LABELS,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
type AssigneeOption,
type ProjectListItem,
type ProjectStatus,
type ServiceCategory,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
createProject,
fetchProjectManagerOptions,
fetchProjects,
transitionProject,
} from '../api/participation';
const items = ref<ProjectListItem[]>([]),
managers = ref<AssigneeOption[]>([]),
loading = ref(true),
busy = ref(false),
showCreate = ref(false),
message = ref('');
const page = ref(1),
total = ref(0),
pageSize = 20,
totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const filters = reactive<{ status: ProjectStatus | '' }>({ status: '' });
const form = reactive({
name: '',
category: 'OLD_AGE' as ServiceCategory,
summary: '',
content: '',
managerUserPublicId: '',
startDate: '',
endDate: '',
targetPeople: undefined as number | undefined,
isPublic: true,
sortOrder: 0,
});
const errorText = (error: unknown) =>
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
async function load(target = 1) {
loading.value = true;
message.value = '';
try {
const result = await fetchProjects({
page: target,
pageSize,
...(filters.status ? { status: filters.status } : {}),
});
items.value = result.items;
total.value = result.total;
page.value = result.page;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function save() {
if (!form.managerUserPublicId || !form.startDate || !form.endDate) {
message.value = '请选择负责人并填写项目周期。';
return;
}
busy.value = true;
try {
await createProject({ clientRequestId: crypto.randomUUID(), ...form });
showCreate.value = false;
Object.assign(form, {
name: '',
summary: '',
content: '',
startDate: '',
endDate: '',
targetPeople: undefined,
});
message.value = '项目草稿已创建。';
await load(1);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function transition(item: ProjectListItem) {
const targetStatus = item.status === 'DRAFT' ? 'PUBLISHED' : 'COMPLETED';
if (!window.confirm(`确认${targetStatus === 'PUBLISHED' ? '发布' : '完成'}项目“${item.name}”?`))
return;
busy.value = true;
try {
await transitionProject(item.publicId, { clientRequestId: crypto.randomUUID(), targetStatus });
message.value = '项目状态已更新并记录审计。';
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
onMounted(async () => {
await Promise.all([
load(),
fetchProjectManagerOptions().then((value) => {
managers.value = value;
}),
]);
});
</script>
<template>
<main class="content-area" tabindex="-1">
<header class="page-header">
<div>
<h1>公益项目</h1>
<p>维护项目内容、负责人、周期与公开状态。</p>
</div>
<button class="primary-button compact-button" @click="showCreate = !showCreate">
{{ showCreate ? '取消新建' : '新建项目' }}
</button>
</header>
<form v-if="showCreate" class="panel form-grid" @submit.prevent="save">
<label
>项目名称<input v-model.trim="form.name" required minlength="2" maxlength="200" /></label
><label
>服务类型<select v-model="form.category">
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
{{ SERVICE_CATEGORY_LABELS[category] }}
</option>
</select></label
><label
>负责人<select v-model="form.managerUserPublicId" required>
<option value="">请选择</option>
<option
v-for="manager in managers"
:key="manager.userPublicId"
:value="manager.userPublicId"
>
{{ manager.displayName }}
</option>
</select></label
><label>目标人数<input v-model.number="form.targetPeople" type="number" min="1" /></label
><label>开始日期<input v-model="form.startDate" type="date" required /></label
><label>结束日期<input v-model="form.endDate" type="date" required /></label
><label class="form-span"
>项目摘要<textarea
v-model.trim="form.summary"
required
minlength="10"
maxlength="500"
></textarea></label
><label class="form-span"
>项目内容<textarea
v-model.trim="form.content"
required
minlength="20"
maxlength="20000"
></textarea></label
><label><input v-model="form.isPublic" type="checkbox" /> 发布后公开展示</label
><label
>排序<input v-model.number="form.sortOrder" type="number" min="-10000" max="10000"
/></label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">
{{ busy ? '保存中…' : '保存草稿' }}
</button>
</div>
</form>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label for="project-status">项目状态</label
><select id="project-status" v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in PROJECT_STATUSES" :key="status" :value="status">
{{ PROJECT_STATUS_LABELS[status] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="loading" class="panel">正在加载项目…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无项目</h2>
<p>可先建立项目草稿。</p>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>项目</th>
<th>负责人</th>
<th>周期</th>
<th>状态</th>
<th>活动/工单</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>
<strong>{{ item.name }}</strong
><br />{{ item.projectNo }} · {{ SERVICE_CATEGORY_LABELS[item.category] }}
</td>
<td>{{ item.managerDisplayName }}</td>
<td>{{ item.startDate }} 至 {{ item.endDate }}</td>
<td>{{ item.statusLabel }}<br />{{ item.isPublic ? '公开' : '内部' }}</td>
<td>{{ item.activityCount }} / {{ item.serviceCaseCount }}</td>
<td>
<button
v-if="item.status !== 'COMPLETED'"
class="link-button"
:disabled="busy"
@click="transition(item)"
>
{{ item.status === 'DRAFT' ? '发布' : '完成' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
><span>第 {{ page }} / {{ totalPages }} 页</span
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
</div>
</section>
</main>
</template>
@@ -0,0 +1,572 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
SERVICE_APPLICATION_STATUSES,
SERVICE_APPLICATION_STATUS_LABELS,
SERVICE_CASE_PRIORITIES,
SERVICE_CASE_PRIORITY_LABELS,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
type ServiceApplicationDetail,
type ServiceApplicationListItem,
type ServiceApplicationStatus,
type ServiceCategory,
type AssigneeOption,
type BeneficiaryTagOption,
type ServiceCasePriority,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
acceptServiceApplication,
fetchAssigneeOptions,
fetchBeneficiaryTags,
transitionServiceApplication,
} from '../api/case-management';
import {
fetchAdminServiceApplication,
fetchAdminServiceApplications,
} from '../api/service-applications';
import { formatChinaDateTime, statusTone } from '../application-format';
type ViewState = 'loading' | 'success' | 'empty' | 'error' | 'no-permission';
const state = ref<ViewState>('loading');
const message = ref('');
const items = ref<ServiceApplicationListItem[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = 20;
const selected = ref<ServiceApplicationDetail | null>(null);
const detailLoading = ref(false);
const workflowBusy = ref(false);
const assignees = ref<AssigneeOption[]>([]);
const tags = ref<BeneficiaryTagOption[]>([]);
const acceptance = reactive({
mode: 'CREATE' as 'CREATE' | 'LINK_EXISTING',
existingPublicId: '',
ownerStaffPublicId: '',
collaboratorUserPublicIds: [] as string[],
priority: 'NORMAL' as ServiceCasePriority,
summary: '',
serviceLocation: '',
displayName: '',
mobile: '',
regionCode: '',
birthYear: undefined as number | undefined,
keyCareFlag: false,
isMinor: false,
guardianName: '',
guardianRelation: '监护人',
guardianMobile: '',
tagCodes: [] as string[],
});
const filters = reactive<{
applicationNo: string;
serviceCategory: ServiceCategory | '';
status: ServiceApplicationStatus | '';
}>({ applicationNo: '', serviceCategory: '', status: '' });
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
function errorMessage(error: unknown): string {
if (error instanceof ApiClientError) {
return `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`;
}
return '服务申请加载失败,请稍后重试';
}
async function loadList(targetPage = page.value): Promise<void> {
state.value = 'loading';
message.value = '';
selected.value = null;
try {
const result = await fetchAdminServiceApplications({
page: targetPage,
pageSize,
...(filters.applicationNo.trim()
? { applicationNo: filters.applicationNo.trim().toUpperCase() }
: {}),
...(filters.serviceCategory ? { serviceCategory: filters.serviceCategory } : {}),
...(filters.status ? { status: filters.status } : {}),
});
page.value = result.page;
items.value = result.items;
total.value = result.total;
state.value = result.items.length ? 'success' : 'empty';
} catch (error) {
state.value =
error instanceof ApiClientError && error.code === 'FORBIDDEN' ? 'no-permission' : 'error';
message.value = errorMessage(error);
}
}
async function openDetail(publicId: string): Promise<void> {
detailLoading.value = true;
message.value = '';
try {
selected.value = (await fetchAdminServiceApplication(publicId)).application;
acceptance.displayName = selected.value.beneficiaryName;
acceptance.mobile = selected.value.contactMobile;
acceptance.regionCode = selected.value.regionCode;
acceptance.summary = selected.value.description;
if (selected.value.status === 'ASSESSING') {
[assignees.value, tags.value] = await Promise.all([
fetchAssigneeOptions(),
fetchBeneficiaryTags(),
]);
}
} catch (error) {
message.value = errorMessage(error);
} finally {
detailLoading.value = false;
}
}
async function transition(targetStatus: 'PENDING_CONTACT' | 'ASSESSING'): Promise<void> {
if (!selected.value) return;
workflowBusy.value = true;
message.value = '';
try {
await transitionServiceApplication(selected.value.publicId, {
targetStatus,
reasonCode: targetStatus === 'PENDING_CONTACT' ? 'CONTACT_STARTED' : 'CONTACT_COMPLETED',
});
await openDetail(selected.value.publicId);
message.value = '申请状态已更新并记录审计。';
} catch (error) {
message.value = errorMessage(error);
} finally {
workflowBusy.value = false;
}
}
async function accept(): Promise<void> {
if (!selected.value || !acceptance.ownerStaffPublicId || acceptance.summary.trim().length < 10) {
message.value = '请选择负责人,并填写至少 10 个字的服务计划。';
return;
}
if (acceptance.mode === 'LINK_EXISTING' && !acceptance.existingPublicId) {
message.value = '请输入已核验的服务对象档案标识。';
return;
}
if (
acceptance.mode === 'CREATE' &&
acceptance.isMinor &&
(!acceptance.guardianName || !acceptance.guardianMobile)
) {
message.value = '未成年人档案必须填写监护人及手机号。';
return;
}
workflowBusy.value = true;
message.value = '';
try {
const beneficiary =
acceptance.mode === 'LINK_EXISTING'
? ({
mode: 'LINK_EXISTING',
beneficiaryPublicId: acceptance.existingPublicId.trim(),
manualMatchConfirmed: true,
} as const)
: ({
mode: 'CREATE',
profile: {
displayName: acceptance.displayName.trim(),
...(acceptance.birthYear ? { birthYear: acceptance.birthYear } : {}),
...(acceptance.mobile ? { mobile: acceptance.mobile.trim() } : {}),
regionCode: acceptance.regionCode.trim(),
keyCareFlag: acceptance.keyCareFlag,
isMinor: acceptance.isMinor,
contacts: acceptance.isMinor
? [
{
name: acceptance.guardianName.trim(),
relation: acceptance.guardianRelation.trim(),
mobile: acceptance.guardianMobile.trim(),
isGuardian: true,
isEmergencyContact: true,
},
]
: [],
tagCodes: acceptance.tagCodes,
...(acceptance.isMinor ? { guardianConsentConfirmed: true as const } : {}),
},
} as const);
const result = await acceptServiceApplication(selected.value.publicId, {
clientRequestId: crypto.randomUUID(),
beneficiary,
ownerStaffPublicId: acceptance.ownerStaffPublicId,
collaboratorUserPublicIds: acceptance.collaboratorUserPublicIds,
priority: acceptance.priority,
...(acceptance.serviceLocation.trim()
? { serviceLocation: acceptance.serviceLocation.trim() }
: {}),
summary: acceptance.summary.trim(),
});
message.value = `接单成功,已生成工单 ${result.serviceCase.caseNo}。`;
selected.value = null;
await loadList(1);
} catch (error) {
message.value = errorMessage(error);
} finally {
workflowBusy.value = false;
}
}
function resetFilters(): void {
filters.applicationNo = '';
filters.serviceCategory = '';
filters.status = '';
void loadList(1);
}
onMounted(() => loadList(1));
</script>
<template>
<main id="main-content" class="content-area" tabindex="-1">
<template v-if="selected">
<header class="page-header">
<div>
<button class="back-button" type="button" @click="selected = null">← 返回申请列表</button>
<h1>服务申请详情</h1>
<p>查看本次申请的必要信息与状态历史。</p>
</div>
<span :class="['status-tag', `status-tag--${statusTone(selected.status)}`]">
{{ selected.statusLabel }}
</span>
</header>
<div class="audit-notice" role="note">
此页面包含服务联系所需的敏感信息,本次查看已记录安全审计。请勿复制或用于服务以外的目的。
</div>
<section
v-if="selected.status === 'SUBMITTED' || selected.status === 'PENDING_CONTACT'"
class="panel workflow-panel"
>
<h2>受理进度</h2>
<p>状态必须按顺序推进;系统会在同一事务中写入状态历史和审计记录。</p>
<button
v-if="selected.status === 'SUBMITTED'"
class="primary-button compact-button"
:disabled="workflowBusy"
@click="transition('PENDING_CONTACT')"
>
开始联系
</button>
<button
v-else
class="primary-button compact-button"
:disabled="workflowBusy"
@click="transition('ASSESSING')"
>
完成联系,进入评估
</button>
</section>
<section v-if="selected.status === 'ASSESSING'" class="panel workflow-panel">
<h2>评估通过并生成工单</h2>
<p>接单会原子化建立或关联服务对象档案、指派负责人并生成工单。</p>
<form class="form-grid" @submit.prevent="accept">
<label
>档案处理方式<select v-model="acceptance.mode">
<option value="CREATE">建立新档案</option>
<option value="LINK_EXISTING">关联已有档案</option>
</select></label
>
<label v-if="acceptance.mode === 'LINK_EXISTING'"
>已人工核验的档案标识<input v-model.trim="acceptance.existingPublicId" required
/></label>
<template v-else>
<label
>服务对象姓名<input v-model.trim="acceptance.displayName" required maxlength="50"
/></label>
<label
>手机号<input
v-model.trim="acceptance.mobile"
inputmode="numeric"
pattern="1[3-9][0-9]{9}"
/></label>
<label
>所在地区<input v-model.trim="acceptance.regionCode" required maxlength="100"
/></label>
<label
>出生年份<input
v-model.number="acceptance.birthYear"
type="number"
min="1900"
max="2200"
/></label>
<label><input v-model="acceptance.keyCareFlag" type="checkbox" /> 重点关怀对象</label>
<label><input v-model="acceptance.isMinor" type="checkbox" /> 未成年人</label>
<template v-if="acceptance.isMinor">
<label
>监护人姓名<input v-model.trim="acceptance.guardianName" required maxlength="50"
/></label>
<label
>与服务对象关系<input
v-model.trim="acceptance.guardianRelation"
required
maxlength="50"
/></label>
<label
>监护人手机号<input
v-model.trim="acceptance.guardianMobile"
required
pattern="1[3-9][0-9]{9}"
/></label>
<p class="form-span consent-note">
提交表示工作人员已取得监护人明确授权;系统将创建独立授权记录。
</p>
</template>
<fieldset class="form-span checkbox-group">
<legend>服务标签</legend>
<label v-for="tag in tags" :key="tag.code"
><input v-model="acceptance.tagCodes" type="checkbox" :value="tag.code" />
{{ tag.name }}</label
>
</fieldset>
</template>
<label
>负责人<select v-model="acceptance.ownerStaffPublicId" required>
<option value="">请选择</option>
<option
v-for="item in assignees.filter((option) => option.type === 'STAFF')"
:key="item.userPublicId"
:value="item.userPublicId"
>
{{ item.displayName }}{{ item.department ? ` · ${item.department}` : '' }}
</option>
</select></label
>
<label
>优先级<select v-model="acceptance.priority">
<option v-for="priority in SERVICE_CASE_PRIORITIES" :key="priority" :value="priority">
{{ SERVICE_CASE_PRIORITY_LABELS[priority] }}
</option>
</select></label
>
<label class="form-span"
>协作人员<select v-model="acceptance.collaboratorUserPublicIds" multiple size="5">
<option v-for="item in assignees" :key="item.userPublicId" :value="item.userPublicId">
{{ item.displayName }} · {{ item.type === 'STAFF' ? '工作人员' : '志愿者' }}
</option>
</select></label
>
<label class="form-span"
>服务地点<input v-model.trim="acceptance.serviceLocation" maxlength="300"
/></label>
<label class="form-span"
>服务计划<textarea
v-model.trim="acceptance.summary"
required
minlength="10"
maxlength="2000"
></textarea>
</label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="workflowBusy">
{{ workflowBusy ? '正在生成…' : '确认接单并生成工单' }}
</button>
</div>
</form>
</section>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section class="detail-grid">
<article class="panel">
<h2>申请概况</h2>
<dl class="detail-list">
<div>
<dt>服务编号</dt>
<dd class="tabular">{{ selected.applicationNo }}</dd>
</div>
<div>
<dt>服务类型</dt>
<dd>{{ selected.serviceCategoryLabel }}</dd>
</div>
<div>
<dt>申请关系</dt>
<dd>{{ selected.applyModeLabel }}</dd>
</div>
<div>
<dt>服务对象</dt>
<dd>{{ selected.beneficiaryName }}</dd>
</div>
<div>
<dt>所在地区</dt>
<dd>{{ selected.regionCode }}</dd>
</div>
<div>
<dt>联系电话</dt>
<dd class="sensitive-value">{{ selected.contactMobile }}</dd>
</div>
<div>
<dt>期望联系</dt>
<dd>{{ selected.preferredContactTime ?? '未填写' }}</dd>
</div>
<div>
<dt>提交时间</dt>
<dd>{{ formatChinaDateTime(selected.submittedAt) }}</dd>
</div>
</dl>
</article>
<article class="panel">
<h2>补充情况</h2>
<dl class="detail-list">
<div>
<dt>希望上门</dt>
<dd>{{ selected.needsHomeVisit ? '是' : '否或未说明' }}</dd>
</div>
<div>
<dt>行动不便</dt>
<dd>{{ selected.mobilityDifficulty ? '是' : '否或未说明' }}</dd>
</div>
</dl>
<h3>需求说明</h3>
<p class="description-text">{{ selected.description }}</p>
</article>
</section>
<section class="panel timeline-panel">
<h2>状态历史</h2>
<ol class="admin-timeline">
<li v-for="history in selected.history" :key="history.createdAt">
<span class="timeline-marker" aria-hidden="true"></span>
<div>
<strong>{{ history.statusLabel }}</strong>
<p>{{ history.reasonLabel }}</p>
<time :datetime="history.createdAt">{{
formatChinaDateTime(history.createdAt)
}}</time>
</div>
</li>
</ol>
</section>
</template>
<template v-else>
<header class="page-header">
<div>
<h1>服务申请</h1>
<p>查看服务需求,并按状态机完成联系、评估、接单和工单生成。</p>
</div>
<span class="result-count">共 {{ total }} 条</span>
</header>
<form class="filter-panel" aria-label="申请筛选" @submit.prevent="loadList(1)">
<div class="filter-field">
<label for="application-no">服务编号</label>
<input id="application-no" v-model="filters.applicationNo" placeholder="输入服务编号" />
</div>
<div class="filter-field">
<label for="service-category">服务类型</label>
<select id="service-category" v-model="filters.serviceCategory">
<option value="">全部类型</option>
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
{{ SERVICE_CATEGORY_LABELS[category] }}
</option>
</select>
</div>
<div class="filter-field">
<label for="application-status">当前状态</label>
<select id="application-status" v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in SERVICE_APPLICATION_STATUSES" :key="status" :value="status">
{{ SERVICE_APPLICATION_STATUS_LABELS[status] }}
</option>
</select>
</div>
<div class="filter-actions">
<button class="primary-button compact-button" type="submit">查询</button>
<button class="secondary-button compact-button" type="button" @click="resetFilters">
重置
</button>
</div>
</form>
<section v-if="state === 'loading'" class="panel" aria-live="polite">
<div v-for="index in 6" :key="index" class="table-skeleton"></div>
<p class="sr-only">正在加载服务申请</p>
</section>
<section v-else-if="state === 'empty'" class="empty-panel">
<h2>没有符合条件的服务申请</h2>
<p>可以调整筛选条件后重新查询。</p>
<button class="secondary-button compact-button" type="button" @click="resetFilters">
清除筛选
</button>
</section>
<section v-else-if="state === 'no-permission'" class="empty-panel" role="alert">
<h2>您暂无权限查看服务申请</h2>
<p>{{ message }}</p>
</section>
<section v-else-if="state === 'error'" class="empty-panel" role="alert">
<h2>服务申请加载失败</h2>
<p>{{ message }}</p>
<button class="secondary-button compact-button" type="button" @click="loadList()">
重新加载
</button>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<caption class="sr-only">
服务申请列表
</caption>
<thead>
<tr>
<th>服务编号</th>
<th>服务对象</th>
<th>服务类型</th>
<th>联系电话</th>
<th>状态</th>
<th>提交时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td class="tabular">{{ item.applicationNo }}</td>
<td>{{ item.beneficiaryName }}</td>
<td>{{ item.serviceCategoryLabel }}</td>
<td class="tabular">{{ item.contactMobileMasked }}</td>
<td>
<span :class="['status-tag', `status-tag--${statusTone(item.status)}`]">{{
item.statusLabel
}}</span>
</td>
<td>{{ formatChinaDateTime(item.submittedAt) }}</td>
<td>
<button
class="link-button"
type="button"
:disabled="detailLoading"
@click="openDetail(item.publicId)"
>
{{ detailLoading ? '读取中…' : '查看' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" aria-label="分页">
<button type="button" :disabled="page <= 1" @click="loadList(page - 1)">上一页</button>
<span>第 {{ page }} / {{ totalPages }} 页</span>
<button type="button" :disabled="page >= totalPages" @click="loadList(page + 1)">
下一页
</button>
</div>
</section>
<p v-if="message && state === 'success'" class="global-message" role="alert">{{ message }}</p>
</template>
</main>
</template>
@@ -0,0 +1,590 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
BENEFICIARY_CONFIRM_STATUSES,
SERVICE_CASE_STATUSES,
SERVICE_CASE_STATUS_LABELS,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
SERVICE_RECORD_TYPES,
SERVICE_RECORD_TYPE_LABELS,
type AssigneeOption,
type ServiceCaseDetail,
type ServiceCaseListItem,
type ServiceCaseStatus,
type ServiceCategory,
type ServiceRecordItem,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import {
confirmUpload,
completeServiceCase,
createServiceRecord,
createUploadIntent,
fetchAssigneeOptions,
fetchDownloadUrl,
fetchServiceCase,
fetchServiceCases,
replaceCaseAssignees,
startServiceCase,
updateServiceRecord,
} from '../api/case-management';
import { formatChinaDateTime } from '../application-format';
const items = ref<ServiceCaseListItem[]>([]);
const selected = ref<ServiceCaseDetail | null>(null);
const options = ref<AssigneeOption[]>([]);
const collaborators = ref<string[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const total = ref(0);
const page = ref(1);
const pageSize = 20;
const filters = reactive<{
caseNo: string;
status: ServiceCaseStatus | '';
category: ServiceCategory | '';
}>({ caseNo: '', status: '', category: '' });
const recordForm = reactive({
publicId: '',
revision: 0,
serviceStartAt: '',
serviceEndAt: '',
serviceType: 'HOME_VISIT' as (typeof SERVICE_RECORD_TYPES)[number],
content: '',
result: '',
followUpSuggestion: '',
beneficiaryConfirmStatus: 'NOT_REQUESTED' as (typeof BENEFICIARY_CONFIRM_STATUSES)[number],
attachmentPublicIds: [] as string[],
});
const pendingFiles = ref<Array<{ publicId: string; filename: string }>>([]);
const completionForm = reactive({ resultSummary: '', followUpPlan: '', followUpDueAt: '' });
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
function errorText(error: unknown): string {
return error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
}
function localDate(value: string): string {
return value ? new Date(value).toISOString() : '';
}
function resetRecord(): void {
Object.assign(recordForm, {
publicId: '',
revision: 0,
serviceStartAt: '',
serviceEndAt: '',
serviceType: 'HOME_VISIT',
content: '',
result: '',
followUpSuggestion: '',
beneficiaryConfirmStatus: 'NOT_REQUESTED',
attachmentPublicIds: [],
});
pendingFiles.value = [];
}
async function load(targetPage = 1): Promise<void> {
loading.value = true;
selected.value = null;
message.value = '';
try {
const result = await fetchServiceCases({
page: targetPage,
pageSize,
...(filters.caseNo.trim() ? { caseNo: filters.caseNo.trim().toUpperCase() } : {}),
...(filters.status ? { status: filters.status } : {}),
...(filters.category ? { category: filters.category } : {}),
});
items.value = result.items;
total.value = result.total;
page.value = result.page;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function open(publicId: string): Promise<void> {
loading.value = true;
message.value = '';
try {
const [caseResult, assignees] = await Promise.all([
fetchServiceCase(publicId),
fetchAssigneeOptions().catch(() => []),
]);
selected.value = caseResult.serviceCase;
options.value = assignees;
collaborators.value = caseResult.serviceCase.assignees
.filter((item) => item.role === 'COLLABORATOR')
.map((item) => item.userPublicId);
resetRecord();
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function start(): Promise<void> {
if (!selected.value) return;
busy.value = true;
message.value = '';
try {
selected.value = (await startServiceCase(selected.value.publicId)).serviceCase;
message.value = '服务已开始,申请进度与工单状态已同步更新。';
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function completeCase(): Promise<void> {
if (
!selected.value ||
completionForm.resultSummary.trim().length < 2 ||
completionForm.followUpPlan.trim().length < 2 ||
!completionForm.followUpDueAt
) {
message.value = '请完整填写服务总结、回访计划和回访时间。';
return;
}
busy.value = true;
message.value = '';
try {
const publicId = selected.value.publicId;
const result = await completeServiceCase(publicId, {
clientRequestId: crypto.randomUUID(),
resultSummary: completionForm.resultSummary.trim(),
followUpPlan: completionForm.followUpPlan.trim(),
followUpDueAt: localDate(completionForm.followUpDueAt),
});
await open(publicId);
message.value = `服务已完成,回访任务 ${result.followUp.followUpNo} 已自动生成。`;
Object.assign(completionForm, { resultSummary: '', followUpPlan: '', followUpDueAt: '' });
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function saveAssignees(): Promise<void> {
if (!selected.value) return;
busy.value = true;
message.value = '';
try {
selected.value = (
await replaceCaseAssignees(selected.value.publicId, {
collaboratorUserPublicIds: collaborators.value,
})
).serviceCase;
message.value = '协作人员已更新并记录审计。';
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function upload(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!selected.value || !file) return;
busy.value = true;
message.value = '';
try {
const intent = await createUploadIntent(selected.value.publicId, {
clientRequestId: crypto.randomUUID(),
filename: file.name,
mimeType: file.type as 'image/jpeg' | 'image/png' | 'image/webp',
size: file.size,
});
const uploaded = await fetch(intent.uploadUrl, {
method: 'PUT',
headers: intent.uploadHeaders,
body: file,
});
if (!uploaded.ok) throw new Error('对象存储上传失败');
const confirmed = await confirmUpload(intent.file.publicId);
recordForm.attachmentPublicIds.push(confirmed.file.publicId);
pendingFiles.value.push({
publicId: confirmed.file.publicId,
filename: confirmed.file.filename,
});
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
input.value = '';
}
}
function editRecord(record: ServiceRecordItem): void {
Object.assign(recordForm, {
publicId: record.publicId,
revision: record.revision,
serviceStartAt: record.serviceStartAt.slice(0, 16),
serviceEndAt: record.serviceEndAt?.slice(0, 16) ?? '',
serviceType: record.serviceType,
content: record.content,
result: record.result,
followUpSuggestion: record.followUpSuggestion ?? '',
beneficiaryConfirmStatus: record.beneficiaryConfirmStatus,
attachmentPublicIds: record.attachments.map((item) => item.publicId),
});
pendingFiles.value = record.attachments.map(({ publicId, filename }) => ({ publicId, filename }));
}
async function saveRecord(): Promise<void> {
if (
!selected.value ||
!recordForm.serviceStartAt ||
recordForm.content.trim().length < 2 ||
recordForm.result.trim().length < 2
) {
message.value = '请完整填写服务时间、内容和结果。';
return;
}
busy.value = true;
message.value = '';
const editing = Boolean(recordForm.publicId);
const common = {
serviceStartAt: localDate(recordForm.serviceStartAt),
...(recordForm.serviceEndAt ? { serviceEndAt: localDate(recordForm.serviceEndAt) } : {}),
serviceType: recordForm.serviceType,
content: recordForm.content.trim(),
result: recordForm.result.trim(),
...(recordForm.followUpSuggestion.trim()
? { followUpSuggestion: recordForm.followUpSuggestion.trim() }
: {}),
beneficiaryConfirmStatus: recordForm.beneficiaryConfirmStatus,
attachmentPublicIds: recordForm.attachmentPublicIds,
};
try {
if (recordForm.publicId)
await updateServiceRecord(selected.value.publicId, recordForm.publicId, {
...common,
expectedRevision: recordForm.revision,
});
else
await createServiceRecord(selected.value.publicId, {
...common,
clientRequestId: crypto.randomUUID(),
});
await open(selected.value.publicId);
message.value = editing ? '服务记录已更新。' : '服务记录已保存。';
resetRecord();
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function download(publicId: string): Promise<void> {
try {
window.open((await fetchDownloadUrl(publicId)).downloadUrl, '_blank', 'noopener,noreferrer');
} catch (error) {
message.value = errorText(error);
}
}
onMounted(() => load());
</script>
<template>
<main class="content-area" tabindex="-1">
<template v-if="selected">
<header class="page-header">
<div>
<button class="back-button" @click="selected = null">← 返回工单列表</button>
<h1>{{ selected.caseNo }}</h1>
<p>
{{ selected.beneficiaryDisplayName }} ·
{{ SERVICE_CATEGORY_LABELS[selected.category] }} · 负责人
{{ selected.ownerDisplayName }}
</p>
</div>
<span class="status-tag status-tag--info">{{ selected.statusLabel }}</span>
</header>
<div class="audit-notice">
仅可将以下信息用于已分配的公益服务;查看、指派和记录操作均会留痕。
</div>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section class="detail-grid">
<article class="panel">
<h2>服务计划</h2>
<dl class="detail-list">
<div>
<dt>地区</dt>
<dd>{{ selected.regionCode }}</dd>
</div>
<div>
<dt>联系电话</dt>
<dd>{{ selected.beneficiaryMobile ?? '未留存' }}</dd>
</div>
<div>
<dt>服务地点</dt>
<dd>{{ selected.serviceLocation ?? '未填写' }}</dd>
</div>
<div>
<dt>计划说明</dt>
<dd>{{ selected.summary }}</dd>
</div>
<div v-if="selected.resultSummary">
<dt>服务总结</dt>
<dd>{{ selected.resultSummary }}</dd>
</div>
<div v-if="selected.followUpPlan">
<dt>回访计划</dt>
<dd>{{ selected.followUpPlan }}</dd>
</div>
</dl>
<button
v-if="selected.status === 'ASSIGNED'"
class="primary-button compact-button"
:disabled="busy"
@click="start"
>
开始服务
</button>
</article>
<article class="panel">
<h2>负责人和协作人员</h2>
<p v-for="item in selected.assignees" :key="item.userPublicId">
{{ item.role === 'OWNER' ? '负责人' : '协作' }} · {{ item.displayName }}({{
item.type === 'STAFF' ? '工作人员' : '志愿者'
}})
</p>
<label
>调整协作人员<select v-model="collaborators" multiple size="6">
<option v-for="item in options" :key="item.userPublicId" :value="item.userPublicId">
{{ item.displayName }} · {{ item.type === 'STAFF' ? '工作人员' : '志愿者' }}
</option>
</select></label
><button class="secondary-button compact-button" :disabled="busy" @click="saveAssignees">
保存指派
</button>
</article>
</section>
<section class="panel timeline-panel">
<h2>状态历史</h2>
<ol class="admin-timeline">
<li v-for="item in selected.history" :key="item.createdAt">
<span class="timeline-marker"></span>
<div>
<strong>{{ item.statusLabel }}</strong>
<p>{{ item.reasonLabel }}</p>
<time>{{ formatChinaDateTime(item.createdAt) }}</time>
</div>
</li>
</ol>
</section>
<section class="panel">
<h2>{{ recordForm.publicId ? '修改服务记录' : '新增服务记录' }}</h2>
<form class="form-grid" @submit.prevent="saveRecord">
<label
>开始时间<input
v-model="recordForm.serviceStartAt"
type="datetime-local"
required /></label
><label>结束时间<input v-model="recordForm.serviceEndAt" type="datetime-local" /></label
><label
>服务方式<select v-model="recordForm.serviceType">
<option v-for="type in SERVICE_RECORD_TYPES" :key="type" :value="type">
{{ SERVICE_RECORD_TYPE_LABELS[type] }}
</option>
</select></label
><label
>对象确认<select v-model="recordForm.beneficiaryConfirmStatus">
<option value="NOT_REQUESTED">未请求确认</option>
<option value="CONFIRMED">已确认</option>
<option value="PARTIALLY_CONFIRMED">部分确认</option>
<option value="DISPUTED">有异议</option>
</select></label
><label class="form-span"
>服务内容<textarea
v-model="recordForm.content"
maxlength="5000"
required
></textarea></label
><label class="form-span"
>服务结果<textarea
v-model="recordForm.result"
maxlength="5000"
required
></textarea></label
><label class="form-span"
>后续建议<textarea
v-model="recordForm.followUpSuggestion"
maxlength="2000"
></textarea></label
><label class="form-span"
>服务照片(JPEG / PNG / WebP,单张不超过 10 MiB)<input
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="busy"
@change="upload"
/></label>
<p v-if="pendingFiles.length" class="form-span">
已附加:{{ pendingFiles.map((item) => item.filename).join('、') }}
</p>
<div class="form-actions form-span">
<button
class="primary-button compact-button"
:disabled="busy || selected.status !== 'IN_SERVICE'"
>
{{ busy ? '处理中…' : recordForm.publicId ? '保存修改' : '保存记录' }}</button
><button
v-if="recordForm.publicId"
class="secondary-button compact-button"
type="button"
@click="resetRecord"
>
取消修改
</button>
</div>
</form>
</section>
<section class="panel record-list">
<h2>服务记录</h2>
<p v-if="!selected.records.length">工单开始服务后,可在上方添加记录。</p>
<article v-for="record in selected.records" :key="record.publicId" class="record-card">
<header>
<strong>{{ record.serviceTypeLabel }}</strong
><time>{{ formatChinaDateTime(record.serviceStartAt) }}</time>
</header>
<p>{{ record.content }}</p>
<p><strong>结果:</strong>{{ record.result }}</p>
<div>
<button class="link-button" @click="editRecord(record)">修改</button
><button
v-for="file in record.attachments"
:key="file.publicId"
class="link-button"
@click="download(file.publicId)"
>
查看 {{ file.filename }}
</button>
</div>
</article>
</section>
<section v-if="selected.status === 'IN_SERVICE'" class="panel record-list">
<h2>完成本阶段服务</h2>
<p class="consent-note">
完成后将同步更新申请状态并自动生成回访任务。案件至少需要一条服务记录。
</p>
<form class="form-grid" @submit.prevent="completeCase">
<label class="form-span"
>服务总结<textarea
v-model="completionForm.resultSummary"
maxlength="5000"
required
></textarea></label
><label class="form-span"
>回访计划<textarea
v-model="completionForm.followUpPlan"
maxlength="2000"
required
></textarea></label
><label
>计划回访时间<input
v-model="completionForm.followUpDueAt"
type="datetime-local"
required
/></label>
<div class="form-actions form-span">
<button class="primary-button compact-button" :disabled="busy">
{{ busy ? '正在完成并创建回访…' : '完成服务并创建回访' }}
</button>
</div>
</form>
</section>
</template>
<template v-else>
<header class="page-header">
<div>
<h1>服务工单</h1>
<p>按工单状态和服务类型查看已授权范围内的任务。</p>
</div>
<span class="result-count">共 {{ total }} 个</span>
</header>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field"><label>工单编号</label><input v-model="filters.caseNo" /></div>
<div class="filter-field">
<label>状态</label
><select v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in SERVICE_CASE_STATUSES" :key="status" :value="status">
{{ SERVICE_CASE_STATUS_LABELS[status] }}
</option>
</select>
</div>
<div class="filter-field">
<label>服务类型</label
><select v-model="filters.category">
<option value="">全部类型</option>
<option v-for="category in SERVICE_CATEGORIES" :key="category" :value="category">
{{ SERVICE_CATEGORY_LABELS[category] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<section v-if="loading" class="panel">
<div v-for="n in 6" :key="n" class="table-skeleton"></div>
</section>
<section v-else-if="message" class="empty-panel">
<h2>工单加载失败</h2>
<p>{{ message }}</p>
<button class="secondary-button compact-button" @click="load(page)">重试</button>
</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无符合条件的工单</h2>
<p>申请完成评估与接单后将在此生成工单。</p>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>工单编号</th>
<th>服务对象</th>
<th>服务类型</th>
<th>优先级</th>
<th>状态</th>
<th>负责人</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>{{ item.caseNo }}</td>
<td>{{ item.beneficiaryDisplayName }}</td>
<td>{{ SERVICE_CATEGORY_LABELS[item.category] }}</td>
<td>{{ item.priorityLabel }}</td>
<td>
<span class="status-tag status-tag--info">{{ item.statusLabel }}</span>
</td>
<td>{{ item.ownerDisplayName }}</td>
<td><button class="link-button" @click="open(item.publicId)">进入工单</button></td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
><span>第 {{ page }} / {{ totalPages }} 页</span
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
</div>
</section>
</template>
</main>
</template>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { SERVICE_CATEGORY_LABELS, type StatisticsOverview } from '@lehuo/contracts';
import { fetchStatistics } from '../api/statistics';
const data = ref<StatisticsOverview | null>(null),
loading = ref(true),
message = ref('');
async function load() {
loading.value = true;
try {
data.value = await fetchStatistics();
} catch (e) {
message.value = e instanceof Error ? e.message : '统计加载失败';
} finally {
loading.value = false;
}
}
function download() {
const base = import.meta.env.VITE_API_BASE_URL as string | undefined;
if (base)
window.open(
new URL('/api/v1/admin/statistics/export.csv', base).toString(),
'_blank',
'noopener',
);
}
onMounted(load);
</script>
<template>
<main class="content-area">
<header class="page-header">
<div>
<h1>服务统计</h1>
<p>指标直接从业务事实表计算,不支持人工覆盖。</p>
</div>
<button class="primary-button compact-button" @click="download">导出脱敏汇总 CSV</button>
</header>
<p v-if="message" class="global-message">{{ message }}</p>
<section v-if="loading" class="panel">正在计算统计…</section>
<template v-else-if="data"
><section class="dashboard-grid">
<article class="dashboard-card">
<span>申请数量</span><strong>{{ data.applicationCount }}</strong>
</article>
<article class="dashboard-card">
<span>已受理</span><strong>{{ data.acceptedCount }}</strong>
</article>
<article class="dashboard-card">
<span>完成服务</span><strong>{{ data.completedServiceCount }}</strong>
</article>
<article class="dashboard-card">
<span>服务人次</span><strong>{{ data.servicePersonTimes }}</strong>
</article>
<article class="dashboard-card">
<span>服务对象</span><strong>{{ data.uniqueBeneficiaryCount }}</strong>
</article>
<article class="dashboard-card">
<span>志愿者</span><strong>{{ data.volunteerCount }}</strong>
</article>
<article class="dashboard-card">
<span>活动</span><strong>{{ data.activityCount }}</strong>
</article>
<article class="dashboard-card">
<span>志愿分钟</span><strong>{{ data.volunteerMinutes }}</strong>
</article>
</section>
<section class="panel">
<h2>按服务类别</h2>
<div v-for="item in data.byCategory" :key="item.category" class="stat-row">
<span>{{ SERVICE_CATEGORY_LABELS[item.category] }}</span
><strong>{{ item.count }}</strong>
</div>
<p>统计区间:{{ data.period.startDate }} 至 {{ data.period.endDate }}</p>
</section></template
>
</main>
</template>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import {
VOLUNTEER_STATUSES,
VOLUNTEER_STATUS_LABELS,
type AdminVolunteerListItem,
type VolunteerStatus,
} from '@lehuo/contracts';
import { ApiClientError } from '../api/api-client';
import { fetchVolunteers, reviewVolunteer } from '../api/participation';
import { formatChinaDateTime } from '../application-format';
const items = ref<AdminVolunteerListItem[]>([]),
loading = ref(true),
busy = ref(false),
message = ref('');
const page = ref(1),
total = ref(0),
pageSize = 20;
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const filters = reactive<{ status: VolunteerStatus | '' }>({ status: 'APPLIED' });
const errorText = (error: unknown) =>
error instanceof ApiClientError
? `${error.message}${error.requestId ? `(编号:${error.requestId})` : ''}`
: '操作失败,请稍后重试';
async function load(target = 1) {
loading.value = true;
message.value = '';
try {
const result = await fetchVolunteers({
page: target,
pageSize,
...(filters.status ? { status: filters.status } : {}),
});
items.value = result.items;
total.value = result.total;
page.value = result.page;
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
}
}
async function review(
item: AdminVolunteerListItem,
targetStatus: 'APPROVED' | 'REJECTED' | 'SUSPENDED',
) {
const note =
targetStatus === 'APPROVED'
? '资料核验通过'
: targetStatus === 'REJECTED'
? '资料未通过审核'
: '暂停志愿者资格';
if (!window.confirm(`确认将“${item.realName}”设为${VOLUNTEER_STATUS_LABELS[targetStatus]}?`))
return;
busy.value = true;
try {
await reviewVolunteer(item.publicId, {
clientRequestId: crypto.randomUUID(),
targetStatus,
note,
});
message.value = '志愿者状态已更新并记录审计。';
await load(page.value);
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
onMounted(() => load());
</script>
<template>
<main class="content-area" tabindex="-1">
<header class="page-header">
<div>
<h1>志愿者审核</h1>
<p>核验最少必要资料,管理志愿者资格与累计服务事实。</p>
</div>
<span class="result-count">共 {{ total }} 人</span>
</header>
<form class="filter-panel" @submit.prevent="load(1)">
<div class="filter-field">
<label for="volunteer-status">资格状态</label
><select id="volunteer-status" v-model="filters.status">
<option value="">全部状态</option>
<option v-for="status in VOLUNTEER_STATUSES" :key="status" :value="status">
{{ VOLUNTEER_STATUS_LABELS[status] }}
</option>
</select>
</div>
<button class="primary-button compact-button">查询</button>
</form>
<p v-if="message" class="global-message" role="status">{{ message }}</p>
<section v-if="loading" class="panel" aria-live="polite">正在加载志愿者资料…</section>
<section v-else-if="!items.length" class="empty-panel">
<h2>暂无符合条件的志愿者</h2>
<p>新的申请会显示在这里。</p>
</section>
<section v-else class="table-panel">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>志愿者</th>
<th>联系方式</th>
<th>地区/技能</th>
<th>资格</th>
<th>服务事实</th>
<th>申请时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.publicId">
<td>{{ item.realName }}</td>
<td>{{ item.mobileMasked }}</td>
<td>{{ item.regionCode }}<br />{{ item.skillLabels.join('、') || '未选择' }}</td>
<td>
<span class="status-tag">{{ item.statusLabel }}</span>
</td>
<td>{{ item.completedActivityCount }} 次 / {{ item.totalServiceMinutes }} 分钟</td>
<td>{{ formatChinaDateTime(item.createdAt) }}</td>
<td>
<div class="inline-actions">
<button
v-if="item.status === 'APPLIED'"
class="link-button"
:disabled="busy"
@click="review(item, 'APPROVED')"
>
通过</button
><button
v-if="item.status === 'APPLIED'"
class="link-button link-button--danger"
:disabled="busy"
@click="review(item, 'REJECTED')"
>
拒绝</button
><button
v-if="item.status === 'APPROVED'"
class="link-button link-button--danger"
:disabled="busy"
@click="review(item, 'SUSPENDED')"
>
暂停</button
><button
v-if="item.status === 'SUSPENDED'"
class="link-button"
:disabled="busy"
@click="review(item, 'APPROVED')"
>
恢复
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button
><span>第 {{ page }} / {{ totalPages }} 页</span
><button :disabled="page >= totalPages" @click="load(page + 1)">下一页</button>
</div>
</section>
</main>
</template>
+6
View File
@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent;
export default component;
}
+11
View File
@@ -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"]
}
+6
View File
@@ -0,0 +1,6 @@
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [vue()],
});
+25
View File
@@ -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"]
+7
View File
@@ -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,尚未开放。
+49
View File
@@ -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"
}
}
+13
View File
@@ -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'),
},
});
@@ -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';
@@ -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';
@@ -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');
@@ -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');
@@ -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');
@@ -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';
@@ -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';
@@ -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;
@@ -0,0 +1 @@
provider = "mysql"
File diff suppressed because it is too large Load Diff
+62
View File
@@ -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('*');
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AuditService } from './audit.service';
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
+21
View File
@@ -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/);
});
});
+67
View File
@@ -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<string, string | number | boolean | null>;
}
@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<void> {
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);
}
}
@@ -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<AdminLoginResponse> {
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<LogoutResponse> {
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 };
}
}
+172
View File
@@ -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<void> {
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<void> {
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,
);
});
}
}
+21
View File
@@ -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<RequestContext>();
if (request.header('origin') !== this.config.adminWebOrigin) throw new ForbiddenException();
return true;
}
}
+66
View File
@@ -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<WechatLoginResponse> {
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<LogoutResponse> {
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 };
}
}
+28
View File
@@ -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 {}
+55
View File
@@ -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<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<RequestContext>();
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<SessionChannel>(
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<string, unknown> | undefined;
const value = cookies?.[ADMIN_SESSION_COOKIE];
return typeof value === 'string' ? value : null;
}
}
@@ -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);
+18
View File
@@ -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;
}
@@ -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;
}
@@ -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);
});
});
+24
View File
@@ -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<string> {
return hash(password, ARGON_OPTIONS);
}
async verifyOrDummy(passwordHash: string | null, password: string): Promise<boolean> {
const selectedHash = passwordHash ?? (await this.dummyHash);
const valid = await verify(selectedHash, password);
return passwordHash !== null && valid;
}
}
+1
View File
@@ -0,0 +1 @@
export const ADMIN_SESSION_COOKIE = 'lehuo_admin_session';
+150
View File
@@ -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<AuthContext> {
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<void> {
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,
};
}
}
+10
View File
@@ -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<WechatIdentityResult>;
}
+126
View File
@@ -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<WechatLoginResponse> {
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),
};
}
}
+51
View File
@@ -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<WechatIdentityResult> {
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,
);
}
}
}
@@ -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<AcceptServiceApplicationResponse> {
return this.cases.acceptApplication(publicId, input, actor, request);
}
}
@@ -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<BeneficiaryTagOption[]> {
return this.cases.tags();
}
@Get()
list(
@Query(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType: BeneficiaryQueryDto,
}),
)
query: BeneficiaryQueryDto,
): Promise<PaginatedBeneficiariesResponse> {
return this.cases.listBeneficiaries(query);
}
@Get(':publicId')
get(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<BeneficiaryDetailResponse> {
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<BeneficiaryDetailResponse> {
return this.cases.updateBeneficiary(publicId, input, actor, request);
}
}
@@ -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<AssigneeOption[]> {
return this.cases.assigneeOptions();
}
@Get()
list(
@Query(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType: ServiceCaseQueryDto,
}),
)
query: ServiceCaseQueryDto,
@CurrentAuth() actor: AuthContext,
): Promise<PaginatedServiceCasesResponse> {
return this.cases.listCases(query, actor);
}
@Get(':publicId')
get(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ServiceCaseDetailResponse> {
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<ServiceCaseDetailResponse> {
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<ServiceCaseDetailResponse> {
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<ServiceRecordResponse> {
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<ServiceRecordResponse> {
return this.cases.updateRecord(publicId, recordPublicId, input, actor, request);
}
}
@@ -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<PaginatedServiceCasesResponse> {
return this.cases.listCases(query, actor);
}
@Get(':publicId')
get(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ServiceCaseDetailResponse> {
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<ServiceRecordResponse> {
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<ServiceRecordResponse> {
return this.cases.updateRecord(publicId, recordPublicId, input, actor, request);
}
}
@@ -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<CreateUploadIntentResponse> {
return this.files.createUploadIntent(casePublicId, input, actor, request);
}
@Post('files/:publicId/confirm')
confirm(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ConfirmUploadResponse> {
return this.files.confirm(publicId, actor, request);
}
@Get('files/:publicId/download-url')
download(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<DownloadUrlResponse> {
return this.files.downloadUrl(publicId, actor, request);
}
}
@@ -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 {}
@@ -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<StoredBeneficiary | null> {
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<StoredBeneficiary> {
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<boolean> {
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<StoredServiceCase> {
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<StoredServiceCase | null> {
return this.prisma.serviceCase.findUnique({ where: { publicId }, include: caseInclude });
}
findCaseById(id: bigint): Promise<StoredServiceCase | null> {
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<StoredServiceCase | null> {
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<StoredServiceCase> {
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<Awaited<ReturnType<CaseManagementRepository['findRecord']>>>,
input: Omit<PreparedServiceRecord, 'publicId' | 'clientRequestId' | 'requestFingerprint'> & {
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 } },
},
});
});
}
}
@@ -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<BeneficiaryTagOption[]> {
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<AcceptServiceApplicationResponse> {
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<PaginatedBeneficiariesResponse> {
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<BeneficiaryDetailResponse> {
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<BeneficiaryDetailResponse> {
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<AssigneeOption[]> {
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<PaginatedServiceCasesResponse> {
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<ServiceCaseDetailResponse> {
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<ServiceCaseDetailResponse> {
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<ServiceCaseDetailResponse> {
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<ServiceRecordResponse> {
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<ServiceRecordResponse> {
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<StoredServiceCase> {
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<StoredServiceCase> {
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<StoredServiceCase> {
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<AcceptServiceApplicationDto['beneficiary']['profile']> | 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);
}
}
@@ -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;
}
+100
View File
@@ -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<RequestContext>();
const response = http.getResponse<Response>();
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<string, string[]>;
} {
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: '服务暂时不可用,请稍后重试',
};
}
}
@@ -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<unknown> {
const raw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE_KEY, [
context.getHandler(),
context.getClass(),
]);
if (raw) return next.handle();
const request = context.switchToHttp().getRequest<RequestContext>();
return next.handle().pipe(
map((data: unknown): ApiSuccessResponse<unknown> => ({
success: true,
data,
requestId: request.requestId,
})),
);
}
}
+14
View File
@@ -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<string, string[]>,
) {
super(message, status);
}
}
@@ -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<RequestContext>();
if (!request.auth) throw new Error('Authentication context is unavailable');
return request.auth;
},
);
@@ -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);
@@ -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);
@@ -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);
+23
View File
@@ -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;
}
@@ -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();
}
}
@@ -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<unknown> {
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,
);
}),
);
}
}
@@ -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,
};
}
}
@@ -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,
};
}
}
+10
View File
@@ -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 {}

Some files were not shown because too many files have changed in this diff Show More