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
+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 {}
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from 'vitest';
import { AppConfigService } from './app-config.service';
const ORIGINAL_ENV = { ...process.env };
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
function setValidEnvironment(): void {
process.env.DATABASE_URL = 'mysql://user:password@localhost:3306/lehuo_test';
process.env.SESSION_TOKEN_PEPPER = 'test-session-pepper-with-32-characters';
process.env.AUDIT_IP_HASH_KEY = 'test-audit-key-with-at-least-32-chars';
process.env.ADMIN_WEB_ORIGIN = 'http://localhost:5173';
process.env.FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('base64');
}
describe('AppConfigService', () => {
it('parses an explicit MySQL configuration', () => {
setValidEnvironment();
const config = new AppConfigService();
expect(config.database).toEqual({
host: 'localhost',
port: 3306,
user: 'user',
password: 'password',
database: 'lehuo_test',
});
});
it('rejects encryption keys that are not 32 bytes', () => {
setValidEnvironment();
process.env.FIELD_ENCRYPTION_KEY = Buffer.alloc(16).toString('base64');
expect(() => new AppConfigService()).toThrow(/32-byte key/);
});
});
+115
View File
@@ -0,0 +1,115 @@
import { Injectable } from '@nestjs/common';
export interface DatabaseConnectionOptions {
host: string;
port: number;
user: string;
password: string;
database: string;
}
function requiredEnvironmentValue(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function positiveInteger(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const value = Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
}
function parseBoolean(name: string, fallback: boolean): boolean {
const raw = process.env[name]?.trim().toLowerCase();
if (!raw) return fallback;
if (raw === 'true') return true;
if (raw === 'false') return false;
throw new Error(`${name} must be true or false`);
}
@Injectable()
export class AppConfigService {
readonly appEnvironment = process.env.APP_ENV?.trim() || 'development';
readonly apiPort = positiveInteger('API_PORT', 3000);
readonly databaseUrl = requiredEnvironmentValue('DATABASE_URL');
readonly sessionTokenPepper = requiredEnvironmentValue('SESSION_TOKEN_PEPPER');
readonly auditIpHashKey = requiredEnvironmentValue('AUDIT_IP_HASH_KEY');
readonly adminWebOrigin = requiredEnvironmentValue('ADMIN_WEB_ORIGIN');
readonly adminCookieSecure = parseBoolean('ADMIN_COOKIE_SECURE', true);
readonly adminSessionTtlSeconds = positiveInteger('AUTH_SESSION_TTL_SECONDS', 28_800);
readonly wechatSessionTtlSeconds = positiveInteger('WECHAT_SESSION_TTL_SECONDS', 7_200);
readonly wechatAppId = process.env.WECHAT_APP_ID?.trim() || null;
readonly wechatAppSecret = process.env.WECHAT_APP_SECRET?.trim() || null;
readonly wechatNotificationTemplates = {
APPLICATION_SUBMITTED: process.env.WECHAT_TEMPLATE_APPLICATION_SUBMITTED?.trim() || null,
APPLICATION_ACCEPTED: process.env.WECHAT_TEMPLATE_APPLICATION_ACCEPTED?.trim() || null,
SERVICE_ARRANGED: process.env.WECHAT_TEMPLATE_SERVICE_ARRANGED?.trim() || null,
SERVICE_COMPLETED: process.env.WECHAT_TEMPLATE_SERVICE_COMPLETED?.trim() || null,
FOLLOW_UP_PENDING: process.env.WECHAT_TEMPLATE_FOLLOW_UP_PENDING?.trim() || null,
ACTIVITY_REGISTRATION_RESULT:
process.env.WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT?.trim() || null,
} as const;
readonly notificationWorkerEnabled = parseBoolean('NOTIFICATION_WORKER_ENABLED', false);
readonly objectStorageEndpoint = process.env.OBJECT_STORAGE_ENDPOINT?.trim() || null;
readonly objectStorageRegion = process.env.OBJECT_STORAGE_REGION?.trim() || 'auto';
readonly objectStorageBucket = process.env.OBJECT_STORAGE_BUCKET?.trim() || null;
readonly objectStorageAccessKey = process.env.OBJECT_STORAGE_ACCESS_KEY?.trim() || null;
readonly objectStorageSecretKey = process.env.OBJECT_STORAGE_SECRET_KEY?.trim() || null;
readonly objectStorageForcePathStyle = parseBoolean('OBJECT_STORAGE_FORCE_PATH_STYLE', true);
readonly fieldEncryptionKey = this.parseEncryptionKey();
readonly database = this.parseDatabaseUrl();
get isProduction(): boolean {
return this.appEnvironment === 'production';
}
get objectStorageConfigured(): boolean {
return Boolean(
this.objectStorageEndpoint &&
this.objectStorageBucket &&
this.objectStorageAccessKey &&
this.objectStorageSecretKey,
);
}
get wechatNotificationConfigured(): boolean {
return Boolean(
this.wechatAppId &&
this.wechatAppSecret &&
Object.values(this.wechatNotificationTemplates).every(Boolean),
);
}
private parseEncryptionKey(): Buffer {
const encoded = requiredEnvironmentValue('FIELD_ENCRYPTION_KEY');
const key = Buffer.from(encoded, 'base64');
if (key.length !== 32) {
throw new Error('FIELD_ENCRYPTION_KEY must be a base64-encoded 32-byte key');
}
return key;
}
private parseDatabaseUrl(): DatabaseConnectionOptions {
const url = new URL(this.databaseUrl);
if (url.protocol !== 'mysql:') {
throw new Error('DATABASE_URL must use the mysql protocol');
}
const database = url.pathname.replace(/^\//, '');
if (!url.hostname || !url.username || !database) {
throw new Error('DATABASE_URL must include host, user, and database');
}
return {
host: url.hostname,
port: url.port ? Number(url.port) : 3306,
user: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
database,
};
}
}
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AdminContentController, PublicContentController } from './content.controller';
import { ContentService } from './content.service';
import {
AdminNotificationsController,
MeNotificationsController,
} from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { NotificationWorkerService } from './notification-worker.service';
import {
HttpWechatSubscriptionAdapter,
WechatSubscriptionAdapter,
} from './wechat-subscription.adapter';
@Module({
imports: [AuditModule],
controllers: [
PublicContentController,
AdminContentController,
MeNotificationsController,
AdminNotificationsController,
],
providers: [
ContentService,
NotificationsService,
NotificationWorkerService,
{ provide: WechatSubscriptionAdapter, useClass: HttpWechatSubscriptionAdapter },
],
exports: [NotificationsService],
})
export class ContentNotificationsModule {}
@@ -0,0 +1,91 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Patch,
Post,
Query,
Req,
ValidationPipe,
} from '@nestjs/common';
import {
PERMISSION_CODES,
type ContentPostDetailResponse,
type PaginatedContentPostsResponse,
} from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import { Public } from '../common/decorators/public.decorator';
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import { ContentService } from './content.service';
import {
ContentQueryDto,
CreateContentDto,
PublicContentQueryDto,
TransitionContentDto,
UpdateContentDto,
} from './dto/content-notifications.dto';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Controller('api/v1/content')
@Public()
export class PublicContentController {
constructor(@Inject(ContentService) private readonly service: ContentService) {}
@Get() list(
@Query(strict(PublicContentQueryDto)) q: PublicContentQueryDto,
): Promise<PaginatedContentPostsResponse> {
return this.service.listPublic(q);
}
@Get(':publicId') get(@Param('publicId') id: string): Promise<ContentPostDetailResponse> {
return this.service.getPublic(id);
}
}
@Controller('api/v1/admin/content')
@RequireSessionChannel(SessionChannel.ADMIN)
export class AdminContentController {
constructor(@Inject(ContentService) private readonly service: ContentService) {}
@Get() @RequirePermissions(PERMISSION_CODES.CONTENT_READ) list(
@Query(strict(ContentQueryDto)) q: ContentQueryDto,
) {
return this.service.listAdmin(q);
}
@Get(':publicId') @RequirePermissions(PERMISSION_CODES.CONTENT_READ) get(
@Param('publicId') id: string,
) {
return this.service.getAdmin(id);
}
@Post() @RequirePermissions(PERMISSION_CODES.CONTENT_MANAGE) create(
@Body(strict(CreateContentDto)) input: CreateContentDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.create(input, actor, req);
}
@Patch(':publicId') @RequirePermissions(PERMISSION_CODES.CONTENT_MANAGE) update(
@Param('publicId') id: string,
@Body(strict(UpdateContentDto)) input: UpdateContentDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.update(id, input, actor, req);
}
@Post(':publicId/transition') @RequirePermissions(PERMISSION_CODES.CONTENT_REVIEW) transition(
@Param('publicId') id: string,
@Body(strict(TransitionContentDto)) input: TransitionContentDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.transition(id, input, actor, req);
}
}
@@ -0,0 +1,328 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
type ContentPostDetail,
type ContentPostDetailResponse,
type PaginatedContentPostsResponse,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
ContentQueryDto,
CreateContentDto,
PublicContentQueryDto,
TransitionContentDto,
UpdateContentDto,
} from './dto/content-notifications.dto';
const include = {
coverFile: true,
statusHistory: { orderBy: { createdAt: 'desc' as const } },
} satisfies Prisma.ContentPostInclude;
type Stored = Prisma.ContentPostGetPayload<{ include: typeof include }>;
@Injectable()
export class ContentService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
) {}
listAdmin(q: ContentQueryDto) {
return this.list(
{
...(q.type ? { type: q.type as never } : {}),
...(q.status ? { status: q.status as never } : {}),
},
q.page,
q.pageSize,
);
}
listPublic(q: PublicContentQueryDto) {
return this.list(
{ status: 'PUBLISHED', ...(q.type ? { type: q.type as never } : {}) },
q.page,
q.pageSize,
);
}
async getAdmin(id: string) {
return { post: this.map(await this.find(id)) };
}
async getPublic(id: string) {
const post = await this.find(id);
if (post.status !== 'PUBLISHED') throw this.notFound();
return { post: this.map(post) };
}
async create(
input: CreateContentDto,
actor: AuthContext,
request: RequestContext,
): Promise<ContentPostDetailResponse> {
this.assertPlain(input);
const fp = this.fingerprint('content-create', input);
const replay = await this.prisma.contentPost.findUnique({
where: {
createdById_clientRequestId: {
createdById: actor.userId,
clientRequestId: input.clientRequestId,
},
},
include,
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { post: this.map(replay), idempotentReplay: true };
}
const post = await this.prisma.$transaction(async (tx) => {
const p = await tx.contentPost.create({
data: {
publicId: randomUUID(),
clientRequestId: input.clientRequestId,
requestFingerprint: fp,
type: input.type as never,
title: input.title.trim(),
summary: input.summary.trim(),
content: input.content.trim(),
createdById: actor.userId,
updatedById: actor.userId,
},
});
await tx.contentPostStatusHistory.create({
data: {
contentPostId: p.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
toStatus: 'DRAFT',
operatorUserId: actor.userId,
reasonCode: 'CREATED',
},
});
await this.audit.record(
request,
{
action: 'CONTENT_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'CONTENT_POST',
resourcePublicId: p.publicId,
metadata: { type: input.type },
},
tx,
);
return tx.contentPost.findUniqueOrThrow({ where: { id: p.id }, include });
});
return { post: this.map(post), idempotentReplay: false };
}
async update(id: string, input: UpdateContentDto, actor: AuthContext, request: RequestContext) {
this.assertPlain(input);
const post = await this.find(id);
if (post.status !== 'DRAFT') throw this.invalid();
if (post.revision !== input.expectedRevision) throw this.conflict();
const updated = await this.prisma.$transaction(async (tx) => {
const r = await tx.contentPost.updateMany({
where: { id: post.id, status: 'DRAFT', revision: input.expectedRevision },
data: {
type: input.type as never,
title: input.title.trim(),
summary: input.summary.trim(),
content: input.content.trim(),
updatedById: actor.userId,
revision: { increment: 1 },
privacyReviewedAt: null,
},
});
if (r.count !== 1) throw this.conflict();
await this.audit.record(
request,
{
action: 'CONTENT_UPDATED',
result: 'SUCCESS',
actor,
resourceType: 'CONTENT_POST',
resourcePublicId: id,
},
tx,
);
return tx.contentPost.findUniqueOrThrow({ where: { id: post.id }, include });
});
return { post: this.map(updated) };
}
async transition(
id: string,
input: TransitionContentDto,
actor: AuthContext,
request: RequestContext,
) {
const post = await this.find(id);
const fp = this.fingerprint('content-transition', {
id,
target: input.targetStatus,
note: input.note,
});
const replay = await this.prisma.contentPostStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { contentPost: { include } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { post: this.map(replay.contentPost), idempotentReplay: true };
}
const from = post.status;
const allowed: Record<string, string[]> = {
DRAFT: ['PENDING_REVIEW'],
PENDING_REVIEW: ['PUBLISHED', 'DRAFT'],
PUBLISHED: ['ARCHIVED'],
ARCHIVED: [],
};
if (!allowed[from]?.includes(input.targetStatus)) throw this.invalid();
if (
input.targetStatus === 'PUBLISHED' &&
post.type === 'SERVICE_STORY' &&
input.privacyReviewed !== true
)
throw new ApplicationError(
API_ERROR_CODES.VALIDATION_ERROR,
'服务故事发布前必须确认已完成隐私脱敏审核',
HttpStatus.BAD_REQUEST,
);
const now = new Date();
const updated = await this.prisma.$transaction(async (tx) => {
const r = await tx.contentPost.updateMany({
where: { id: post.id, status: from },
data: {
status: input.targetStatus as never,
updatedById: actor.userId,
revision: { increment: 1 },
...(input.targetStatus === 'PUBLISHED'
? {
reviewedById: actor.userId,
reviewedAt: now,
publishedAt: now,
privacyReviewedAt: post.type === 'SERVICE_STORY' ? now : post.privacyReviewedAt,
}
: {}),
},
});
if (r.count !== 1) throw this.conflict();
await tx.contentPostStatusHistory.create({
data: {
contentPostId: post.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
fromStatus: from,
toStatus: input.targetStatus as never,
operatorUserId: actor.userId,
reasonCode: input.targetStatus === 'DRAFT' ? 'REJECTED' : input.targetStatus,
noteEncrypted: this.sensitive.encrypt(input.note.trim()),
},
});
await this.audit.record(
request,
{
action: 'CONTENT_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'CONTENT_POST',
resourcePublicId: id,
metadata: { fromStatus: from, toStatus: input.targetStatus },
},
tx,
);
return tx.contentPost.findUniqueOrThrow({ where: { id: post.id }, include });
});
return { post: this.map(updated), idempotentReplay: false };
}
private async list(
where: Prisma.ContentPostWhereInput,
page: number,
pageSize: number,
): Promise<PaginatedContentPostsResponse> {
const [total, items] = await this.prisma.$transaction([
this.prisma.contentPost.count({ where }),
this.prisma.contentPost.findMany({
where,
include,
orderBy: [{ publishedAt: 'desc' }, { updatedAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return { items: items.map((p) => this.item(p)), page, pageSize, total };
}
private async find(publicId: string) {
const p = await this.prisma.contentPost.findUnique({ where: { publicId }, include });
if (!p) throw this.notFound();
return p;
}
private item(p: Stored) {
return {
publicId: p.publicId,
type: p.type,
title: p.title,
summary: p.summary,
status: p.status,
revision: p.revision,
publishedAt: p.publishedAt?.toISOString() ?? null,
updatedAt: p.updatedAt.toISOString(),
};
}
private map(p: Stored): ContentPostDetail {
return {
...this.item(p),
content: p.content,
coverFilePublicId: p.coverFile?.publicId ?? null,
privacyReviewedAt: p.privacyReviewedAt?.toISOString() ?? null,
reviewedAt: p.reviewedAt?.toISOString() ?? null,
history: p.statusHistory.map((h) => ({
fromStatus: h.fromStatus,
toStatus: h.toStatus,
reasonCode: h.reasonCode,
note: h.noteEncrypted ? this.sensitive.decrypt(h.noteEncrypted) : null,
createdAt: h.createdAt.toISOString(),
})),
};
}
private assertPlain(v: { title: string; summary: string; content: string }) {
if (/<\s*script|javascript:|on\w+\s*=/i.test(`${v.title}\n${v.summary}\n${v.content}`))
throw new ApplicationError(
API_ERROR_CODES.VALIDATION_ERROR,
'内容包含不安全标记,请使用纯文本或 Markdown',
HttpStatus.BAD_REQUEST,
);
}
private fingerprint(d: string, v: object) {
return this.sensitive.lookupHash(d, JSON.stringify(v));
}
private notFound() {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_NOT_FOUND,
'未找到该内容',
HttpStatus.NOT_FOUND,
);
}
private invalid() {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
'当前内容状态不能执行该操作',
HttpStatus.CONFLICT,
);
}
private conflict() {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'内容已更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private duplicate() {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
}
@@ -0,0 +1,64 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Length,
Min,
ValidateNested,
} from 'class-validator';
import { CONTENT_POST_STATUSES, CONTENT_POST_TYPES, NOTIFICATION_TYPES } from '@lehuo/contracts';
import { PaginationDto } from '../../case-management/dto/case-management.dto';
export class ContentQueryDto extends PaginationDto {
@IsOptional() @IsIn(CONTENT_POST_TYPES) type?: string;
@IsOptional() @IsIn(CONTENT_POST_STATUSES) status?: string;
}
export class PublicContentQueryDto extends PaginationDto {
@IsOptional() @IsIn(CONTENT_POST_TYPES) type?: string;
}
export class CreateContentDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(CONTENT_POST_TYPES) type!: string;
@IsString() @Length(2, 200) title!: string;
@IsString() @Length(10, 500) summary!: string;
@IsString() @Length(20, 50000) content!: string;
@IsOptional() @IsUUID('4') coverFilePublicId?: string;
}
export class UpdateContentDto {
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@IsIn(CONTENT_POST_TYPES) type!: string;
@IsString() @Length(2, 200) title!: string;
@IsString() @Length(10, 500) summary!: string;
@IsString() @Length(20, 50000) content!: string;
@IsOptional() @IsUUID('4') coverFilePublicId?: string;
}
export class TransitionContentDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(CONTENT_POST_STATUSES) targetStatus!: string;
@IsString() @Length(2, 500) note!: string;
@IsOptional() @IsIn([true]) privacyReviewed?: true;
}
export class NotificationQueryDto extends PaginationDto {
@IsOptional() @IsBoolean() @Type(() => Boolean) unreadOnly?: boolean;
}
export class PreferenceInputDto {
@IsIn(NOTIFICATION_TYPES) type!: string;
@IsBoolean() inAppEnabled!: boolean;
@IsBoolean() wechatEnabled!: boolean;
}
export class UpdatePreferencesDto {
@IsArray()
@ArrayMaxSize(NOTIFICATION_TYPES.length)
@ValidateNested({ each: true })
@Type(() => PreferenceInputDto)
items!: PreferenceInputDto[];
}
export class DeliveryQueryDto extends PaginationDto {
@IsOptional() @IsIn(['PENDING', 'PROCESSING', 'SENT', 'FAILED', 'DEAD']) status?: string;
}
@@ -0,0 +1,19 @@
import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
import { AppConfigService } from '../config/app-config.service';
import { NotificationsService } from './notifications.service';
@Injectable()
export class NotificationWorkerService implements OnModuleInit, OnModuleDestroy {
private timer?: ReturnType<typeof setInterval>;
constructor(
@Inject(AppConfigService) private readonly config: AppConfigService,
@Inject(NotificationsService) private readonly notifications: NotificationsService,
) {}
onModuleInit() {
if (!this.config.notificationWorkerEnabled) return;
this.timer = setInterval(() => void this.notifications.processBatch(), 30_000);
this.timer.unref();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
}
@@ -0,0 +1,78 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Post,
Put,
Query,
Req,
ValidationPipe,
} from '@nestjs/common';
import { PERMISSION_CODES } from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import {
DeliveryQueryDto,
NotificationQueryDto,
UpdatePreferencesDto,
} from './dto/content-notifications.dto';
import { NotificationsService } from './notifications.service';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Controller('api/v1/me/notifications')
@RequireSessionChannel(SessionChannel.WECHAT)
export class MeNotificationsController {
constructor(@Inject(NotificationsService) private readonly service: NotificationsService) {}
@Get() list(
@Query(strict(NotificationQueryDto)) q: NotificationQueryDto,
@CurrentAuth() a: AuthContext,
) {
return this.service.list(q, a);
}
@Post(':publicId/read') read(
@Param('publicId') id: string,
@CurrentAuth() a: AuthContext,
@Req() r: RequestContext,
) {
return this.service.read(id, a, r);
}
@Get('preferences') preferences(@CurrentAuth() a: AuthContext) {
return this.service.preferences(a);
}
@Put('preferences') update(
@Body(strict(UpdatePreferencesDto)) i: UpdatePreferencesDto,
@CurrentAuth() a: AuthContext,
@Req() r: RequestContext,
) {
return this.service.updatePreferences(i, a, r);
}
}
@Controller('api/v1/admin/notification-deliveries')
@RequireSessionChannel(SessionChannel.ADMIN)
@RequirePermissions(PERMISSION_CODES.NOTIFICATIONS_MANAGE)
export class AdminNotificationsController {
constructor(@Inject(NotificationsService) private readonly service: NotificationsService) {}
@Get() list(@Query(strict(DeliveryQueryDto)) q: DeliveryQueryDto) {
return this.service.deliveries(q);
}
@Post(':publicId/retry') retry(
@Param('publicId') id: string,
@CurrentAuth() a: AuthContext,
@Req() r: RequestContext,
) {
return this.service.retry(id, a, r);
}
@Post('process') process() {
return this.service.processBatch();
}
}
@@ -0,0 +1,282 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
NOTIFICATION_TYPES,
type NotificationPreferencesResponse,
type PaginatedNotificationDeliveriesResponse,
type PaginatedNotificationsResponse,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { AppConfigService } from '../config/app-config.service';
import { PrismaService } from '../database/prisma.service';
import type { NotificationType, Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
DeliveryQueryDto,
NotificationQueryDto,
UpdatePreferencesDto,
} from './dto/content-notifications.dto';
import { WechatSubscriptionAdapter } from './wechat-subscription.adapter';
@Injectable()
export class NotificationsService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(AppConfigService) private readonly config: AppConfigService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
@Inject(WechatSubscriptionAdapter) private readonly wechat: WechatSubscriptionAdapter,
) {}
async list(q: NotificationQueryDto, actor: AuthContext): Promise<PaginatedNotificationsResponse> {
const where = { userId: actor.userId, ...(q.unreadOnly ? { readAt: null } : {}) };
const [total, unreadCount, items] = await this.prisma.$transaction([
this.prisma.notification.count({ where }),
this.prisma.notification.count({ where: { userId: actor.userId, readAt: null } }),
this.prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return {
items: items.map((n) => ({
publicId: n.publicId,
type: n.type,
title: n.title,
body: n.body,
resourceType: n.resourceType,
resourcePublicId: n.resourcePublicId,
readAt: n.readAt?.toISOString() ?? null,
createdAt: n.createdAt.toISOString(),
})),
page: q.page,
pageSize: q.pageSize,
total,
unreadCount,
};
}
async read(id: string, actor: AuthContext, request: RequestContext) {
const n = await this.prisma.notification.findFirst({
where: { publicId: id, userId: actor.userId },
});
if (!n)
throw new ApplicationError(
API_ERROR_CODES.RESOURCE_NOT_FOUND,
'未找到该消息',
HttpStatus.NOT_FOUND,
);
if (n.readAt) return { read: true, idempotentReplay: true };
await this.prisma.$transaction(async (tx) => {
await tx.notification.update({ where: { id: n.id }, data: { readAt: new Date() } });
await this.audit.record(
request,
{
action: 'NOTIFICATION_READ',
result: 'SUCCESS',
actor,
resourceType: 'NOTIFICATION',
resourcePublicId: id,
},
tx,
);
});
return { read: true, idempotentReplay: false };
}
async preferences(actor: AuthContext): Promise<NotificationPreferencesResponse> {
const stored = await this.prisma.notificationPreference.findMany({
where: { userId: actor.userId },
});
return {
items: NOTIFICATION_TYPES.map((type) => {
const p = stored.find((v) => v.type === type);
return {
type,
inAppEnabled: p?.inAppEnabled ?? true,
wechatEnabled: p?.wechatEnabled ?? false,
authorizedAt: p?.authorizedAt?.toISOString() ?? null,
};
}),
wechatAdapterConfigured: this.wechat.configured,
};
}
async updatePreferences(
input: UpdatePreferencesDto,
actor: AuthContext,
request: RequestContext,
) {
if (new Set(input.items.map((i) => i.type)).size !== input.items.length)
throw new ApplicationError(
API_ERROR_CODES.VALIDATION_ERROR,
'通知类型不能重复',
HttpStatus.BAD_REQUEST,
);
await this.prisma.$transaction(async (tx) => {
for (const item of input.items)
await tx.notificationPreference.upsert({
where: { userId_type: { userId: actor.userId, type: item.type as NotificationType } },
create: {
userId: actor.userId,
type: item.type as NotificationType,
inAppEnabled: item.inAppEnabled,
wechatEnabled: item.wechatEnabled,
authorizedAt: item.wechatEnabled ? new Date() : null,
},
update: {
inAppEnabled: item.inAppEnabled,
wechatEnabled: item.wechatEnabled,
authorizedAt: item.wechatEnabled ? new Date() : null,
},
});
await this.audit.record(
request,
{
action: 'NOTIFICATION_PREFERENCES_UPDATED',
result: 'SUCCESS',
actor,
resourceType: 'USER',
resourcePublicId: actor.userPublicId,
metadata: { preferenceCount: input.items.length },
},
tx,
);
});
return this.preferences(actor);
}
async create(
tx: Prisma.TransactionClient,
input: {
userId: bigint;
eventKey: string;
type: NotificationType;
title: string;
body: string;
resourceType?: string;
resourcePublicId?: string;
},
) {
const pref = await tx.notificationPreference.findUnique({
where: { userId_type: { userId: input.userId, type: input.type } },
});
if (pref?.inAppEnabled === false && !pref.wechatEnabled) return null;
const n = await tx.notification.upsert({
where: { userId_eventKey: { userId: input.userId, eventKey: input.eventKey } },
create: { publicId: randomUUID(), ...input },
update: {},
});
if (pref?.wechatEnabled)
await tx.notificationDelivery.upsert({
where: { notificationId_channel: { notificationId: n.id, channel: 'WECHAT_SUBSCRIPTION' } },
create: { publicId: randomUUID(), notificationId: n.id, channel: 'WECHAT_SUBSCRIPTION' },
update: {},
});
return n;
}
async deliveries(q: DeliveryQueryDto): Promise<PaginatedNotificationDeliveriesResponse> {
const where = q.status ? { status: q.status as never } : {};
const [total, rows] = await this.prisma.$transaction([
this.prisma.notificationDelivery.count({ where }),
this.prisma.notificationDelivery.findMany({
where,
include: { notification: true },
orderBy: { createdAt: 'desc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return {
items: rows.map((d) => ({
publicId: d.publicId,
notificationPublicId: d.notification.publicId,
type: d.notification.type,
channel: d.channel,
status: d.status,
attemptCount: d.attemptCount,
nextAttemptAt: d.nextAttemptAt.toISOString(),
lastAttemptAt: d.lastAttemptAt?.toISOString() ?? null,
errorCode: d.errorCode,
createdAt: d.createdAt.toISOString(),
})),
page: q.page,
pageSize: q.pageSize,
total,
};
}
async retry(id: string, actor: AuthContext, request: RequestContext) {
const d = await this.prisma.notificationDelivery.findUnique({ where: { publicId: id } });
if (!d)
throw new ApplicationError(
API_ERROR_CODES.RESOURCE_NOT_FOUND,
'未找到该投递任务',
HttpStatus.NOT_FOUND,
);
await this.prisma.$transaction(async (tx) => {
await tx.notificationDelivery.update({
where: { id: d.id },
data: { status: 'PENDING', nextAttemptAt: new Date(), errorCode: null },
});
await this.audit.record(
request,
{
action: 'NOTIFICATION_DELIVERY_RETRIED',
result: 'SUCCESS',
actor,
resourceType: 'NOTIFICATION_DELIVERY',
resourcePublicId: id,
},
tx,
);
});
return { queued: true };
}
async processBatch(limit = 20) {
if (!this.wechat.configured) return { processed: 0, unavailable: true };
const jobs = await this.prisma.notificationDelivery.findMany({
where: { status: { in: ['PENDING', 'FAILED'] }, nextAttemptAt: { lte: new Date() } },
include: { notification: { include: { user: { include: { wechatIdentities: true } } } } },
orderBy: { nextAttemptAt: 'asc' },
take: limit,
});
let processed = 0;
for (const job of jobs) {
const claimed = await this.prisma.notificationDelivery.updateMany({
where: { id: job.id, status: job.status },
data: { status: 'PROCESSING', lastAttemptAt: new Date(), attemptCount: { increment: 1 } },
});
if (!claimed.count) continue;
processed++;
try {
const identity = job.notification.user.wechatIdentities.find(
(i) => i.appid === this.config.wechatAppId,
);
if (!identity) throw new Error('WECHAT_IDENTITY_NOT_FOUND');
const result = await this.wechat.send({
openid: this.sensitive.decrypt(identity.openidCiphertext),
type: job.notification.type,
title: job.notification.title,
body: job.notification.body,
resourcePublicId: job.notification.resourcePublicId,
});
await this.prisma.notificationDelivery.update({
where: { id: job.id },
data: { status: 'SENT', providerMessageId: result.providerMessageId, errorCode: null },
});
} catch (error) {
const attempts = job.attemptCount + 1;
const code = error instanceof Error ? error.message.slice(0, 100) : 'WECHAT_SEND_FAILED';
await this.prisma.notificationDelivery.update({
where: { id: job.id },
data: {
status: attempts >= 5 ? 'DEAD' : 'FAILED',
errorCode: code,
nextAttemptAt: new Date(Date.now() + Math.min(3600, 2 ** attempts * 30) * 1000),
},
});
}
}
return { processed, unavailable: false };
}
}
@@ -0,0 +1,64 @@
import { Inject, Injectable } from '@nestjs/common';
import type { NotificationType } from '@lehuo/contracts';
import { AppConfigService } from '../config/app-config.service';
export interface SubscriptionMessage {
openid: string;
type: NotificationType;
title: string;
body: string;
resourcePublicId: string | null;
}
export interface SubscriptionSendResult {
providerMessageId: string | null;
}
export abstract class WechatSubscriptionAdapter {
abstract get configured(): boolean;
abstract send(message: SubscriptionMessage): Promise<SubscriptionSendResult>;
}
@Injectable()
export class HttpWechatSubscriptionAdapter extends WechatSubscriptionAdapter {
constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {
super();
}
get configured() {
return this.config.wechatNotificationConfigured;
}
async send(message: SubscriptionMessage) {
if (!this.config.wechatAppId || !this.config.wechatAppSecret)
throw new Error('WECHAT_NOT_CONFIGURED');
const templateId = this.config.wechatNotificationTemplates[message.type];
if (!templateId) throw new Error('WECHAT_TEMPLATE_NOT_CONFIGURED');
const tokenResponse = await fetch(
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(this.config.wechatAppId)}&secret=${encodeURIComponent(this.config.wechatAppSecret)}`,
);
const token = (await tokenResponse.json()) as { access_token?: string; errcode?: number };
if (!tokenResponse.ok || !token.access_token)
throw new Error(`WECHAT_TOKEN_${token.errcode ?? 'FAILED'}`);
const response = await fetch(
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${encodeURIComponent(token.access_token)}`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
touser: message.openid,
template_id: templateId,
page: message.resourcePublicId
? `pages/index/index?resource=${message.resourcePublicId}`
: 'pages/index/index',
data: {
thing1: { value: message.title.slice(0, 20) },
thing2: { value: message.body.slice(0, 20) },
},
}),
},
);
const result = (await response.json()) as {
errcode?: number;
errmsg?: string;
msgid?: string | number;
};
if (!response.ok || result.errcode !== 0)
throw new Error(`WECHAT_SEND_${result.errcode ?? 'FAILED'}`);
return { providerMessageId: result.msgid?.toString() ?? null };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class DatabaseModule {}
+26
View File
@@ -0,0 +1,26 @@
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
import { Inject, Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
import { AppConfigService } from '../config/app-config.service';
import { PrismaClient } from '../generated/prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor(@Inject(AppConfigService) config: AppConfigService) {
const adapter = new PrismaMariaDb({
...config.database,
connectionLimit: 10,
connectTimeout: 5_000,
acquireTimeout: 10_000,
});
super({ adapter });
}
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { OBJECT_STORAGE_ADAPTER } from './object-storage.adapter';
import { FilesRepository } from './files.repository';
import { S3ObjectStorageAdapter } from './s3-object-storage.adapter';
@Module({
imports: [AuditModule],
providers: [
S3ObjectStorageAdapter,
FilesRepository,
{ provide: OBJECT_STORAGE_ADAPTER, useExisting: S3ObjectStorageAdapter },
],
exports: [OBJECT_STORAGE_ADAPTER, FilesRepository],
})
export class FilesModule {}
+124
View File
@@ -0,0 +1,124 @@
import { Inject, Injectable } from '@nestjs/common';
import { AuditService } from '../audit/audit.service';
import type { AuthContext, RequestContext } from '../common/request-context';
import type { FileBusinessType } from '../generated/prisma/enums';
import { PrismaService } from '../database/prisma.service';
@Injectable()
export class FilesRepository {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(AuditService) private readonly audit: AuditService,
) {}
findByIdempotency(uploaderUserId: bigint, clientRequestId: string) {
return this.prisma.file.findUnique({
where: { uploaderUserId_clientRequestId: { uploaderUserId, clientRequestId } },
});
}
findByPublicId(publicId: string) {
return this.prisma.file.findUnique({ where: { publicId } });
}
async createPending(
input: {
publicId: string;
caseId?: bigint;
businessType?: FileBusinessType;
clientRequestId: string;
requestFingerprint: string;
provider: string;
bucket: string;
objectKey: string;
filenameEncrypted: string;
mimeType: string;
size: bigint;
sha256?: string;
},
actor: AuthContext,
request: RequestContext,
) {
return this.prisma.$transaction(async (transaction) => {
const file = await transaction.file.create({
data: {
publicId: input.publicId,
caseId: input.caseId,
clientRequestId: input.clientRequestId,
requestFingerprint: input.requestFingerprint,
storageProvider: input.provider,
bucket: input.bucket,
objectKey: input.objectKey,
originalFilenameEncrypted: input.filenameEncrypted,
mimeType: input.mimeType,
size: input.size,
sha256: input.sha256,
businessType: input.businessType ?? 'SERVICE_RECORD',
uploaderUserId: actor.userId,
},
});
await this.audit.record(
request,
{
action: 'FILE_UPLOAD_INTENT_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'FILE',
resourcePublicId: file.publicId,
metadata: {
businessType: input.businessType ?? 'SERVICE_RECORD',
mimeType: input.mimeType,
size: Number(input.size),
},
},
transaction,
);
return file;
});
}
async markAvailable(publicId: string, actor: AuthContext, request: RequestContext) {
return this.prisma.$transaction(async (transaction) => {
const file = await transaction.file.update({
where: { publicId },
data: { status: 'AVAILABLE' },
});
await this.audit.record(
request,
{
action: 'FILE_UPLOAD_CONFIRMED',
result: 'SUCCESS',
actor,
resourceType: 'FILE',
resourcePublicId: publicId,
},
transaction,
);
return file;
});
}
async quarantine(
publicId: string,
actor: AuthContext,
request: RequestContext,
reasonCode: string,
) {
await this.prisma.$transaction(async (transaction) => {
await transaction.file.update({ where: { publicId }, data: { status: 'QUARANTINED' } });
await this.audit.record(
request,
{
action: 'FILE_UPLOAD_REJECTED',
result: 'FAILURE',
actor,
resourceType: 'FILE',
resourcePublicId: publicId,
metadata: { reasonCode },
},
transaction,
);
});
}
}
+246
View File
@@ -0,0 +1,246 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
type ConfirmUploadResponse,
type CreateUploadIntentResponse,
type DownloadUrlResponse,
type ServiceRecordAttachment,
} from '@lehuo/contracts';
import { CaseManagementService } from '../case-management/case-management.service';
import type { CreateUploadIntentDto } from '../case-management/dto/case-management.dto';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import type { File } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import { FilesRepository } from './files.repository';
import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from './object-storage.adapter';
const EXTENSIONS: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};
@Injectable()
export class FilesService {
constructor(
@Inject(FilesRepository) private readonly repository: FilesRepository,
@Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter,
@Inject(CaseManagementService) private readonly cases: CaseManagementService,
@Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
) {}
async createUploadIntent(
casePublicId: string,
input: CreateUploadIntentDto,
actor: AuthContext,
request: RequestContext,
): Promise<CreateUploadIntentResponse> {
this.assertConfigured();
const serviceCase = await this.cases.requireAssignedCase(casePublicId, actor);
const filename = this.safeFilename(input.filename);
const extension = EXTENSIONS[input.mimeType];
if (
!extension ||
(!filename.toLowerCase().endsWith(`.${extension}`) &&
!(extension === 'jpg' && filename.toLowerCase().endsWith('.jpeg')))
) {
throw this.validation('文件扩展名与图片类型不一致');
}
const normalized = {
casePublicId,
filename,
mimeType: input.mimeType,
size: input.size,
sha256: input.sha256 ?? null,
};
const fingerprint = this.sensitiveData.lookupHash(
'file-upload-intent',
JSON.stringify(normalized),
);
const previous = await this.repository.findByIdempotency(actor.userId, input.clientRequestId);
if (previous) {
if (previous.requestFingerprint !== fingerprint) throw this.duplicateRequest();
return this.uploadResponse(previous, true);
}
const now = new Date();
const objectKey = `service-records/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
const file = await this.repository.createPending(
{
publicId: randomUUID(),
caseId: serviceCase.id,
clientRequestId: input.clientRequestId,
requestFingerprint: fingerprint,
provider: this.storage.providerName,
bucket: this.storage.bucket ?? '',
objectKey,
filenameEncrypted: this.sensitiveData.encrypt(filename),
mimeType: input.mimeType,
size: BigInt(input.size),
sha256: input.sha256,
},
actor,
request,
);
return this.uploadResponse(file, false);
}
async confirm(
publicId: string,
actor: AuthContext,
request: RequestContext,
): Promise<ConfirmUploadResponse> {
this.assertConfigured();
const file = await this.repository.findByPublicId(publicId);
if (!file) throw this.notFound();
if (file.caseId === null || file.businessType !== 'SERVICE_RECORD') throw this.notFound();
await this.cases.requireAssignedCaseByIdForFile(file.caseId, actor);
if (file.uploaderUserId !== actor.userId) throw this.notFound();
if (file.status === 'AVAILABLE') return { file: this.toAttachment(file) };
if (file.status !== 'UPLOAD_PENDING') {
throw new ApplicationError(
API_ERROR_CODES.UPLOAD_NOT_READY,
'该附件当前不能确认,请重新选择文件',
HttpStatus.CONFLICT,
);
}
let metadata;
try {
metadata = await this.storage.headObject(file.objectKey);
} catch {
throw this.storageUnavailable();
}
if (!metadata) {
throw new ApplicationError(
API_ERROR_CODES.UPLOAD_NOT_READY,
'尚未收到上传文件,请完成上传后重试',
HttpStatus.CONFLICT,
);
}
const mismatch =
metadata.contentLength !== Number(file.size) ||
metadata.contentType !== file.mimeType ||
(file.sha256 !== null && metadata.sha256 !== file.sha256);
if (mismatch) {
await this.repository.quarantine(publicId, actor, request, 'OBJECT_METADATA_MISMATCH');
throw this.validation('附件校验未通过,请重新选择文件');
}
return {
file: this.toAttachment(await this.repository.markAvailable(publicId, actor, request)),
};
}
async downloadUrl(
publicId: string,
actor: AuthContext,
request: RequestContext,
): Promise<DownloadUrlResponse> {
this.assertConfigured();
const file = await this.repository.findByPublicId(publicId);
if (!file || file.status !== 'AVAILABLE') throw this.notFound();
if (file.caseId === null || file.businessType !== 'SERVICE_RECORD') throw this.notFound();
await this.cases.requireAssignedCaseByIdForFile(file.caseId, actor);
const expiresIn = 300;
let downloadUrl: string;
try {
downloadUrl = await this.storage.createDownloadUrl(file.objectKey, expiresIn);
} catch {
throw this.storageUnavailable();
}
await this.audit.record(request, {
action: 'PRIVATE_FILE_DOWNLOAD_URL_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'FILE',
resourcePublicId: publicId,
});
return {
downloadUrl,
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
};
}
private async uploadResponse(file: File, idempotentReplay: boolean) {
const expiresIn = 600;
let uploadUrl: string;
try {
uploadUrl = await this.storage.createUploadUrl(
file.objectKey,
file.mimeType,
file.sha256 ?? undefined,
expiresIn,
);
} catch {
throw this.storageUnavailable();
}
return {
file: this.toAttachment(file),
uploadUrl,
uploadHeaders: {
'Content-Type': file.mimeType,
...(file.sha256 ? { 'x-amz-meta-sha256': file.sha256 } : {}),
},
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
idempotentReplay,
} satisfies CreateUploadIntentResponse;
}
private safeFilename(value: string): string {
const filename = value.trim();
if (
!filename ||
filename.includes('/') ||
filename.includes('\\') ||
Array.from(filename).some((character) => character.charCodeAt(0) <= 31)
) {
throw this.validation('文件名不合法');
}
return filename;
}
private toAttachment(file: File): ServiceRecordAttachment {
return {
publicId: file.publicId,
filename: this.sensitiveData.decrypt(file.originalFilenameEncrypted),
mimeType: file.mimeType,
size: Number(file.size),
};
}
private assertConfigured(): void {
if (!this.storage.configured || !this.storage.bucket) throw this.storageUnavailable();
}
private storageUnavailable(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.STORAGE_UNAVAILABLE,
'附件服务尚未配置或暂时不可用,请稍后重试',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private duplicateRequest(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'本次上传标识已用于其他文件,请重新选择',
HttpStatus.CONFLICT,
);
}
private notFound(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.FILE_NOT_FOUND,
'未找到该附件',
HttpStatus.NOT_FOUND,
);
}
}
@@ -0,0 +1,21 @@
export interface StoredObjectMetadata {
contentLength: number;
contentType: string | null;
sha256: string | null;
}
export interface ObjectStorageAdapter {
readonly configured: boolean;
readonly providerName: string;
readonly bucket: string | null;
createUploadUrl(
objectKey: string,
mimeType: string,
sha256: string | undefined,
expiresInSeconds: number,
): Promise<string>;
createDownloadUrl(objectKey: string, expiresInSeconds: number): Promise<string>;
headObject(objectKey: string): Promise<StoredObjectMetadata | null>;
}
export const OBJECT_STORAGE_ADAPTER = Symbol('OBJECT_STORAGE_ADAPTER');
@@ -0,0 +1,102 @@
import {
GetObjectCommand,
HeadObjectCommand,
NotFound,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Inject, Injectable } from '@nestjs/common';
import { AppConfigService } from '../config/app-config.service';
import type { ObjectStorageAdapter, StoredObjectMetadata } from './object-storage.adapter';
@Injectable()
export class S3ObjectStorageAdapter implements ObjectStorageAdapter {
readonly providerName = 'S3_COMPATIBLE';
private readonly client: S3Client | null;
constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {
this.client = config.objectStorageConfigured
? new S3Client({
endpoint: config.objectStorageEndpoint ?? undefined,
region: config.objectStorageRegion,
forcePathStyle: config.objectStorageForcePathStyle,
credentials: {
accessKeyId: config.objectStorageAccessKey ?? '',
secretAccessKey: config.objectStorageSecretKey ?? '',
},
})
: null;
}
get configured(): boolean {
return this.client !== null;
}
get bucket(): string | null {
return this.config.objectStorageBucket;
}
createUploadUrl(
objectKey: string,
mimeType: string,
sha256: string | undefined,
expiresInSeconds: number,
): Promise<string> {
return getSignedUrl(
this.requiredClient(),
new PutObjectCommand({
Bucket: this.requiredBucket(),
Key: objectKey,
ContentType: mimeType,
Metadata: sha256 ? { sha256 } : undefined,
}),
{ expiresIn: expiresInSeconds },
);
}
createDownloadUrl(objectKey: string, expiresInSeconds: number): Promise<string> {
return getSignedUrl(
this.requiredClient(),
new GetObjectCommand({ Bucket: this.requiredBucket(), Key: objectKey }),
{ expiresIn: expiresInSeconds },
);
}
async headObject(objectKey: string): Promise<StoredObjectMetadata | null> {
try {
const result = await this.requiredClient().send(
new HeadObjectCommand({ Bucket: this.requiredBucket(), Key: objectKey }),
);
return {
contentLength: result.ContentLength ?? 0,
contentType: result.ContentType ?? null,
sha256: result.Metadata?.sha256 ?? null,
};
} catch (error) {
if (
error instanceof NotFound ||
(this.isMetadataError(error) && error.$metadata.httpStatusCode === 404)
) {
return null;
}
throw error;
}
}
private requiredClient(): S3Client {
if (!this.client) throw new Error('Object storage is not configured');
return this.client;
}
private requiredBucket(): string {
const bucket = this.config.objectStorageBucket;
if (!bucket) throw new Error('Object storage bucket is not configured');
return bucket;
}
private isMetadataError(error: unknown): error is { $metadata: { httpStatusCode?: number } } {
return typeof error === 'object' && error !== null && '$metadata' in error;
}
}
@@ -0,0 +1,103 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Post,
Query,
Req,
ValidationPipe,
} from '@nestjs/common';
import {
PERMISSION_CODES,
type CompleteServiceCaseResponse,
type FollowUpDetailResponse,
type FollowUpMutationResponse,
type FollowUpWorkbenchSummary,
type PaginatedFollowUpsResponse,
} from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import {
CompleteFollowUpDto,
CompleteServiceCaseDto,
CreateFollowUpDto,
FollowUpQueryDto,
} from './dto/follow-up.dto';
import { FollowUpsService } from './follow-ups.service';
const strictValidation = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Controller('api/v1/admin')
@RequireSessionChannel(SessionChannel.ADMIN)
export class AdminFollowUpsController {
constructor(@Inject(FollowUpsService) private readonly followUps: FollowUpsService) {}
@Post('service-cases/:publicId/complete')
@RequirePermissions(PERMISSION_CODES.CASES_MANAGE, PERMISSION_CODES.FOLLOW_UPS_MANAGE)
completeCase(
@Param('publicId') publicId: string,
@Body(strictValidation(CompleteServiceCaseDto)) input: CompleteServiceCaseDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<CompleteServiceCaseResponse> {
return this.followUps.completeCase(publicId, input, actor, request);
}
@Get('follow-ups/summary')
@RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_READ)
summary(@CurrentAuth() actor: AuthContext): Promise<FollowUpWorkbenchSummary> {
return this.followUps.summary(actor);
}
@Get('follow-ups')
@RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_READ)
list(
@Query(strictValidation(FollowUpQueryDto)) query: FollowUpQueryDto,
@CurrentAuth() actor: AuthContext,
): Promise<PaginatedFollowUpsResponse> {
return this.followUps.list(query, actor);
}
@Post('follow-ups')
@RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_MANAGE)
create(
@Body(strictValidation(CreateFollowUpDto)) input: CreateFollowUpDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<FollowUpMutationResponse> {
return this.followUps.create(input, actor, request);
}
@Get('follow-ups/:publicId')
@RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_READ)
get(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
): Promise<FollowUpDetailResponse> {
return this.followUps.get(publicId, actor);
}
@Post('follow-ups/:publicId/complete')
@RequirePermissions(PERMISSION_CODES.FOLLOW_UPS_COMPLETE)
complete(
@Param('publicId') publicId: string,
@Body(strictValidation(CompleteFollowUpDto)) input: CompleteFollowUpDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<FollowUpMutationResponse> {
return this.followUps.complete(publicId, input, actor, request);
}
}
@@ -0,0 +1,113 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { FOLLOW_UP_RESULT_CODES, FOLLOW_UP_STATUSES, FOLLOW_UP_TYPES } from '@lehuo/contracts';
export class CompleteServiceCaseDto {
@IsUUID('4')
clientRequestId!: string;
@IsString()
@MinLength(2)
@MaxLength(5000)
resultSummary!: string;
@IsString()
@MinLength(2)
@MaxLength(2000)
followUpPlan!: string;
@IsISO8601({ strict: true })
followUpDueAt!: string;
}
export class CreateFollowUpDto {
@IsUUID('4')
clientRequestId!: string;
@IsUUID('4')
beneficiaryPublicId!: string;
@IsUUID('4')
ownerStaffPublicId!: string;
@IsOptional()
@IsUUID('4')
serviceCasePublicId?: string;
@IsIn(FOLLOW_UP_TYPES.filter((value) => value !== 'SERVICE_COMPLETION'))
followUpType!: 'KEY_CARE' | 'MANUAL';
@IsISO8601({ strict: true })
dueAt!: string;
@IsString()
@MinLength(2)
@MaxLength(2000)
reason!: string;
}
export class CompleteFollowUpDto {
@IsUUID('4')
clientRequestId!: string;
@IsIn(FOLLOW_UP_RESULT_CODES)
resultCode!: (typeof FOLLOW_UP_RESULT_CODES)[number];
@IsString()
@MinLength(2)
@MaxLength(5000)
resultNote!: string;
@IsOptional()
@IsIn([true])
continuationConsentConfirmed?: true;
@IsOptional()
@IsString()
@MinLength(10)
@MaxLength(5000)
continuationDescription?: string;
}
export class FollowUpQueryDto {
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 20;
@IsOptional()
@IsIn(FOLLOW_UP_STATUSES)
status?: (typeof FOLLOW_UP_STATUSES)[number];
@IsOptional()
@IsIn(FOLLOW_UP_TYPES)
followUpType?: (typeof FOLLOW_UP_TYPES)[number];
@IsOptional()
@Transform(({ value }) => {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return value as unknown;
})
@IsBoolean()
overdue?: boolean;
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { ContentNotificationsModule } from '../content-notifications/content-notifications.module';
import { AdminFollowUpsController } from './admin-follow-ups.controller';
import { FollowUpsRepository } from './follow-ups.repository';
import { FollowUpsService } from './follow-ups.service';
@Module({
imports: [AuditModule, ContentNotificationsModule],
controllers: [AdminFollowUpsController],
providers: [FollowUpsRepository, FollowUpsService],
})
export class FollowUpsModule {}
@@ -0,0 +1,466 @@
import { Inject, Injectable } from '@nestjs/common';
import type { FollowUpResultCode, FollowUpStatus, FollowUpType } from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { Prisma } from '../generated/prisma/client';
import { NotificationsService } from '../content-notifications/notifications.service';
const followUpInclude = {
beneficiary: true,
serviceCase: { include: { application: true } },
owner: { include: { staffProfile: true } },
continuedApplication: true,
statusHistory: { orderBy: { createdAt: 'asc' as const } },
} satisfies Prisma.FollowUpTaskInclude;
export type StoredFollowUp = Prisma.FollowUpTaskGetPayload<{ include: typeof followUpInclude }>;
export interface CompleteCaseInput {
requestId: string;
fingerprint: string;
resultSummaryEncrypted: string;
followUpPlanEncrypted: string;
followUpDueAt: Date;
followUpPublicId: string;
followUpNo: string;
followUpReasonEncrypted: string;
}
export interface CreateFollowUpInput {
requestId: string;
fingerprint: string;
publicId: string;
followUpNo: string;
beneficiaryId: bigint;
caseId?: bigint;
ownerStaffId: bigint;
dueAt: Date;
followUpType: 'KEY_CARE' | 'MANUAL';
reasonEncrypted: string;
}
export interface CompleteFollowUpInput {
requestId: string;
fingerprint: string;
resultCode: FollowUpResultCode;
resultNoteEncrypted: string;
continuation?: {
publicId: string;
applicationNo: string;
requestFingerprint: string;
consentPublicId: string;
descriptionEncrypted: string;
};
}
@Injectable()
export class FollowUpsRepository {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(AuditService) private readonly audit: AuditService,
@Inject(NotificationsService) private readonly notifications: NotificationsService,
) {}
findCase(publicId: string) {
return this.prisma.serviceCase.findUnique({
where: { publicId },
include: {
application: true,
beneficiary: true,
owner: { include: { staffProfile: true } },
_count: { select: { serviceRecords: true } },
followUpTasks: { where: { source: 'AUTO_SERVICE_COMPLETION' }, include: followUpInclude },
},
});
}
findBeneficiary(publicId: string) {
return this.prisma.beneficiaryProfile.findUnique({ where: { publicId } });
}
findStaff(publicId: string) {
return this.prisma.user.findFirst({
where: { publicId, status: 'ACTIVE', userType: 'STAFF', staffProfile: { isNot: null } },
include: { staffProfile: true },
});
}
findByCreateRequest(createdById: bigint, requestId: string) {
return this.prisma.followUpTask.findUnique({
where: { createdById_createRequestId: { createdById, createRequestId: requestId } },
include: followUpInclude,
});
}
find(publicId: string) {
return this.prisma.followUpTask.findUnique({ where: { publicId }, include: followUpInclude });
}
async list(
filters: { status?: FollowUpStatus; followUpType?: FollowUpType; overdue?: boolean },
actorUserId: bigint,
accessAll: boolean,
page: number,
pageSize: number,
now: Date,
) {
const where: Prisma.FollowUpTaskWhereInput = {
...(accessAll ? {} : { ownerStaffId: actorUserId }),
...(filters.status ? { status: filters.status } : {}),
...(filters.followUpType ? { followUpType: filters.followUpType } : {}),
...(filters.overdue === true ? { status: 'PENDING', dueAt: { lt: now } } : {}),
};
const [items, total] = await this.prisma.$transaction([
this.prisma.followUpTask.findMany({
where,
include: followUpInclude,
orderBy: [{ dueAt: 'asc' }, { id: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.followUpTask.count({ where }),
]);
return { items, total };
}
async summary(
actorUserId: bigint,
accessAll: boolean,
start: Date,
tomorrow: Date,
now: Date,
weekEnd: Date,
) {
const base: Prisma.FollowUpTaskWhereInput = {
status: 'PENDING',
...(accessAll ? {} : { ownerStaffId: actorUserId }),
};
const [dueToday, overdue, dueThisWeek, keyCarePending] = await this.prisma.$transaction([
this.prisma.followUpTask.count({ where: { ...base, dueAt: { gte: start, lt: tomorrow } } }),
this.prisma.followUpTask.count({ where: { ...base, dueAt: { lt: now } } }),
this.prisma.followUpTask.count({ where: { ...base, dueAt: { gte: now, lt: weekEnd } } }),
this.prisma.followUpTask.count({
where: { ...base, beneficiary: { keyCareFlag: true } },
}),
]);
return { dueToday, overdue, dueThisWeek, keyCarePending };
}
async completeCase(
serviceCase: NonNullable<Awaited<ReturnType<FollowUpsRepository['findCase']>>>,
input: CompleteCaseInput,
actor: AuthContext,
request: RequestContext,
): Promise<StoredFollowUp> {
return this.prisma.$transaction(async (transaction) => {
const current = await transaction.serviceCase.findUniqueOrThrow({
where: { id: serviceCase.id },
include: { _count: { select: { serviceRecords: true } } },
});
if (current.status !== 'IN_SERVICE' || current.ownerStaffId !== actor.userId)
throw new Error('CASE_COMPLETE_STATE_CONFLICT');
if (current._count.serviceRecords < 1) throw new Error('CASE_REQUIRES_RECORD');
await transaction.serviceCase.update({
where: { id: current.id },
data: {
status: 'SERVICE_COMPLETED',
resultSummaryEncrypted: input.resultSummaryEncrypted,
followUpPlanEncrypted: input.followUpPlanEncrypted,
completedAt: new Date(),
completeRequestId: input.requestId,
completeRequestFingerprint: input.fingerprint,
updatedById: actor.userId,
},
});
await transaction.serviceCaseStatusHistory.create({
data: {
caseId: current.id,
fromStatus: 'IN_SERVICE',
toStatus: 'SERVICE_COMPLETED',
operatorUserId: actor.userId,
reasonCode: 'SERVICE_COMPLETED',
},
});
const applicationCompleted = await transaction.serviceApplication.updateMany({
where: { id: current.applicationId, status: 'IN_SERVICE' },
data: { status: 'SERVICE_COMPLETED' },
});
if (applicationCompleted.count !== 1) throw new Error('APPLICATION_STATE_CONFLICT');
await transaction.serviceApplicationStatusHistory.create({
data: {
applicationId: current.applicationId,
fromStatus: 'IN_SERVICE',
toStatus: 'SERVICE_COMPLETED',
operatorUserId: actor.userId,
reasonCode: 'SERVICE_COMPLETED',
},
});
await transaction.serviceApplication.update({
where: { id: current.applicationId },
data: { status: 'FOLLOW_UP_PENDING' },
});
await transaction.serviceApplicationStatusHistory.create({
data: {
applicationId: current.applicationId,
fromStatus: 'SERVICE_COMPLETED',
toStatus: 'FOLLOW_UP_PENDING',
operatorUserId: actor.userId,
reasonCode: 'FOLLOW_UP_CREATED',
},
});
const application = await transaction.serviceApplication.findUniqueOrThrow({
where: { id: current.applicationId },
});
await this.notifications.create(transaction, {
userId: application.applicantUserId,
eventKey: `application:${application.publicId}:completed`,
type: 'SERVICE_COMPLETED',
title: '本次服务已完成',
body: '本次服务已记录完成,感谢您的配合。',
resourceType: 'SERVICE_APPLICATION',
resourcePublicId: application.publicId,
});
await this.notifications.create(transaction, {
userId: application.applicantUserId,
eventKey: `application:${application.publicId}:follow-up`,
type: 'FOLLOW_UP_PENDING',
title: '后续将进行服务回访',
body: '工作人员将在约定时间与您回访服务情况。',
resourceType: 'SERVICE_APPLICATION',
resourcePublicId: application.publicId,
});
const followUp = await transaction.followUpTask.create({
data: {
publicId: input.followUpPublicId,
followUpNo: input.followUpNo,
beneficiaryId: current.beneficiaryId,
caseId: current.id,
ownerStaffId: current.ownerStaffId,
dueAt: input.followUpDueAt,
followUpType: 'SERVICE_COMPLETION',
source: 'AUTO_SERVICE_COMPLETION',
reasonEncrypted: input.followUpReasonEncrypted,
createRequestId: input.requestId,
createRequestFingerprint: input.fingerprint,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await transaction.followUpStatusHistory.create({
data: {
followUpTaskId: followUp.id,
toStatus: 'PENDING',
operatorUserId: actor.userId,
reasonCode: 'AUTO_CREATED_AFTER_SERVICE',
},
});
await this.audit.record(
request,
{
action: 'SERVICE_CASE_COMPLETED',
result: 'SUCCESS',
actor,
resourceType: 'SERVICE_CASE',
resourcePublicId: serviceCase.publicId,
metadata: { followUpPublicId: input.followUpPublicId },
},
transaction,
);
await this.audit.record(
request,
{
action: 'FOLLOW_UP_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'FOLLOW_UP_TASK',
resourcePublicId: input.followUpPublicId,
metadata: { source: 'AUTO_SERVICE_COMPLETION' },
},
transaction,
);
return transaction.followUpTask.findUniqueOrThrow({
where: { id: followUp.id },
include: followUpInclude,
});
});
}
async create(
input: CreateFollowUpInput,
actor: AuthContext,
request: RequestContext,
): Promise<StoredFollowUp> {
return this.prisma.$transaction(async (transaction) => {
const followUp = await transaction.followUpTask.create({
data: {
publicId: input.publicId,
followUpNo: input.followUpNo,
beneficiaryId: input.beneficiaryId,
caseId: input.caseId,
ownerStaffId: input.ownerStaffId,
dueAt: input.dueAt,
followUpType: input.followUpType,
source: input.followUpType === 'KEY_CARE' ? 'KEY_CARE_PLAN' : 'STAFF_MANUAL',
reasonEncrypted: input.reasonEncrypted,
createRequestId: input.requestId,
createRequestFingerprint: input.fingerprint,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await transaction.followUpStatusHistory.create({
data: {
followUpTaskId: followUp.id,
toStatus: 'PENDING',
operatorUserId: actor.userId,
reasonCode: 'MANUAL_CREATED',
},
});
await this.audit.record(
request,
{
action: 'FOLLOW_UP_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'FOLLOW_UP_TASK',
resourcePublicId: input.publicId,
metadata: {
source: input.followUpType === 'KEY_CARE' ? 'KEY_CARE_PLAN' : 'STAFF_MANUAL',
},
},
transaction,
);
return transaction.followUpTask.findUniqueOrThrow({
where: { id: followUp.id },
include: followUpInclude,
});
});
}
async complete(
followUp: StoredFollowUp,
input: CompleteFollowUpInput,
actor: AuthContext,
request: RequestContext,
): Promise<StoredFollowUp> {
return this.prisma.$transaction(async (transaction) => {
const changed = await transaction.followUpTask.updateMany({
where: { id: followUp.id, status: 'PENDING', ownerStaffId: actor.userId },
data: {
status: 'COMPLETED',
resultCode: input.resultCode,
resultNoteEncrypted: input.resultNoteEncrypted,
completeRequestId: input.requestId,
completeRequestFingerprint: input.fingerprint,
completedAt: new Date(),
updatedById: actor.userId,
},
});
if (changed.count !== 1) throw new Error('FOLLOW_UP_STATE_CONFLICT');
await transaction.followUpStatusHistory.create({
data: {
followUpTaskId: followUp.id,
fromStatus: 'PENDING',
toStatus: 'COMPLETED',
operatorUserId: actor.userId,
reasonCode: input.resultCode,
},
});
const parent = followUp.serviceCase?.application;
if (input.continuation) {
if (!parent) throw new Error('CONTINUATION_REQUIRES_APPLICATION');
const beneficiary = await transaction.beneficiaryProfile.findUniqueOrThrow({
where: { id: followUp.beneficiaryId },
});
const consent = await transaction.consent.create({
data: {
publicId: input.continuation.consentPublicId,
userId: parent.applicantUserId,
beneficiaryId: followUp.beneficiaryId,
consentType: 'SERVICE_APPLICATION',
version: 'service-continuation-v1',
grantedBy: beneficiary.isMinor ? 'GUARDIAN' : 'APPLICANT',
grantedAt: new Date(),
evidenceJson: {
source: 'ADMIN_FOLLOW_UP',
confirmationMethod: 'EXPLICIT_CONFIRMATION',
followUpPublicId: followUp.publicId,
},
},
});
const continued = await transaction.serviceApplication.create({
data: {
publicId: input.continuation.publicId,
applicationNo: input.continuation.applicationNo,
applicantUserId: parent.applicantUserId,
consentId: consent.id,
clientRequestId: input.requestId,
requestFingerprint: input.continuation.requestFingerprint,
applyMode: parent.applyMode,
serviceCategory: parent.serviceCategory,
beneficiaryNameEncrypted: parent.beneficiaryNameEncrypted,
contactMobileEncrypted: parent.contactMobileEncrypted,
contactMobileHash: parent.contactMobileHash,
regionCode: parent.regionCode,
descriptionEncrypted: input.continuation.descriptionEncrypted,
preferredContactTime: parent.preferredContactTime,
needsHomeVisit: parent.needsHomeVisit,
mobilityDifficulty: parent.mobilityDifficulty,
source: 'FOLLOW_UP',
beneficiaryId: followUp.beneficiaryId,
sourceFollowUpTaskId: followUp.id,
parentApplicationId: parent.id,
submittedAt: new Date(),
},
});
await transaction.serviceApplicationStatusHistory.create({
data: {
applicationId: continued.id,
toStatus: 'SUBMITTED',
operatorUserId: actor.userId,
reasonCode: 'FOLLOW_UP_CONTINUATION_CREATED',
},
});
}
if (parent?.status === 'FOLLOW_UP_PENDING') {
await transaction.serviceApplication.update({
where: { id: parent.id },
data: { status: 'CLOSED', closedAt: new Date() },
});
await transaction.serviceApplicationStatusHistory.create({
data: {
applicationId: parent.id,
fromStatus: 'FOLLOW_UP_PENDING',
toStatus: 'CLOSED',
operatorUserId: actor.userId,
reasonCode: input.continuation ? 'CONTINUATION_CREATED' : 'FOLLOW_UP_COMPLETED',
},
});
}
await this.audit.record(
request,
{
action: 'FOLLOW_UP_COMPLETED',
result: 'SUCCESS',
actor,
resourceType: 'FOLLOW_UP_TASK',
resourcePublicId: followUp.publicId,
metadata: {
resultCode: input.resultCode,
continuationCreated: Boolean(input.continuation),
},
},
transaction,
);
return transaction.followUpTask.findUniqueOrThrow({
where: { id: followUp.id },
include: followUpInclude,
});
});
}
}
@@ -0,0 +1,427 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomBytes, randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
FOLLOW_UP_RESULT_LABELS,
FOLLOW_UP_STATUS_LABELS,
FOLLOW_UP_TYPE_LABELS,
PERMISSION_CODES,
type CompleteServiceCaseResponse,
type FollowUpDetail,
type FollowUpDetailResponse,
type FollowUpListItem,
type FollowUpMutationResponse,
type FollowUpWorkbenchSummary,
type PaginatedFollowUpsResponse,
} from '@lehuo/contracts';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
CompleteFollowUpDto,
CompleteServiceCaseDto,
CreateFollowUpDto,
FollowUpQueryDto,
} from './dto/follow-up.dto';
import { FollowUpsRepository, type StoredFollowUp } from './follow-ups.repository';
function shanghaiDatePrefix(date: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${value.year}${value.month}${value.day}`;
}
function shanghaiDayStart(now: Date): Date {
const date = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
return new Date(`${date}T00:00:00+08:00`);
}
function isUniqueConstraintError(error: unknown): error is { code: 'P2002' } {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
}
@Injectable()
export class FollowUpsService {
constructor(
@Inject(FollowUpsRepository) private readonly repository: FollowUpsRepository,
@Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
) {}
async completeCase(
publicId: string,
input: CompleteServiceCaseDto,
actor: AuthContext,
request: RequestContext,
): Promise<CompleteServiceCaseResponse> {
const normalized = {
resultSummary: input.resultSummary.trim(),
followUpPlan: input.followUpPlan.trim(),
followUpDueAt: new Date(input.followUpDueAt).toISOString(),
};
const fingerprint = this.sensitiveData.lookupHash(
'service-case-completion',
JSON.stringify(normalized),
);
const serviceCase = await this.repository.findCase(publicId);
if (!serviceCase) throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务案件');
if (serviceCase.completeRequestId) {
if (
serviceCase.completeRequestId !== input.clientRequestId ||
serviceCase.completeRequestFingerprint !== fingerprint ||
!serviceCase.followUpTasks[0]
) {
throw this.duplicateRequest();
}
return this.caseCompletionResponse(serviceCase.followUpTasks[0], true);
}
if (serviceCase.status !== 'IN_SERVICE' || serviceCase.ownerStaffId !== actor.userId) {
throw this.invalidTransition();
}
if (serviceCase._count.serviceRecords < 1) {
throw this.validation('至少记录一次实际服务后才能完成案件');
}
const dueAt = new Date(normalized.followUpDueAt);
if (dueAt <= new Date()) throw this.validation('回访时间必须晚于当前时间');
const now = new Date();
try {
const followUp = await this.repository.completeCase(
serviceCase,
{
requestId: input.clientRequestId,
fingerprint,
resultSummaryEncrypted: this.sensitiveData.encrypt(normalized.resultSummary),
followUpPlanEncrypted: this.sensitiveData.encrypt(normalized.followUpPlan),
followUpDueAt: dueAt,
followUpPublicId: randomUUID(),
followUpNo: `HF${shanghaiDatePrefix(now)}${randomBytes(5).toString('hex').toUpperCase()}`,
followUpReasonEncrypted: this.sensitiveData.encrypt(normalized.followUpPlan),
},
actor,
request,
);
return this.caseCompletionResponse(followUp, false);
} catch (error) {
if (isUniqueConstraintError(error)) {
const replay = await this.repository.findCase(publicId);
if (
replay?.completeRequestId === input.clientRequestId &&
replay.completeRequestFingerprint === fingerprint &&
replay.followUpTasks[0]
) {
return this.caseCompletionResponse(replay.followUpTasks[0], true);
}
throw this.duplicateRequest();
}
if (error instanceof Error && error.message === 'CASE_REQUIRES_RECORD') {
throw this.validation('至少记录一次实际服务后才能完成案件');
}
if (
error instanceof Error &&
['CASE_COMPLETE_STATE_CONFLICT', 'APPLICATION_STATE_CONFLICT'].includes(error.message)
) {
throw this.invalidTransition();
}
throw error;
}
}
async create(
input: CreateFollowUpDto,
actor: AuthContext,
request: RequestContext,
): Promise<FollowUpMutationResponse> {
const normalized = {
beneficiaryPublicId: input.beneficiaryPublicId,
ownerStaffPublicId: input.ownerStaffPublicId,
serviceCasePublicId: input.serviceCasePublicId ?? null,
followUpType: input.followUpType,
dueAt: new Date(input.dueAt).toISOString(),
reason: input.reason.trim(),
};
const fingerprint = this.sensitiveData.lookupHash(
'follow-up-create',
JSON.stringify(normalized),
);
const replay = await this.repository.findByCreateRequest(actor.userId, input.clientRequestId);
if (replay) {
if (replay.createRequestFingerprint !== fingerprint) throw this.duplicateRequest();
return { followUp: this.toDetail(replay), idempotentReplay: true };
}
const [beneficiary, owner, serviceCase] = await Promise.all([
this.repository.findBeneficiary(input.beneficiaryPublicId),
this.repository.findStaff(input.ownerStaffPublicId),
input.serviceCasePublicId
? this.repository.findCase(input.serviceCasePublicId)
: Promise.resolve(null),
]);
if (!beneficiary)
throw this.notFound(API_ERROR_CODES.BENEFICIARY_NOT_FOUND, '未找到该服务对象档案');
if (!owner) throw this.validation('回访负责人必须是有效工作人员');
if (input.serviceCasePublicId && !serviceCase)
throw this.notFound(API_ERROR_CODES.CASE_NOT_FOUND, '未找到该服务案件');
if (serviceCase && serviceCase.beneficiaryId !== beneficiary.id) {
throw this.validation('服务案件与回访对象不一致');
}
if (input.followUpType === 'KEY_CARE' && !beneficiary.keyCareFlag) {
throw this.validation('只有重点关爱对象可以创建重点关爱回访');
}
const dueAt = new Date(normalized.dueAt);
if (dueAt <= new Date()) throw this.validation('回访时间必须晚于当前时间');
const now = new Date();
try {
const created = await this.repository.create(
{
requestId: input.clientRequestId,
fingerprint,
publicId: randomUUID(),
followUpNo: `HF${shanghaiDatePrefix(now)}${randomBytes(5).toString('hex').toUpperCase()}`,
beneficiaryId: beneficiary.id,
caseId: serviceCase?.id,
ownerStaffId: owner.id,
dueAt,
followUpType: input.followUpType,
reasonEncrypted: this.sensitiveData.encrypt(normalized.reason),
},
actor,
request,
);
return { followUp: this.toDetail(created), idempotentReplay: false };
} catch (error) {
if (isUniqueConstraintError(error)) {
const existing = await this.repository.findByCreateRequest(
actor.userId,
input.clientRequestId,
);
if (existing?.createRequestFingerprint === fingerprint) {
return { followUp: this.toDetail(existing), idempotentReplay: true };
}
throw this.duplicateRequest();
}
throw error;
}
}
async list(query: FollowUpQueryDto, actor: AuthContext): Promise<PaginatedFollowUpsResponse> {
const result = await this.repository.list(
query,
actor.userId,
actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL),
query.page,
query.pageSize,
new Date(),
);
return {
items: result.items.map((item) => this.toListItem(item)),
page: query.page,
pageSize: query.pageSize,
total: result.total,
};
}
async summary(actor: AuthContext): Promise<FollowUpWorkbenchSummary> {
const now = new Date();
const start = shanghaiDayStart(now);
const tomorrow = new Date(start.getTime() + 24 * 60 * 60 * 1000);
const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
return this.repository.summary(
actor.userId,
actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL),
start,
tomorrow,
now,
weekEnd,
);
}
async get(publicId: string, actor: AuthContext): Promise<FollowUpDetailResponse> {
const followUp = await this.repository.find(publicId);
if (!followUp) throw this.notFound(API_ERROR_CODES.FOLLOW_UP_NOT_FOUND, '未找到该回访任务');
this.assertVisible(followUp, actor);
return { followUp: this.toDetail(followUp) };
}
async complete(
publicId: string,
input: CompleteFollowUpDto,
actor: AuthContext,
request: RequestContext,
): Promise<FollowUpMutationResponse> {
const normalized = {
resultCode: input.resultCode,
resultNote: input.resultNote.trim(),
continuationConsentConfirmed: input.continuationConsentConfirmed === true,
continuationDescription: input.continuationDescription?.trim() ?? null,
};
if (input.resultCode === 'NEED_MORE_HELP') {
if (input.continuationConsentConfirmed !== true || !normalized.continuationDescription) {
throw this.validation('继续帮扶必须确认服务对象或监护人授权,并填写新的帮扶需求');
}
} else if (input.continuationConsentConfirmed || input.continuationDescription) {
throw this.validation('只有“需要继续帮扶”才能创建关联申请');
}
const fingerprint = this.sensitiveData.lookupHash(
'follow-up-completion',
JSON.stringify(normalized),
);
const followUp = await this.repository.find(publicId);
if (!followUp) throw this.notFound(API_ERROR_CODES.FOLLOW_UP_NOT_FOUND, '未找到该回访任务');
if (followUp.completeRequestId) {
if (
followUp.completeRequestId !== input.clientRequestId ||
followUp.completeRequestFingerprint !== fingerprint
) {
throw this.duplicateRequest();
}
return { followUp: this.toDetail(followUp), idempotentReplay: true };
}
if (followUp.status !== 'PENDING' || followUp.ownerStaffId !== actor.userId) {
throw this.invalidTransition();
}
if (input.resultCode === 'NEED_MORE_HELP' && !followUp.serviceCase?.application) {
throw this.validation('该回访没有可关联的原服务申请,不能创建继续帮扶申请');
}
const now = new Date();
try {
const completed = await this.repository.complete(
followUp,
{
requestId: input.clientRequestId,
fingerprint,
resultCode: input.resultCode,
resultNoteEncrypted: this.sensitiveData.encrypt(normalized.resultNote),
continuation:
input.resultCode === 'NEED_MORE_HELP' && normalized.continuationDescription
? {
publicId: randomUUID(),
applicationNo: `SQ${shanghaiDatePrefix(now)}${randomBytes(5).toString('hex').toUpperCase()}`,
requestFingerprint: this.sensitiveData.lookupHash(
'service-continuation',
JSON.stringify({ followUpPublicId: publicId, ...normalized }),
),
consentPublicId: randomUUID(),
descriptionEncrypted: this.sensitiveData.encrypt(
normalized.continuationDescription,
),
}
: undefined,
},
actor,
request,
);
return { followUp: this.toDetail(completed), idempotentReplay: false };
} catch (error) {
if (isUniqueConstraintError(error)) {
const replay = await this.repository.find(publicId);
if (
replay?.completeRequestId === input.clientRequestId &&
replay.completeRequestFingerprint === fingerprint
) {
return { followUp: this.toDetail(replay), idempotentReplay: true };
}
throw this.duplicateRequest();
}
if (error instanceof Error && error.message === 'FOLLOW_UP_STATE_CONFLICT') {
throw this.invalidTransition();
}
throw error;
}
}
private caseCompletionResponse(
followUp: StoredFollowUp,
idempotentReplay: boolean,
): CompleteServiceCaseResponse {
return {
serviceCasePublicId: followUp.serviceCase?.publicId ?? '',
serviceCaseStatus: 'SERVICE_COMPLETED',
applicationStatus: 'FOLLOW_UP_PENDING',
followUp: this.toListItem(followUp),
idempotentReplay,
};
}
private toListItem(followUp: StoredFollowUp): FollowUpListItem {
return {
publicId: followUp.publicId,
followUpNo: followUp.followUpNo,
beneficiaryPublicId: followUp.beneficiary.publicId,
beneficiaryDisplayName: this.sensitiveData.decrypt(followUp.beneficiary.displayNameEncrypted),
beneficiaryKeyCareFlag: followUp.beneficiary.keyCareFlag,
category: followUp.beneficiary.primaryCategory,
serviceCasePublicId: followUp.serviceCase?.publicId ?? null,
serviceCaseNo: followUp.serviceCase?.caseNo ?? null,
ownerStaffPublicId: followUp.owner.publicId,
ownerDisplayName: followUp.owner.staffProfile?.displayName ?? '工作人员',
dueAt: followUp.dueAt.toISOString(),
followUpType: followUp.followUpType,
followUpTypeLabel: FOLLOW_UP_TYPE_LABELS[followUp.followUpType],
status: followUp.status,
statusLabel: FOLLOW_UP_STATUS_LABELS[followUp.status],
resultCode: followUp.resultCode,
resultLabel: followUp.resultCode ? FOLLOW_UP_RESULT_LABELS[followUp.resultCode] : null,
createdAt: followUp.createdAt.toISOString(),
completedAt: followUp.completedAt?.toISOString() ?? null,
};
}
private toDetail(followUp: StoredFollowUp): FollowUpDetail {
return {
...this.toListItem(followUp),
reason: this.sensitiveData.decrypt(followUp.reasonEncrypted),
resultNote: followUp.resultNoteEncrypted
? this.sensitiveData.decrypt(followUp.resultNoteEncrypted)
: null,
continuedApplicationPublicId: followUp.continuedApplication?.publicId ?? null,
continuedApplicationNo: followUp.continuedApplication?.applicationNo ?? null,
};
}
private assertVisible(followUp: StoredFollowUp, actor: AuthContext): void {
if (
followUp.ownerStaffId !== actor.userId &&
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
) {
throw new ApplicationError(
API_ERROR_CODES.FORBIDDEN,
'无权访问该回访任务',
HttpStatus.FORBIDDEN,
);
}
}
private invalidTransition(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
'当前状态不能执行此操作,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private duplicateRequest(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'本次提交标识已用于其他内容,请重新操作',
HttpStatus.CONFLICT,
);
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private notFound(code: string, message: string): ApplicationError {
return new ApplicationError(code as never, message, HttpStatus.NOT_FOUND);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Controller, Get, Inject } from '@nestjs/common';
import type { HealthResponse } from '@lehuo/contracts';
import { Public } from '../common/decorators/public.decorator';
import { RawResponse } from '../common/decorators/raw-response.decorator';
import { HealthService } from './health.service';
@Controller('health')
@Public()
@RawResponse()
export class HealthController {
constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
@Get()
getHealth(): HealthResponse {
return this.healthService.getHealth();
}
@Get('live')
liveness(): HealthResponse {
return this.healthService.getHealth();
}
@Get('ready')
readiness() {
return this.healthService.readiness();
}
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { HealthService } from './health.service';
describe('HealthService', () => {
it('returns a healthy API response with an ISO timestamp', () => {
const response = new HealthService().getHealth();
expect(response.status).toBe('ok');
expect(response.service).toBe('api');
expect(Number.isNaN(Date.parse(response.timestamp))).toBe(false);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { Inject, Injectable } from '@nestjs/common';
import { HEALTH_STATUS, type HealthResponse } from '@lehuo/contracts';
import { ServiceUnavailableException } from '@nestjs/common';
import { PrismaService } from '../database/prisma.service';
@Injectable()
export class HealthService {
constructor(@Inject(PrismaService) private readonly prisma?: PrismaService) {}
getHealth(): HealthResponse {
return {
status: HEALTH_STATUS,
service: 'api',
timestamp: new Date().toISOString(),
};
}
async readiness() {
try {
if (this.prisma) await this.prisma.$queryRaw`SELECT 1`;
return {
status: 'ready' as const,
database: 'up' as const,
timestamp: new Date().toISOString(),
};
} catch {
throw new ServiceUnavailableException('Database readiness check failed');
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { AppConfigService } from './config/app-config.service';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
const config = app.get(AppConfigService);
app.use(cookieParser());
app.enableCors({ origin: config.adminWebOrigin, credentials: true });
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
const openApi = new DocumentBuilder()
.setTitle('乐活公益暖心服务平台 API')
.setVersion('1.0')
.addBearerAuth()
.addCookieAuth('lehuo_admin_session')
.build();
SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, openApi));
app.enableShutdownHooks();
await app.listen(config.apiPort, '0.0.0.0');
}
void bootstrap();
@@ -0,0 +1,987 @@
import { ForbiddenException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomBytes, randomUUID } from 'node:crypto';
import {
ACTIVITY_STATUS_LABELS,
API_ERROR_CODES,
CHECK_IN_STATUS_LABELS,
PERMISSION_CODES,
REGISTRATION_STATUS_LABELS,
type ActivityDetail,
type ActivityDetailResponse,
type ActivityListItem,
type ActivityRegistrationItem,
type ActivityRegistrationResponse,
type PaginatedActivitiesResponse,
type PaginatedActivityRegistrationsResponse,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
ActivityQueryDto,
CheckInRegistrationDto,
CreateActivityDto,
PublicActivityQueryDto,
RecordServiceMinutesDto,
RegisterActivityDto,
RegistrationQueryDto,
ReviewRegistrationDto,
TransitionActivityDto,
UpdateActivityDto,
} from './dto/participation.dto';
import { MediaService } from './media.service';
import { NotificationsService } from '../content-notifications/notifications.service';
const activityInclude = {
coverFile: true,
project: true,
statusHistory: { orderBy: { createdAt: 'desc' as const } },
registrations: {
where: { status: { in: ['PENDING' as const, 'APPROVED' as const] } },
select: { id: true },
},
} satisfies Prisma.ActivityInclude;
const registrationInclude = {
activity: true,
volunteer: true,
} satisfies Prisma.ActivityRegistrationInclude;
type StoredActivity = Prisma.ActivityGetPayload<{ include: typeof activityInclude }>;
type StoredRegistration = Prisma.ActivityRegistrationGetPayload<{
include: typeof registrationInclude;
}>;
function dateNoPrefix(date: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${value.year}${value.month}${value.day}`;
}
function maskMobile(value: string): string {
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
}
@Injectable()
export class ActivitiesService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
@Inject(MediaService) private readonly media: MediaService,
@Inject(NotificationsService) private readonly notifications: NotificationsService,
) {}
listAdmin(query: ActivityQueryDto, actor: AuthContext): Promise<PaginatedActivitiesResponse> {
const scoped: Prisma.ActivityWhereInput = actor.permissions.includes(
PERMISSION_CODES.RESOURCES_ACCESS_ALL,
)
? {}
: { OR: [{ createdById: actor.userId }, { project: { managerUserId: actor.userId } }] };
return this.list(
{
...scoped,
...(query.status ? { status: query.status as never } : {}),
...(query.projectPublicId
? {
project: {
publicId: query.projectPublicId,
...(!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
? { managerUserId: actor.userId }
: {}),
},
}
: {}),
},
query.page,
query.pageSize,
);
}
listPublic(query: PublicActivityQueryDto): Promise<PaginatedActivitiesResponse> {
return this.list(
{
status: { in: ['PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED'] },
...(query.projectPublicId
? { project: { publicId: query.projectPublicId, isPublic: true } }
: {}),
OR: [
{ projectId: null },
{ project: { isPublic: true, status: { in: ['PUBLISHED', 'COMPLETED'] } } },
],
},
query.page,
query.pageSize,
);
}
async getAdmin(publicId: string, actor: AuthContext): Promise<ActivityDetailResponse> {
const activity = await this.find(publicId);
this.assertAccess(activity, actor);
return { activity: await this.detail(activity), currentUserRegistration: null };
}
async getPublic(publicId: string): Promise<ActivityDetailResponse> {
const activity = await this.find(publicId);
if (activity.status === 'DRAFT' || (activity.project && !activity.project.isPublic))
throw this.notFound();
return { activity: await this.detail(activity), currentUserRegistration: null };
}
async create(
input: CreateActivityDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityDetailResponse> {
const values = await this.prepare(input, actor);
const fingerprint = this.fingerprint('activity-create', {
...input,
title: input.title.trim(),
summary: input.summary.trim(),
content: input.content.trim(),
locationText: input.locationText.trim(),
});
const replay = await this.prisma.activity.findUnique({
where: {
createdById_clientRequestId: {
createdById: actor.userId,
clientRequestId: input.clientRequestId,
},
},
include: activityInclude,
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return {
activity: await this.detail(replay),
currentUserRegistration: null,
idempotentReplay: true,
};
}
const activity = await this.prisma.$transaction(async (transaction) => {
const created = await transaction.activity.create({
data: {
publicId: randomUUID(),
activityNo: `HD${dateNoPrefix(new Date())}${randomBytes(4).toString('hex').toUpperCase()}`,
clientRequestId: input.clientRequestId,
requestFingerprint: fingerprint,
...values,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await transaction.activityStatusHistory.create({
data: {
activityId: created.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
toStatus: 'DRAFT',
operatorUserId: actor.userId,
reasonCode: 'CREATED',
},
});
await this.audit.record(
request,
{
action: 'ACTIVITY_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY',
resourcePublicId: created.publicId,
metadata: { status: 'DRAFT' },
},
transaction,
);
return transaction.activity.findUniqueOrThrow({
where: { id: created.id },
include: activityInclude,
});
});
return {
activity: await this.detail(activity),
currentUserRegistration: null,
idempotentReplay: false,
};
}
async update(
publicId: string,
input: UpdateActivityDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityDetailResponse> {
const activity = await this.find(publicId);
this.assertAccess(activity, actor);
if (activity.status !== 'DRAFT') throw this.invalidTransition('只有草稿活动可以编辑');
if (activity.revision !== input.expectedRevision) throw this.conflict();
const values = await this.prepare(input, actor);
const updated = await this.prisma
.$transaction(async (transaction) => {
const changed = await transaction.activity.updateMany({
where: { id: activity.id, status: 'DRAFT', revision: input.expectedRevision },
data: { ...values, updatedById: actor.userId, revision: { increment: 1 } },
});
if (changed.count !== 1) throw new Error('ACTIVITY_CONFLICT');
await this.audit.record(
request,
{
action: 'ACTIVITY_UPDATED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY',
resourcePublicId: publicId,
},
transaction,
);
return transaction.activity.findUniqueOrThrow({
where: { id: activity.id },
include: activityInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'ACTIVITY_CONFLICT') throw this.conflict();
throw error;
});
return { activity: await this.detail(updated), currentUserRegistration: null };
}
async transition(
publicId: string,
input: TransitionActivityDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityDetailResponse> {
const activity = await this.find(publicId);
this.assertAccess(activity, actor);
const fingerprint = this.fingerprint('activity-transition', {
publicId,
targetStatus: input.targetStatus,
});
const replay = await this.prisma.activityStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { activity: { include: activityInclude } },
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return {
activity: await this.detail(replay.activity),
currentUserRegistration: null,
idempotentReplay: true,
};
}
const allowed: Record<string, string[]> = {
DRAFT: ['PUBLISHED', 'CANCELLED'],
PUBLISHED: ['REGISTRATION_CLOSED', 'CANCELLED'],
REGISTRATION_CLOSED: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['COMPLETED'],
};
if (!allowed[activity.status]?.includes(input.targetStatus)) throw this.invalidTransition();
if (input.targetStatus === 'PUBLISHED' && activity.registrationDeadline <= new Date()) {
throw this.validation('报名截止时间必须晚于当前时间');
}
const updated = await this.prisma
.$transaction(async (transaction) => {
const changed = await transaction.activity.updateMany({
where: { id: activity.id, status: activity.status },
data: {
status: input.targetStatus as never,
publishedAt: input.targetStatus === 'PUBLISHED' ? new Date() : activity.publishedAt,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (changed.count !== 1) throw new Error('ACTIVITY_STATE_CONFLICT');
await transaction.activityStatusHistory.create({
data: {
activityId: activity.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
fromStatus: activity.status,
toStatus: input.targetStatus as never,
operatorUserId: actor.userId,
reasonCode: input.targetStatus,
},
});
await this.audit.record(
request,
{
action: 'ACTIVITY_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY',
resourcePublicId: publicId,
metadata: { fromStatus: activity.status, toStatus: input.targetStatus },
},
transaction,
);
return transaction.activity.findUniqueOrThrow({
where: { id: activity.id },
include: activityInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'ACTIVITY_STATE_CONFLICT')
throw this.invalidTransition();
throw error;
});
return {
activity: await this.detail(updated),
currentUserRegistration: null,
idempotentReplay: false,
};
}
async register(
activityPublicId: string,
input: RegisterActivityDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityRegistrationResponse> {
const fingerprint = this.fingerprint('activity-registration', { activityPublicId });
const replay = await this.prisma.activityRegistration.findUnique({
where: {
userId_clientRequestId: { userId: actor.userId, clientRequestId: input.clientRequestId },
},
include: registrationInclude,
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return { registration: this.registration(replay), idempotentReplay: true };
}
const volunteer = await this.prisma.volunteer.findUnique({ where: { userId: actor.userId } });
if (!volunteer || volunteer.status !== 'APPROVED') {
throw new ApplicationError(
API_ERROR_CODES.VOLUNTEER_NOT_APPROVED,
'仅已认证志愿者可以报名',
HttpStatus.FORBIDDEN,
);
}
try {
const created = await this.prisma.$transaction(async (transaction) => {
const rows = await transaction.$queryRaw<
Array<{ id: bigint }>
>`SELECT id FROM activities WHERE public_id = ${activityPublicId} FOR UPDATE`;
if (!rows.length) throw new Error('ACTIVITY_NOT_FOUND');
const activity = await transaction.activity.findUniqueOrThrow({
where: { id: rows[0]!.id },
});
if (activity.status !== 'PUBLISHED' || activity.registrationDeadline <= new Date())
throw new Error('REGISTRATION_CLOSED');
const active = await transaction.activityRegistration.count({
where: { activityId: activity.id, status: { in: ['PENDING', 'APPROVED'] } },
});
if (activity.capacity !== null && active >= activity.capacity)
throw new Error('ACTIVITY_FULL');
const registration = await transaction.activityRegistration.create({
data: {
publicId: randomUUID(),
activityId: activity.id,
userId: actor.userId,
volunteerId: volunteer.id,
clientRequestId: input.clientRequestId,
requestFingerprint: fingerprint,
},
});
await transaction.activityRegistrationHistory.create({
data: {
registrationId: registration.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
toStatus: 'PENDING',
operatorUserId: actor.userId,
actionCode: 'REGISTERED',
},
});
await this.audit.record(
request,
{
action: 'ACTIVITY_REGISTRATION_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY_REGISTRATION',
resourcePublicId: registration.publicId,
metadata: { activityPublicId },
},
transaction,
);
return transaction.activityRegistration.findUniqueOrThrow({
where: { id: registration.id },
include: registrationInclude,
});
});
return { registration: this.registration(created), idempotentReplay: false };
} catch (error) {
if (error instanceof Error && error.message === 'ACTIVITY_NOT_FOUND') throw this.notFound();
if (error instanceof Error && error.message === 'REGISTRATION_CLOSED')
throw this.registrationClosed();
if (error instanceof Error && error.message === 'ACTIVITY_FULL') throw this.full();
if (this.uniqueError(error)) {
const existing = await this.prisma.activityRegistration.findFirst({
where: { activity: { publicId: activityPublicId }, userId: actor.userId },
include: registrationInclude,
});
if (
existing?.clientRequestId === input.clientRequestId &&
existing.requestFingerprint === fingerprint
) {
return { registration: this.registration(existing), idempotentReplay: true };
}
throw this.duplicate('您已报名该活动');
}
throw error;
}
}
async myRegistrations(
query: RegistrationQueryDto,
actor: AuthContext,
): Promise<PaginatedActivityRegistrationsResponse> {
return this.listRegistrations(
{
userId: actor.userId,
...(query.status ? { status: query.status as never } : {}),
...(query.activityPublicId ? { activity: { publicId: query.activityPublicId } } : {}),
},
query.page,
query.pageSize,
);
}
async adminRegistrations(
query: RegistrationQueryDto,
): Promise<PaginatedActivityRegistrationsResponse> {
return this.listRegistrations(
{
...(query.status ? { status: query.status as never } : {}),
...(query.activityPublicId ? { activity: { publicId: query.activityPublicId } } : {}),
},
query.page,
query.pageSize,
);
}
async cancel(
publicId: string,
input: RegisterActivityDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityRegistrationResponse> {
const registration = await this.findRegistration(publicId);
if (registration.userId !== actor.userId) throw new ForbiddenException();
const replay = await this.replayRegistration(
input.clientRequestId,
this.fingerprint('registration-status', {
publicId,
targetStatus: 'CANCELLED',
note: '',
}),
);
if (replay) return { registration: this.registration(replay), idempotentReplay: true };
if (
!['PENDING', 'APPROVED'].includes(registration.status) ||
registration.activity.startAt <= new Date()
)
throw this.invalidTransition();
return this.changeRegistrationStatus(
registration,
input.clientRequestId,
'CANCELLED',
'',
actor,
request,
'REGISTRATION_CANCELLED',
);
}
async reviewRegistration(
publicId: string,
input: ReviewRegistrationDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityRegistrationResponse> {
const registration = await this.findRegistration(publicId);
const replay = await this.replayRegistration(
input.clientRequestId,
this.fingerprint('registration-status', {
publicId,
targetStatus: input.targetStatus,
note: input.note.trim(),
}),
);
if (replay) return { registration: this.registration(replay), idempotentReplay: true };
if (registration.status !== 'PENDING') throw this.invalidTransition();
return this.changeRegistrationStatus(
registration,
input.clientRequestId,
input.targetStatus,
input.note,
actor,
request,
'REGISTRATION_REVIEWED',
);
}
async checkIn(
publicId: string,
input: CheckInRegistrationDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityRegistrationResponse> {
const registration = await this.findRegistration(publicId);
const fingerprint = this.fingerprint('registration-check-in', {
publicId,
targetStatus: input.targetStatus,
});
const replay = await this.replayRegistration(input.clientRequestId, fingerprint);
if (replay) return { registration: this.registration(replay), idempotentReplay: true };
if (
registration.status !== 'APPROVED' ||
registration.checkInStatus !== 'NOT_CHECKED_IN' ||
registration.activity.status !== 'IN_PROGRESS'
) {
throw this.invalidTransition();
}
const updated = await this.prisma
.$transaction(async (transaction) => {
const changed = await transaction.activityRegistration.updateMany({
where: { id: registration.id, status: 'APPROVED', checkInStatus: 'NOT_CHECKED_IN' },
data: {
checkInStatus: input.targetStatus,
checkedInAt: input.targetStatus === 'CHECKED_IN' ? new Date() : null,
checkedInById: actor.userId,
},
});
if (changed.count !== 1) throw new Error('REGISTRATION_STATE_CONFLICT');
await transaction.activityRegistrationHistory.create({
data: {
registrationId: registration.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
fromCheckInStatus: 'NOT_CHECKED_IN',
toCheckInStatus: input.targetStatus,
operatorUserId: actor.userId,
actionCode: 'CHECK_IN_RECORDED',
},
});
await this.audit.record(
request,
{
action: 'ACTIVITY_CHECK_IN_RECORDED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY_REGISTRATION',
resourcePublicId: publicId,
metadata: { checkInStatus: input.targetStatus },
},
transaction,
);
return transaction.activityRegistration.findUniqueOrThrow({
where: { id: registration.id },
include: registrationInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'REGISTRATION_STATE_CONFLICT')
throw this.invalidTransition();
throw error;
});
return { registration: this.registration(updated), idempotentReplay: false };
}
async recordMinutes(
publicId: string,
input: RecordServiceMinutesDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityRegistrationResponse> {
const registration = await this.findRegistration(publicId);
if (
registration.status !== 'APPROVED' ||
registration.checkInStatus !== 'CHECKED_IN' ||
registration.activity.status !== 'COMPLETED'
) {
throw this.invalidTransition();
}
const durationMinutes = Math.ceil(
(registration.activity.endAt.getTime() - registration.activity.startAt.getTime()) / 60_000,
);
if (input.serviceMinutes > durationMinutes)
throw this.validation('服务时长不能超过活动持续时间');
const fingerprint = this.fingerprint('registration-service-minutes', {
publicId,
serviceMinutes: input.serviceMinutes,
});
const replay = await this.replayRegistration(input.clientRequestId, fingerprint);
if (replay) return { registration: this.registration(replay), idempotentReplay: true };
const updated = await this.prisma.$transaction(async (transaction) => {
const current = await transaction.activityRegistration.findUniqueOrThrow({
where: { id: registration.id },
});
await transaction.activityRegistration.update({
where: { id: registration.id },
data: {
serviceMinutes: input.serviceMinutes,
serviceMinutesRecordedAt: new Date(),
serviceMinutesRecordedById: actor.userId,
},
});
await transaction.activityRegistrationHistory.create({
data: {
registrationId: registration.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
serviceMinutesBefore: current.serviceMinutes,
serviceMinutesAfter: input.serviceMinutes,
operatorUserId: actor.userId,
actionCode: 'SERVICE_MINUTES_RECORDED',
},
});
await this.audit.record(
request,
{
action: 'VOLUNTEER_SERVICE_MINUTES_RECORDED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY_REGISTRATION',
resourcePublicId: publicId,
metadata: {
serviceMinutesBefore: current.serviceMinutes,
serviceMinutesAfter: input.serviceMinutes,
},
},
transaction,
);
return transaction.activityRegistration.findUniqueOrThrow({
where: { id: registration.id },
include: registrationInclude,
});
});
return { registration: this.registration(updated), idempotentReplay: false };
}
private async changeRegistrationStatus(
registration: StoredRegistration,
requestId: string,
targetStatus: 'APPROVED' | 'REJECTED' | 'CANCELLED',
note: string,
actor: AuthContext,
request: RequestContext,
action: string,
): Promise<ActivityRegistrationResponse> {
const fingerprint = this.fingerprint('registration-status', {
publicId: registration.publicId,
targetStatus,
note: note.trim(),
});
const replay = await this.replayRegistration(requestId, fingerprint);
if (replay) return { registration: this.registration(replay), idempotentReplay: true };
const updated = await this.prisma
.$transaction(async (transaction) => {
const changed = await transaction.activityRegistration.updateMany({
where: { id: registration.id, status: registration.status },
data: {
status: targetStatus,
reviewedById: targetStatus === 'CANCELLED' ? null : actor.userId,
reviewedAt: targetStatus === 'CANCELLED' ? null : new Date(),
reviewNoteEncrypted: note ? this.sensitive.encrypt(note.trim()) : null,
},
});
if (changed.count !== 1) throw new Error('REGISTRATION_STATE_CONFLICT');
await transaction.activityRegistrationHistory.create({
data: {
registrationId: registration.id,
requestId,
requestFingerprint: fingerprint,
fromStatus: registration.status,
toStatus: targetStatus,
operatorUserId: actor.userId,
actionCode: action,
},
});
await this.audit.record(
request,
{
action,
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY_REGISTRATION',
resourcePublicId: registration.publicId,
metadata: { fromStatus: registration.status, toStatus: targetStatus },
},
transaction,
);
if (targetStatus !== 'CANCELLED')
await this.notifications.create(transaction, {
userId: registration.userId,
eventKey: `registration:${registration.publicId}:${targetStatus}`,
type: 'ACTIVITY_REGISTRATION_RESULT',
title: targetStatus === 'APPROVED' ? '活动报名已通过' : '活动报名未通过',
body:
targetStatus === 'APPROVED'
? '您的活动报名已通过,请留意活动时间和地点。'
: '很抱歉,本次活动报名未通过,您可以继续关注其他活动。',
resourceType: 'ACTIVITY_REGISTRATION',
resourcePublicId: registration.publicId,
});
return transaction.activityRegistration.findUniqueOrThrow({
where: { id: registration.id },
include: registrationInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'REGISTRATION_STATE_CONFLICT')
throw this.invalidTransition();
throw error;
});
return { registration: this.registration(updated), idempotentReplay: false };
}
private async prepare(input: CreateActivityDto | UpdateActivityDto, actor: AuthContext) {
const startAt = new Date(input.startAt),
endAt = new Date(input.endAt),
registrationDeadline = new Date(input.registrationDeadline);
if (endAt <= startAt) throw this.validation('活动结束时间必须晚于开始时间');
if (registrationDeadline >= startAt) throw this.validation('报名截止时间必须早于活动开始时间');
let projectId: bigint | undefined;
if (input.projectPublicId) {
const project = await this.prisma.project.findUnique({
where: { publicId: input.projectPublicId },
});
if (!project || project.status === 'COMPLETED') throw this.validation('关联项目无效或已完成');
if (
project.managerUserId !== actor.userId &&
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
)
throw new ForbiddenException();
projectId = project.id;
}
const cover = await this.media.requireCover(input.coverFilePublicId, 'ACTIVITY_COVER', actor);
return {
projectId,
title: input.title.trim(),
summary: input.summary.trim(),
content: input.content.trim(),
coverFileId: cover?.id,
startAt,
endAt,
registrationDeadline,
locationText: input.locationText.trim(),
capacity: input.capacity,
};
}
private async list(
where: Prisma.ActivityWhereInput,
page: number,
pageSize: number,
): Promise<PaginatedActivitiesResponse> {
const [total, activities] = await this.prisma.$transaction([
this.prisma.activity.count({ where }),
this.prisma.activity.findMany({
where,
include: activityInclude,
orderBy: { startAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
items: await Promise.all(activities.map((activity) => this.item(activity))),
page,
pageSize,
total,
};
}
private async listRegistrations(
where: Prisma.ActivityRegistrationWhereInput,
page: number,
pageSize: number,
): Promise<PaginatedActivityRegistrationsResponse> {
const [total, registrations] = await this.prisma.$transaction([
this.prisma.activityRegistration.count({ where }),
this.prisma.activityRegistration.findMany({
where,
include: registrationInclude,
orderBy: { registeredAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return { items: registrations.map((item) => this.registration(item)), page, pageSize, total };
}
private async find(publicId: string): Promise<StoredActivity> {
const activity = await this.prisma.activity.findUnique({
where: { publicId },
include: activityInclude,
});
if (!activity) throw this.notFound();
return activity;
}
private async findRegistration(publicId: string): Promise<StoredRegistration> {
const registration = await this.prisma.activityRegistration.findUnique({
where: { publicId },
include: registrationInclude,
});
if (!registration)
throw new ApplicationError(
API_ERROR_CODES.REGISTRATION_NOT_FOUND,
'未找到该报名记录',
HttpStatus.NOT_FOUND,
);
return registration;
}
private replayRegistration(
requestId: string,
fingerprint: string,
): Promise<StoredRegistration | null> {
return this.prisma.activityRegistrationHistory
.findUnique({
where: { requestId },
include: { registration: { include: registrationInclude } },
})
.then((history) => {
if (!history) return null;
if (history.requestFingerprint !== fingerprint) throw this.duplicate();
return history.registration;
});
}
private assertAccess(activity: StoredActivity, actor: AuthContext): void {
if (
activity.createdById !== actor.userId &&
activity.project?.managerUserId !== actor.userId &&
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
) {
throw new ForbiddenException();
}
}
private async item(activity: StoredActivity): Promise<ActivityListItem> {
const active = activity.registrations.length;
return {
publicId: activity.publicId,
activityNo: activity.activityNo,
projectPublicId: activity.project?.publicId ?? null,
projectName: activity.project?.name ?? null,
title: activity.title,
summary: activity.summary,
coverImageUrl: await this.media.publicUrl(activity.coverFile),
startAt: activity.startAt.toISOString(),
endAt: activity.endAt.toISOString(),
registrationDeadline: activity.registrationDeadline.toISOString(),
locationText: activity.locationText,
capacity: activity.capacity,
activeRegistrationCount: active,
remainingCapacity:
activity.capacity === null ? null : Math.max(0, activity.capacity - active),
status: activity.status,
statusLabel: ACTIVITY_STATUS_LABELS[activity.status],
canRegister:
activity.status === 'PUBLISHED' &&
activity.registrationDeadline > new Date() &&
(activity.capacity === null || active < activity.capacity),
revision: activity.revision,
createdAt: activity.createdAt.toISOString(),
};
}
private async detail(activity: StoredActivity): Promise<ActivityDetail> {
return {
...(await this.item(activity)),
content: activity.content,
coverFilePublicId: activity.coverFile?.publicId ?? null,
history: activity.statusHistory.map((history) => ({
status: history.toStatus,
statusLabel: ACTIVITY_STATUS_LABELS[history.toStatus],
reasonLabel:
(
{
CREATED: '创建活动',
PUBLISHED: '发布活动',
REGISTRATION_CLOSED: '关闭报名',
IN_PROGRESS: '开始活动',
COMPLETED: '完成活动',
CANCELLED: '取消活动',
} as Record<string, string>
)[history.reasonCode] ?? '状态更新',
createdAt: history.createdAt.toISOString(),
})),
};
}
private registration(item: StoredRegistration): ActivityRegistrationItem {
const mobile = this.sensitive.decrypt(item.volunteer.mobileEncrypted);
return {
publicId: item.publicId,
activityPublicId: item.activity.publicId,
activityTitle: item.activity.title,
volunteerPublicId: item.volunteer.publicId,
volunteerName: this.sensitive.decrypt(item.volunteer.realNameEncrypted),
mobileMasked: maskMobile(mobile),
status: item.status,
statusLabel: REGISTRATION_STATUS_LABELS[item.status],
checkInStatus: item.checkInStatus,
checkInStatusLabel: CHECK_IN_STATUS_LABELS[item.checkInStatus],
serviceMinutes: item.serviceMinutes,
registeredAt: item.registeredAt.toISOString(),
};
}
private fingerprint(domain: string, value: object): string {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private uniqueError(error: unknown): error is { code: 'P2002' } {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private notFound(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.ACTIVITY_NOT_FOUND,
'未找到该公益活动',
HttpStatus.NOT_FOUND,
);
}
private conflict(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'活动已更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private invalidTransition(message = '当前状态不能执行该操作'): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
message,
HttpStatus.CONFLICT,
);
}
private duplicate(message = '请求标识已用于其他操作'): ApplicationError {
return new ApplicationError(API_ERROR_CODES.DUPLICATE_REQUEST, message, HttpStatus.CONFLICT);
}
private registrationClosed(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.REGISTRATION_CLOSED,
'活动报名已截止',
HttpStatus.CONFLICT,
);
}
private full(): ApplicationError {
return new ApplicationError(API_ERROR_CODES.ACTIVITY_FULL, '活动名额已满', HttpStatus.CONFLICT);
}
}
@@ -0,0 +1,294 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Patch,
Post,
Put,
Query,
Req,
ValidationPipe,
} from '@nestjs/common';
import {
PERMISSION_CODES,
type ActivityDetailResponse,
type ActivityRegistrationResponse,
type AssigneeOption,
type AdminVolunteerDetailResponse,
type ConfirmUploadResponse,
type CreateUploadIntentResponse,
type PaginatedActivitiesResponse,
type PaginatedActivityRegistrationsResponse,
type PaginatedProjectsResponse,
type PaginatedVolunteersResponse,
type ProjectDetailResponse,
} from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import { ActivitiesService } from './activities.service';
import {
ActivityQueryDto,
CheckInRegistrationDto,
CreateActivityDto,
CreateMediaUploadIntentDto,
CreateProjectDto,
ProjectQueryDto,
RecordServiceMinutesDto,
RegistrationQueryDto,
ReplaceProjectCasesDto,
RestoreVolunteerDto,
ReviewRegistrationDto,
ReviewVolunteerDto,
TransitionActivityDto,
TransitionProjectDto,
UpdateActivityDto,
UpdateProjectDto,
VolunteerQueryDto,
} from './dto/participation.dto';
import { MediaService } from './media.service';
import { ProjectsService } from './projects.service';
import { VolunteersService } from './volunteers.service';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Controller('api/v1/admin')
@RequireSessionChannel(SessionChannel.ADMIN)
export class AdminParticipationController {
constructor(
@Inject(VolunteersService) private readonly volunteers: VolunteersService,
@Inject(ProjectsService) private readonly projects: ProjectsService,
@Inject(ActivitiesService) private readonly activities: ActivitiesService,
@Inject(MediaService) private readonly media: MediaService,
) {}
@Get('volunteers')
@RequirePermissions(PERMISSION_CODES.VOLUNTEERS_READ)
volunteersList(
@Query(strict(VolunteerQueryDto)) query: VolunteerQueryDto,
): Promise<PaginatedVolunteersResponse> {
return this.volunteers.list(query);
}
@Get('volunteers/:publicId')
@RequirePermissions(PERMISSION_CODES.VOLUNTEERS_READ)
volunteer(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
return this.volunteers.get(publicId, actor, request);
}
@Post('volunteers/:publicId/review')
@RequirePermissions(PERMISSION_CODES.VOLUNTEERS_REVIEW)
reviewVolunteer(
@Param('publicId') publicId: string,
@Body(strict(ReviewVolunteerDto)) input: ReviewVolunteerDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
return this.volunteers.review(publicId, input, actor, request);
}
@Post('volunteers/:publicId/restore')
@RequirePermissions(PERMISSION_CODES.VOLUNTEERS_REVIEW)
restoreVolunteer(
@Param('publicId') publicId: string,
@Body(strict(RestoreVolunteerDto)) input: RestoreVolunteerDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
return this.volunteers.review(publicId, input, actor, request);
}
@Get('projects')
@RequirePermissions(PERMISSION_CODES.PROJECTS_READ)
projectsList(
@Query(strict(ProjectQueryDto)) query: ProjectQueryDto,
@CurrentAuth() actor: AuthContext,
): Promise<PaginatedProjectsResponse> {
return this.projects.listAdmin(query, actor);
}
@Get('project-manager-options')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
projectManagerOptions(): Promise<AssigneeOption[]> {
return this.projects.managerOptions();
}
@Post('projects')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
createProject(
@Body(strict(CreateProjectDto)) input: CreateProjectDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ProjectDetailResponse> {
return this.projects.create(input, actor, request);
}
@Get('projects/:publicId')
@RequirePermissions(PERMISSION_CODES.PROJECTS_READ)
project(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
): Promise<ProjectDetailResponse> {
return this.projects.getAdmin(publicId, actor);
}
@Patch('projects/:publicId')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
updateProject(
@Param('publicId') publicId: string,
@Body(strict(UpdateProjectDto)) input: UpdateProjectDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ProjectDetailResponse> {
return this.projects.update(publicId, input, actor, request);
}
@Post('projects/:publicId/transition')
@RequirePermissions(PERMISSION_CODES.PROJECTS_PUBLISH)
transitionProject(
@Param('publicId') publicId: string,
@Body(strict(TransitionProjectDto)) input: TransitionProjectDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ProjectDetailResponse> {
return this.projects.transition(publicId, input, actor, request);
}
@Put('projects/:publicId/service-cases')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
replaceProjectCases(
@Param('publicId') publicId: string,
@Body(strict(ReplaceProjectCasesDto)) input: ReplaceProjectCasesDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ProjectDetailResponse> {
return this.projects.replaceCases(publicId, input, actor, request);
}
@Get('activities')
@RequirePermissions(PERMISSION_CODES.ACTIVITIES_READ)
activitiesList(
@Query(strict(ActivityQueryDto)) query: ActivityQueryDto,
@CurrentAuth() actor: AuthContext,
): Promise<PaginatedActivitiesResponse> {
return this.activities.listAdmin(query, actor);
}
@Post('activities')
@RequirePermissions(PERMISSION_CODES.ACTIVITIES_MANAGE)
createActivity(
@Body(strict(CreateActivityDto)) input: CreateActivityDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityDetailResponse> {
return this.activities.create(input, actor, request);
}
@Get('activities/:publicId')
@RequirePermissions(PERMISSION_CODES.ACTIVITIES_READ)
activity(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
): Promise<ActivityDetailResponse> {
return this.activities.getAdmin(publicId, actor);
}
@Patch('activities/:publicId')
@RequirePermissions(PERMISSION_CODES.ACTIVITIES_MANAGE)
updateActivity(
@Param('publicId') publicId: string,
@Body(strict(UpdateActivityDto)) input: UpdateActivityDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityDetailResponse> {
return this.activities.update(publicId, input, actor, request);
}
@Post('activities/:publicId/transition')
@RequirePermissions(PERMISSION_CODES.ACTIVITIES_PUBLISH)
transitionActivity(
@Param('publicId') publicId: string,
@Body(strict(TransitionActivityDto)) input: TransitionActivityDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityDetailResponse> {
return this.activities.transition(publicId, input, actor, request);
}
@Get('activity-registrations')
@RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
registrationList(
@Query(strict(RegistrationQueryDto)) query: RegistrationQueryDto,
): Promise<PaginatedActivityRegistrationsResponse> {
return this.activities.adminRegistrations(query);
}
@Post('activity-registrations/:publicId/review')
@RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
reviewRegistration(
@Param('publicId') publicId: string,
@Body(strict(ReviewRegistrationDto)) input: ReviewRegistrationDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityRegistrationResponse> {
return this.activities.reviewRegistration(publicId, input, actor, request);
}
@Post('activity-registrations/:publicId/check-in')
@RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
checkIn(
@Param('publicId') publicId: string,
@Body(strict(CheckInRegistrationDto)) input: CheckInRegistrationDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityRegistrationResponse> {
return this.activities.checkIn(publicId, input, actor, request);
}
@Post('activity-registrations/:publicId/service-minutes')
@RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
serviceMinutes(
@Param('publicId') publicId: string,
@Body(strict(RecordServiceMinutesDto)) input: RecordServiceMinutesDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityRegistrationResponse> {
return this.activities.recordMinutes(publicId, input, actor, request);
}
@Post('media/upload-intents')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
mediaIntent(
@Body(strict(CreateMediaUploadIntentDto)) input: CreateMediaUploadIntentDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<CreateUploadIntentResponse> {
return this.media.createIntent(input, actor, request);
}
@Post('media/:publicId/confirm')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
confirmMedia(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ConfirmUploadResponse> {
return this.media.confirm(publicId, actor, request);
}
}
@@ -0,0 +1,169 @@
import { Type } from 'class-transformer';
import { OmitType } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
IsUUID,
Length,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
import {
ACTIVITY_STATUSES,
CHECK_IN_STATUSES,
PROJECT_STATUSES,
REGISTRATION_STATUSES,
SERVICE_CATEGORIES,
VOLUNTEER_SKILL_CODES,
VOLUNTEER_STATUSES,
} from '@lehuo/contracts';
import { PaginationDto } from '../../case-management/dto/case-management.dto';
const MOBILE_PATTERN = /^1[3-9]\d{9}$/;
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export class ApplyVolunteerDto {
@IsUUID('4') clientRequestId!: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) expectedRevision?: number;
@IsString() @Length(2, 50) realName!: string;
@Matches(MOBILE_PATTERN) mobile!: string;
@IsString() @Length(2, 100) regionCode!: string;
@IsString() @Length(2, 500) availabilityNote!: string;
@IsArray() @ArrayMaxSize(12) @IsIn(VOLUNTEER_SKILL_CODES, { each: true }) skillCodes!: string[];
@IsArray()
@ArrayMaxSize(12)
@IsIn(SERVICE_CATEGORIES, { each: true })
interestCategories!: string[];
@IsIn([true]) privacyConsent!: true;
}
export class VolunteerQueryDto extends PaginationDto {
@IsOptional() @IsIn(VOLUNTEER_STATUSES) status?: string;
@IsOptional() @IsString() @MaxLength(100) regionCode?: string;
}
export class ReviewVolunteerDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['APPROVED', 'REJECTED', 'SUSPENDED']) targetStatus!: 'APPROVED' | 'REJECTED' | 'SUSPENDED';
@IsString() @Length(2, 500) note!: string;
}
export class RestoreVolunteerDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['APPROVED']) targetStatus!: 'APPROVED';
@IsString() @Length(2, 500) note!: string;
}
export class ProjectQueryDto extends PaginationDto {
@IsOptional() @IsIn(PROJECT_STATUSES) status?: string;
@IsOptional() @IsIn(SERVICE_CATEGORIES) category?: string;
}
export class PublicProjectQueryDto extends PaginationDto {
@IsOptional() @IsIn(SERVICE_CATEGORIES) category?: string;
}
export class CreateProjectDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(2, 200) name!: string;
@IsIn(SERVICE_CATEGORIES) category!: string;
@IsString() @Length(10, 500) summary!: string;
@IsString() @Length(20, 20000) content!: string;
@IsOptional() @IsUUID('4') coverFilePublicId?: string;
@IsUUID('4') managerUserPublicId!: string;
@Matches(DATE_PATTERN) startDate!: string;
@Matches(DATE_PATTERN) endDate!: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1_000_000) targetPeople?: number;
@IsBoolean() isPublic!: boolean;
@Type(() => Number) @IsInt() @Min(-10_000) @Max(10_000) sortOrder!: number;
}
export class UpdateProjectDto extends OmitType(CreateProjectDto, ['clientRequestId'] as const) {
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
}
export class TransitionProjectDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['PUBLISHED', 'COMPLETED']) targetStatus!: 'PUBLISHED' | 'COMPLETED';
}
export class ReplaceProjectCasesDto {
@IsArray() @ArrayMaxSize(500) @IsUUID('4', { each: true }) serviceCasePublicIds!: string[];
}
export class ActivityQueryDto extends PaginationDto {
@IsOptional() @IsIn(ACTIVITY_STATUSES) status?: string;
@IsOptional() @IsUUID('4') projectPublicId?: string;
}
export class PublicActivityQueryDto extends PaginationDto {
@IsOptional() @IsUUID('4') projectPublicId?: string;
}
export class CreateActivityDto {
@IsUUID('4') clientRequestId!: string;
@IsOptional() @IsUUID('4') projectPublicId?: string;
@IsString() @Length(2, 200) title!: string;
@IsString() @Length(10, 500) summary!: string;
@IsString() @Length(20, 20000) content!: string;
@IsOptional() @IsUUID('4') coverFilePublicId?: string;
@IsISO8601({ strict: true }) startAt!: string;
@IsISO8601({ strict: true }) endAt!: string;
@IsISO8601({ strict: true }) registrationDeadline!: string;
@IsString() @Length(2, 300) locationText!: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100_000) capacity?: number;
}
export class UpdateActivityDto extends OmitType(CreateActivityDto, ['clientRequestId'] as const) {
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
}
export class TransitionActivityDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(ACTIVITY_STATUSES.filter((status) => status !== 'DRAFT')) targetStatus!: string;
}
export class RegisterActivityDto {
@IsUUID('4') clientRequestId!: string;
}
export class RegistrationQueryDto extends PaginationDto {
@IsOptional() @IsIn(REGISTRATION_STATUSES) status?: string;
@IsOptional() @IsUUID('4') activityPublicId?: string;
}
export class ReviewRegistrationDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['APPROVED', 'REJECTED']) targetStatus!: 'APPROVED' | 'REJECTED';
@IsString() @Length(2, 500) note!: string;
}
export class CheckInRegistrationDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(CHECK_IN_STATUSES.filter((status) => status !== 'NOT_CHECKED_IN')) targetStatus!:
'CHECKED_IN' | 'ABSENT';
}
export class RecordServiceMinutesDto {
@IsUUID('4') clientRequestId!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(100_800) serviceMinutes!: number;
}
export class CreateMediaUploadIntentDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['PROJECT_COVER', 'ACTIVITY_COVER']) businessType!: 'PROJECT_COVER' | 'ACTIVITY_COVER';
@IsString() @Length(1, 255) filename!: string;
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
}
@@ -0,0 +1,89 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Post,
Put,
Query,
Req,
ValidationPipe,
} from '@nestjs/common';
import type {
ActivityRegistrationResponse,
PaginatedActivityRegistrationsResponse,
VolunteerProfileResponse,
} from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import { ActivitiesService } from './activities.service';
import {
ApplyVolunteerDto,
RegisterActivityDto,
RegistrationQueryDto,
} from './dto/participation.dto';
import { VolunteersService } from './volunteers.service';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Controller('api/v1')
@RequireSessionChannel(SessionChannel.WECHAT)
export class MeParticipationController {
constructor(
@Inject(VolunteersService) private readonly volunteers: VolunteersService,
@Inject(ActivitiesService) private readonly activities: ActivitiesService,
) {}
@Get('me/volunteer')
mine(@CurrentAuth() actor: AuthContext): Promise<VolunteerProfileResponse> {
return this.volunteers.mine(actor);
}
@Put('me/volunteer')
apply(
@Body(strict(ApplyVolunteerDto)) input: ApplyVolunteerDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<VolunteerProfileResponse> {
return this.volunteers.apply(input, actor, request);
}
@Get('me/activity-registrations')
registrations(
@Query(strict(RegistrationQueryDto)) query: RegistrationQueryDto,
@CurrentAuth() actor: AuthContext,
): Promise<PaginatedActivityRegistrationsResponse> {
return this.activities.myRegistrations(query, actor);
}
@Post('activities/:publicId/registrations')
register(
@Param('publicId') publicId: string,
@Body(strict(RegisterActivityDto)) input: RegisterActivityDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityRegistrationResponse> {
return this.activities.register(publicId, input, actor, request);
}
@Post('me/activity-registrations/:publicId/cancel')
cancel(
@Param('publicId') publicId: string,
@Body(strict(RegisterActivityDto)) input: RegisterActivityDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityRegistrationResponse> {
return this.activities.cancel(publicId, input, actor, request);
}
}
+234
View File
@@ -0,0 +1,234 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
type ConfirmUploadResponse,
type CreateUploadIntentResponse,
type ServiceRecordAttachment,
} from '@lehuo/contracts';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { FilesRepository } from '../files/files.repository';
import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
import type { File, FileBusinessType } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type { CreateMediaUploadIntentDto } from './dto/participation.dto';
const EXTENSIONS: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};
@Injectable()
export class MediaService {
constructor(
@Inject(FilesRepository) private readonly files: FilesRepository,
@Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
) {}
async createIntent(
input: CreateMediaUploadIntentDto,
actor: AuthContext,
request: RequestContext,
): Promise<CreateUploadIntentResponse> {
this.assertConfigured();
const filename = this.safeFilename(input.filename);
const extension = EXTENSIONS[input.mimeType];
if (!extension || !this.extensionMatches(filename, extension)) {
throw this.validation('文件扩展名与图片类型不一致');
}
const fingerprint = this.sensitive.lookupHash(
'cover-upload-intent',
JSON.stringify({ ...input, filename }),
);
const previous = await this.files.findByIdempotency(actor.userId, input.clientRequestId);
if (previous) {
if (
previous.requestFingerprint !== fingerprint ||
previous.businessType !== input.businessType
) {
throw this.duplicate();
}
return this.uploadResponse(previous, true);
}
const now = new Date();
const objectKey = `public-covers/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
const file = await this.files.createPending(
{
publicId: randomUUID(),
businessType: input.businessType,
clientRequestId: input.clientRequestId,
requestFingerprint: fingerprint,
provider: this.storage.providerName,
bucket: this.storage.bucket ?? '',
objectKey,
filenameEncrypted: this.sensitive.encrypt(filename),
mimeType: input.mimeType,
size: BigInt(input.size),
sha256: input.sha256,
},
actor,
request,
);
return this.uploadResponse(file, false);
}
async confirm(
publicId: string,
actor: AuthContext,
request: RequestContext,
): Promise<ConfirmUploadResponse> {
this.assertConfigured();
const file = await this.files.findByPublicId(publicId);
if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId)
throw this.notFound();
if (file.businessType !== 'PROJECT_COVER' && file.businessType !== 'ACTIVITY_COVER') {
throw this.notFound();
}
if (file.status === 'AVAILABLE') return { file: this.attachment(file) };
if (file.status !== 'UPLOAD_PENDING') throw this.notReady();
let metadata;
try {
metadata = await this.storage.headObject(file.objectKey);
} catch {
throw this.storageUnavailable();
}
if (!metadata) throw this.notReady();
if (
metadata.contentLength !== Number(file.size) ||
metadata.contentType !== file.mimeType ||
(file.sha256 !== null && metadata.sha256 !== file.sha256)
) {
await this.files.quarantine(publicId, actor, request, 'OBJECT_METADATA_MISMATCH');
throw this.validation('图片校验未通过,请重新选择文件');
}
return { file: this.attachment(await this.files.markAvailable(publicId, actor, request)) };
}
async requireCover(
publicId: string | undefined,
businessType: FileBusinessType,
actor: AuthContext,
): Promise<File | null> {
if (!publicId) return null;
const file = await this.files.findByPublicId(publicId);
if (
!file ||
file.status !== 'AVAILABLE' ||
file.businessType !== businessType ||
file.uploaderUserId !== actor.userId ||
file.caseId !== null
) {
throw this.validation('封面图片无效或尚未确认上传');
}
return file;
}
async publicUrl(file: Pick<File, 'objectKey' | 'status'> | null): Promise<string | null> {
if (!file || file.status !== 'AVAILABLE' || !this.storage.configured) return null;
try {
return await this.storage.createDownloadUrl(file.objectKey, 600);
} catch {
return null;
}
}
private async uploadResponse(
file: File,
idempotentReplay: boolean,
): Promise<CreateUploadIntentResponse> {
const expiresIn = 600;
let uploadUrl: string;
try {
uploadUrl = await this.storage.createUploadUrl(
file.objectKey,
file.mimeType,
file.sha256 ?? undefined,
expiresIn,
);
} catch {
throw this.storageUnavailable();
}
return {
file: this.attachment(file),
uploadUrl,
uploadHeaders: {
'Content-Type': file.mimeType,
...(file.sha256 ? { 'x-amz-meta-sha256': file.sha256 } : {}),
},
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
idempotentReplay,
};
}
private attachment(file: File): ServiceRecordAttachment {
return {
publicId: file.publicId,
filename: this.sensitive.decrypt(file.originalFilenameEncrypted),
mimeType: file.mimeType,
size: Number(file.size),
};
}
private safeFilename(value: string): string {
const filename = value.trim();
if (
!filename ||
filename.includes('/') ||
filename.includes('\\') ||
Array.from(filename).some((character) => character.charCodeAt(0) <= 31)
) {
throw this.validation('文件名不合法');
}
return filename;
}
private extensionMatches(filename: string, extension: string): boolean {
const lower = filename.toLowerCase();
return lower.endsWith(`.${extension}`) || (extension === 'jpg' && lower.endsWith('.jpeg'));
}
private assertConfigured(): void {
if (!this.storage.configured || !this.storage.bucket) throw this.storageUnavailable();
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private duplicate(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他文件',
HttpStatus.CONFLICT,
);
}
private notFound(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.FILE_NOT_FOUND,
'未找到该图片',
HttpStatus.NOT_FOUND,
);
}
private notReady(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.UPLOAD_NOT_READY,
'尚未收到上传文件,请完成上传后重试',
HttpStatus.CONFLICT,
);
}
private storageUnavailable(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.STORAGE_UNAVAILABLE,
'图片服务尚未配置或暂时不可用',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { ContentNotificationsModule } from '../content-notifications/content-notifications.module';
import { FilesModule } from '../files/files.module';
import { ActivitiesService } from './activities.service';
import { AdminParticipationController } from './admin-participation.controller';
import { MeParticipationController } from './me-participation.controller';
import { MediaService } from './media.service';
import { ProjectsService } from './projects.service';
import { PublicParticipationController } from './public-participation.controller';
import { VolunteersService } from './volunteers.service';
@Module({
imports: [AuditModule, FilesModule, ContentNotificationsModule],
controllers: [
PublicParticipationController,
MeParticipationController,
AdminParticipationController,
],
providers: [VolunteersService, ProjectsService, ActivitiesService, MediaService],
})
export class ParticipationModule {}
@@ -0,0 +1,496 @@
import { ForbiddenException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomBytes, randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
PERMISSION_CODES,
PROJECT_STATUS_LABELS,
type PaginatedProjectsResponse,
type ProjectDetail,
type ProjectDetailResponse,
type ProjectListItem,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
CreateProjectDto,
ProjectQueryDto,
PublicProjectQueryDto,
ReplaceProjectCasesDto,
TransitionProjectDto,
UpdateProjectDto,
} from './dto/participation.dto';
import { MediaService } from './media.service';
const projectInclude = {
coverFile: true,
manager: { include: { staffProfile: true } },
statusHistory: { orderBy: { createdAt: 'desc' as const } },
_count: { select: { activities: true, serviceCases: true } },
} satisfies Prisma.ProjectInclude;
type StoredProject = Prisma.ProjectGetPayload<{ include: typeof projectInclude }>;
function dateNoPrefix(date: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${value.year}${value.month}${value.day}`;
}
@Injectable()
export class ProjectsService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
@Inject(MediaService) private readonly media: MediaService,
) {}
async managerOptions() {
const users = await this.prisma.user.findMany({
where: {
status: 'ACTIVE',
staffProfile: { is: { status: 'ACTIVE' } },
userRoles: { some: { role: { code: 'PROJECT_MANAGER' } } },
},
include: { staffProfile: true },
orderBy: { staffProfile: { displayName: 'asc' } },
});
return users.map((user) => ({
userPublicId: user.publicId,
displayName: user.staffProfile?.displayName ?? '项目负责人',
type: 'STAFF' as const,
department: user.staffProfile?.department ?? null,
}));
}
async listAdmin(query: ProjectQueryDto, actor: AuthContext): Promise<PaginatedProjectsResponse> {
const where: Prisma.ProjectWhereInput = {
...(query.status ? { status: query.status as never } : {}),
...(query.category ? { category: query.category as never } : {}),
...(!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
? { managerUserId: actor.userId }
: {}),
};
return this.list(where, query.page, query.pageSize);
}
async listPublic(query: PublicProjectQueryDto): Promise<PaginatedProjectsResponse> {
return this.list(
{
isPublic: true,
status: { in: ['PUBLISHED', 'COMPLETED'] },
...(query.category ? { category: query.category as never } : {}),
},
query.page,
query.pageSize,
);
}
async getAdmin(publicId: string, actor: AuthContext): Promise<ProjectDetailResponse> {
const project = await this.find(publicId);
this.assertAccess(project, actor);
return { project: await this.detail(project) };
}
async getPublic(publicId: string): Promise<ProjectDetailResponse> {
const project = await this.find(publicId);
if (!project.isPublic || !['PUBLISHED', 'COMPLETED'].includes(project.status))
throw this.notFound();
return { project: await this.detail(project) };
}
async create(
input: CreateProjectDto,
actor: AuthContext,
request: RequestContext,
): Promise<ProjectDetailResponse> {
const values = await this.prepare(input, actor);
const fingerprint = this.fingerprint('project-create', {
...input,
name: input.name.trim(),
summary: input.summary.trim(),
content: input.content.trim(),
});
const replay = await this.prisma.project.findUnique({
where: {
createdById_clientRequestId: {
createdById: actor.userId,
clientRequestId: input.clientRequestId,
},
},
include: projectInclude,
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return { project: await this.detail(replay), idempotentReplay: true };
}
const project = await this.prisma.$transaction(async (transaction) => {
const created = await transaction.project.create({
data: {
publicId: randomUUID(),
projectNo: `XM${dateNoPrefix(new Date())}${randomBytes(4).toString('hex').toUpperCase()}`,
clientRequestId: input.clientRequestId,
requestFingerprint: fingerprint,
...values,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await transaction.projectStatusHistory.create({
data: {
projectId: created.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
toStatus: 'DRAFT',
operatorUserId: actor.userId,
reasonCode: 'CREATED',
},
});
await this.audit.record(
request,
{
action: 'PROJECT_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'PROJECT',
resourcePublicId: created.publicId,
metadata: { status: 'DRAFT' },
},
transaction,
);
return transaction.project.findUniqueOrThrow({
where: { id: created.id },
include: projectInclude,
});
});
return { project: await this.detail(project), idempotentReplay: false };
}
async update(
publicId: string,
input: UpdateProjectDto,
actor: AuthContext,
request: RequestContext,
): Promise<ProjectDetailResponse> {
const project = await this.find(publicId);
this.assertAccess(project, actor);
if (project.status !== 'DRAFT') throw this.invalidTransition('只有草稿项目可以编辑');
if (project.revision !== input.expectedRevision) throw this.conflict();
const values = await this.prepare(input, actor);
const updated = await this.prisma
.$transaction(async (transaction) => {
const result = await transaction.project.updateMany({
where: { id: project.id, revision: input.expectedRevision, status: 'DRAFT' },
data: { ...values, updatedById: actor.userId, revision: { increment: 1 } },
});
if (result.count !== 1) throw new Error('PROJECT_CONFLICT');
await this.audit.record(
request,
{
action: 'PROJECT_UPDATED',
result: 'SUCCESS',
actor,
resourceType: 'PROJECT',
resourcePublicId: publicId,
},
transaction,
);
return transaction.project.findUniqueOrThrow({
where: { id: project.id },
include: projectInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'PROJECT_CONFLICT') throw this.conflict();
throw error;
});
return { project: await this.detail(updated) };
}
async transition(
publicId: string,
input: TransitionProjectDto,
actor: AuthContext,
request: RequestContext,
): Promise<ProjectDetailResponse> {
const project = await this.find(publicId);
this.assertAccess(project, actor);
const fingerprint = this.fingerprint('project-transition', {
publicId,
targetStatus: input.targetStatus,
});
const replay = await this.prisma.projectStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { project: { include: projectInclude } },
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return { project: await this.detail(replay.project), idempotentReplay: true };
}
const expected = input.targetStatus === 'PUBLISHED' ? 'DRAFT' : 'PUBLISHED';
if (project.status !== expected) throw this.invalidTransition();
const updated = await this.prisma
.$transaction(async (transaction) => {
const result = await transaction.project.updateMany({
where: { id: project.id, status: expected },
data: {
status: input.targetStatus,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (result.count !== 1) throw new Error('PROJECT_STATE_CONFLICT');
await transaction.projectStatusHistory.create({
data: {
projectId: project.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
fromStatus: expected,
toStatus: input.targetStatus,
operatorUserId: actor.userId,
reasonCode: input.targetStatus === 'PUBLISHED' ? 'PUBLISHED' : 'COMPLETED',
},
});
await this.audit.record(
request,
{
action: 'PROJECT_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'PROJECT',
resourcePublicId: publicId,
metadata: { fromStatus: expected, toStatus: input.targetStatus },
},
transaction,
);
return transaction.project.findUniqueOrThrow({
where: { id: project.id },
include: projectInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'PROJECT_STATE_CONFLICT')
throw this.invalidTransition();
throw error;
});
return { project: await this.detail(updated), idempotentReplay: false };
}
async replaceCases(
publicId: string,
input: ReplaceProjectCasesDto,
actor: AuthContext,
request: RequestContext,
): Promise<ProjectDetailResponse> {
const project = await this.find(publicId);
this.assertAccess(project, actor);
if (project.status === 'COMPLETED') throw this.invalidTransition('已完成项目不能调整服务工单');
if (new Set(input.serviceCasePublicIds).size !== input.serviceCasePublicIds.length) {
throw this.validation('服务工单不能重复');
}
const cases = await this.prisma.serviceCase.findMany({
where: { publicId: { in: input.serviceCasePublicIds } },
select: { id: true, projectId: true },
});
if (cases.length !== input.serviceCasePublicIds.length)
throw this.validation('包含无效的服务工单');
if (
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) &&
cases.some(
(serviceCase) => serviceCase.projectId !== null && serviceCase.projectId !== project.id,
)
) {
throw new ForbiddenException();
}
const updated = await this.prisma.$transaction(async (transaction) => {
await transaction.serviceCase.updateMany({
where: { projectId: project.id },
data: { projectId: null },
});
if (cases.length) {
await transaction.serviceCase.updateMany({
where: { id: { in: cases.map((item) => item.id) } },
data: { projectId: project.id },
});
}
await this.audit.record(
request,
{
action: 'PROJECT_CASES_REPLACED',
result: 'SUCCESS',
actor,
resourceType: 'PROJECT',
resourcePublicId: publicId,
metadata: { serviceCaseCount: cases.length },
},
transaction,
);
return transaction.project.findUniqueOrThrow({
where: { id: project.id },
include: projectInclude,
});
});
return { project: await this.detail(updated) };
}
private async prepare(input: CreateProjectDto | UpdateProjectDto, actor: AuthContext) {
const startDate = new Date(`${input.startDate}T00:00:00.000Z`);
const endDate = new Date(`${input.endDate}T00:00:00.000Z`);
if (endDate < startDate) throw this.validation('项目结束日期不能早于开始日期');
const manager = await this.prisma.user.findFirst({
where: {
publicId: input.managerUserPublicId,
status: 'ACTIVE',
staffProfile: { isNot: null },
userRoles: { some: { role: { code: 'PROJECT_MANAGER' } } },
},
});
if (!manager) throw this.validation('负责人必须是有效的项目管理员');
const cover = await this.media.requireCover(input.coverFilePublicId, 'PROJECT_COVER', actor);
return {
name: input.name.trim(),
category: input.category as never,
summary: input.summary.trim(),
content: input.content.trim(),
coverFileId: cover?.id,
managerUserId: manager.id,
startDate,
endDate,
targetPeople: input.targetPeople,
isPublic: input.isPublic,
sortOrder: input.sortOrder,
};
}
private async list(
where: Prisma.ProjectWhereInput,
page: number,
pageSize: number,
): Promise<PaginatedProjectsResponse> {
const [total, projects] = await this.prisma.$transaction([
this.prisma.project.count({ where }),
this.prisma.project.findMany({
where,
include: projectInclude,
orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
items: await Promise.all(projects.map((project) => this.item(project))),
page,
pageSize,
total,
};
}
private async find(publicId: string): Promise<StoredProject> {
const project = await this.prisma.project.findUnique({
where: { publicId },
include: projectInclude,
});
if (!project) throw this.notFound();
return project;
}
private assertAccess(project: StoredProject, actor: AuthContext): void {
if (
project.managerUserId !== actor.userId &&
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
) {
throw new ForbiddenException();
}
}
private async item(project: StoredProject): Promise<ProjectListItem> {
return {
publicId: project.publicId,
projectNo: project.projectNo,
name: project.name,
category: project.category,
summary: project.summary,
coverImageUrl: await this.media.publicUrl(project.coverFile),
managerUserPublicId: project.manager.publicId,
managerDisplayName:
project.manager.staffProfile?.displayName ?? project.manager.nickname ?? '项目负责人',
startDate: project.startDate.toISOString().slice(0, 10),
endDate: project.endDate.toISOString().slice(0, 10),
targetPeople: project.targetPeople,
status: project.status,
statusLabel: PROJECT_STATUS_LABELS[project.status],
isPublic: project.isPublic,
sortOrder: project.sortOrder,
revision: project.revision,
activityCount: project._count.activities,
serviceCaseCount: project._count.serviceCases,
createdAt: project.createdAt.toISOString(),
};
}
private async detail(project: StoredProject): Promise<ProjectDetail> {
return {
...(await this.item(project)),
content: project.content,
coverFilePublicId: project.coverFile?.publicId ?? null,
history: project.statusHistory.map((history) => ({
status: history.toStatus,
statusLabel: PROJECT_STATUS_LABELS[history.toStatus],
reasonLabel:
(
{ CREATED: '创建项目', PUBLISHED: '发布项目', COMPLETED: '完成项目' } as Record<
string,
string
>
)[history.reasonCode] ?? '状态更新',
createdAt: history.createdAt.toISOString(),
})),
};
}
private fingerprint(domain: string, value: object): string {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private notFound(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.PROJECT_NOT_FOUND,
'未找到该公益项目',
HttpStatus.NOT_FOUND,
);
}
private conflict(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'项目已更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private invalidTransition(message = '当前项目状态不能执行该操作'): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
message,
HttpStatus.CONFLICT,
);
}
private duplicate(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
}
@@ -0,0 +1,54 @@
import { Controller, Get, Inject, Param, Query, ValidationPipe } from '@nestjs/common';
import type {
ActivityDetailResponse,
PaginatedActivitiesResponse,
PaginatedProjectsResponse,
ProjectDetailResponse,
} from '@lehuo/contracts';
import { Public } from '../common/decorators/public.decorator';
import { ActivitiesService } from './activities.service';
import { PublicActivityQueryDto, PublicProjectQueryDto } from './dto/participation.dto';
import { ProjectsService } from './projects.service';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Public()
@Controller('api/v1/public')
export class PublicParticipationController {
constructor(
@Inject(ProjectsService) private readonly projects: ProjectsService,
@Inject(ActivitiesService) private readonly activities: ActivitiesService,
) {}
@Get('projects')
projectsList(
@Query(strict(PublicProjectQueryDto)) query: PublicProjectQueryDto,
): Promise<PaginatedProjectsResponse> {
return this.projects.listPublic(query);
}
@Get('projects/:publicId')
project(@Param('publicId') publicId: string): Promise<ProjectDetailResponse> {
return this.projects.getPublic(publicId);
}
@Get('activities')
activitiesList(
@Query(strict(PublicActivityQueryDto)) query: PublicActivityQueryDto,
): Promise<PaginatedActivitiesResponse> {
return this.activities.listPublic(query);
}
@Get('activities/:publicId')
activity(@Param('publicId') publicId: string): Promise<ActivityDetailResponse> {
return this.activities.getPublic(publicId);
}
}
@@ -0,0 +1,474 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
VOLUNTEER_SKILL_LABELS,
VOLUNTEER_STATUS_LABELS,
type AdminVolunteerDetailResponse,
type PaginatedVolunteersResponse,
type VolunteerProfile,
type VolunteerProfileResponse,
type VolunteerSkillCode,
type VolunteerStatus,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
ApplyVolunteerDto,
RestoreVolunteerDto,
ReviewVolunteerDto,
VolunteerQueryDto,
} from './dto/participation.dto';
const volunteerInclude = {
skills: true,
interests: true,
statusHistory: { orderBy: { createdAt: 'desc' as const } },
registrations: {
where: { status: 'APPROVED' as const, serviceMinutes: { not: null } },
select: { serviceMinutes: true },
},
} satisfies Prisma.VolunteerInclude;
type StoredVolunteer = Prisma.VolunteerGetPayload<{ include: typeof volunteerInclude }>;
function maskMobile(value: string): string {
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
}
@Injectable()
export class VolunteersService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
) {}
async mine(actor: AuthContext): Promise<VolunteerProfileResponse> {
const volunteer = await this.prisma.volunteer.findUnique({
where: { userId: actor.userId },
include: volunteerInclude,
});
return { volunteer: volunteer ? this.profile(volunteer) : null };
}
async apply(
input: ApplyVolunteerDto,
actor: AuthContext,
request: RequestContext,
): Promise<VolunteerProfileResponse> {
this.assertUnique(input.skillCodes, '技能不能重复');
this.assertUnique(input.interestCategories, '服务意向不能重复');
const normalized = {
realName: input.realName.trim(),
mobile: input.mobile,
regionCode: input.regionCode.trim(),
availabilityNote: input.availabilityNote.trim(),
skillCodes: [...input.skillCodes].sort(),
interestCategories: [...input.interestCategories].sort(),
expectedRevision: input.expectedRevision ?? null,
privacyConsent: input.privacyConsent,
};
const fingerprint = this.sensitive.lookupHash(
'volunteer-application',
JSON.stringify(normalized),
);
const existing = await this.prisma.volunteer.findUnique({
where: { userId: actor.userId },
include: volunteerInclude,
});
const replay = await this.prisma.volunteerStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { volunteer: { include: volunteerInclude } },
});
if (replay) {
if (replay.operatorUserId !== actor.userId || replay.requestFingerprint !== fingerprint) {
throw this.duplicate();
}
return { volunteer: this.profile(replay.volunteer), idempotentReplay: true };
}
if (existing && input.expectedRevision !== existing.revision) {
throw this.conflict('志愿者资料已更新,请刷新后重试');
}
if (!existing && input.expectedRevision !== undefined) {
throw this.validation('首次申请不应包含资料版本');
}
const identityChanged = Boolean(
existing &&
(existing.realNameHash !== this.sensitive.lookupHash('volunteer-name', normalized.realName) ||
existing.mobileHash !== this.sensitive.lookupHash('volunteer-mobile', normalized.mobile)),
);
const nextStatus: VolunteerStatus =
existing?.status === 'REJECTED' || existing?.status === 'INACTIVE'
? 'APPLIED'
: existing?.status === 'APPROVED' && identityChanged
? 'APPLIED'
: (existing?.status ?? 'APPLIED');
const created = await this.prisma
.$transaction(async (transaction) => {
const consent = await transaction.consent.create({
data: {
publicId: randomUUID(),
userId: actor.userId,
consentType: 'VOLUNTEER_APPLICATION',
version: 'volunteer-application-v1',
grantedBy: 'SELF',
grantedAt: new Date(),
evidenceJson: { affirmativeAction: true },
ipHash: request.ip ? this.sensitive.auditIpHash(request.ip) : null,
},
});
let volunteer;
if (existing) {
const updated = await transaction.volunteer.updateMany({
where: { id: existing.id, revision: existing.revision },
data: {
consentId: consent.id,
realNameEncrypted: this.sensitive.encrypt(normalized.realName),
realNameHash: this.sensitive.lookupHash('volunteer-name', normalized.realName),
mobileEncrypted: this.sensitive.encrypt(normalized.mobile),
mobileHash: this.sensitive.lookupHash('volunteer-mobile', normalized.mobile),
regionCode: normalized.regionCode,
availabilityNoteEncrypted: this.sensitive.encrypt(normalized.availabilityNote),
status: nextStatus,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (updated.count !== 1) throw new Error('REVISION_CONFLICT');
await transaction.volunteerSkill.deleteMany({ where: { volunteerId: existing.id } });
await transaction.volunteerInterest.deleteMany({ where: { volunteerId: existing.id } });
volunteer = await transaction.volunteer.findUniqueOrThrow({ where: { id: existing.id } });
} else {
volunteer = await transaction.volunteer.create({
data: {
publicId: randomUUID(),
userId: actor.userId,
consentId: consent.id,
realNameEncrypted: this.sensitive.encrypt(normalized.realName),
realNameHash: this.sensitive.lookupHash('volunteer-name', normalized.realName),
mobileEncrypted: this.sensitive.encrypt(normalized.mobile),
mobileHash: this.sensitive.lookupHash('volunteer-mobile', normalized.mobile),
regionCode: normalized.regionCode,
availabilityNoteEncrypted: this.sensitive.encrypt(normalized.availabilityNote),
status: nextStatus,
createdById: actor.userId,
updatedById: actor.userId,
},
});
}
if (normalized.skillCodes.length) {
await transaction.volunteerSkill.createMany({
data: normalized.skillCodes.map((skillCode) => ({
volunteerId: volunteer.id,
skillCode,
})),
});
}
if (normalized.interestCategories.length) {
await transaction.volunteerInterest.createMany({
data: normalized.interestCategories.map((category) => ({
volunteerId: volunteer.id,
category: category as never,
})),
});
}
if (existing?.status === 'APPROVED' && nextStatus === 'APPLIED') {
const role = await transaction.role.findUniqueOrThrow({
where: { code: 'VOLUNTEER' },
});
await transaction.userRole.deleteMany({
where: { userId: actor.userId, roleId: role.id },
});
}
await transaction.volunteerStatusHistory.create({
data: {
volunteerId: volunteer.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
fromStatus: existing?.status,
toStatus: nextStatus,
operatorUserId: actor.userId,
reasonCode: existing
? nextStatus === 'APPLIED'
? identityChanged
? 'IDENTITY_CHANGED'
: 'REAPPLIED'
: 'PROFILE_UPDATED'
: 'APPLIED',
},
});
await this.audit.record(
request,
{
action: existing ? 'VOLUNTEER_PROFILE_UPDATED' : 'VOLUNTEER_APPLICATION_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'VOLUNTEER',
resourcePublicId: volunteer.publicId,
metadata: { status: nextStatus },
},
transaction,
);
return transaction.volunteer.findUniqueOrThrow({
where: { id: volunteer.id },
include: volunteerInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'REVISION_CONFLICT') {
throw this.conflict('志愿者资料已更新,请刷新后重试');
}
throw error;
});
return { volunteer: this.profile(created), idempotentReplay: false };
}
async list(query: VolunteerQueryDto): Promise<PaginatedVolunteersResponse> {
const where = {
...(query.status ? { status: query.status as never } : {}),
...(query.regionCode ? { regionCode: query.regionCode } : {}),
};
const [total, volunteers] = await this.prisma.$transaction([
this.prisma.volunteer.count({ where }),
this.prisma.volunteer.findMany({
where,
include: volunteerInclude,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
return {
items: volunteers.map((volunteer) => {
const item = this.profile(volunteer);
return {
publicId: item.publicId,
realName: item.realName,
mobileMasked: item.mobileMasked,
regionCode: item.regionCode,
skillLabels: item.skillLabels,
interestCategories: item.interestCategories,
status: item.status,
statusLabel: item.statusLabel,
totalServiceMinutes: item.totalServiceMinutes,
completedActivityCount: item.completedActivityCount,
createdAt: item.createdAt,
};
}),
page: query.page,
pageSize: query.pageSize,
total,
};
}
async get(
publicId: string,
actor: AuthContext,
request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
const volunteer = await this.prisma.volunteer.findUnique({
where: { publicId },
include: volunteerInclude,
});
if (!volunteer) throw this.notFound();
await this.audit.record(request, {
action: 'VOLUNTEER_PROFILE_VIEWED',
result: 'SUCCESS',
actor,
resourceType: 'VOLUNTEER',
resourcePublicId: publicId,
});
return { volunteer: this.profile(volunteer) };
}
review(
publicId: string,
input: ReviewVolunteerDto | RestoreVolunteerDto,
actor: AuthContext,
request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
return this.changeStatus(publicId, input, actor, request);
}
private async changeStatus(
publicId: string,
input: ReviewVolunteerDto | RestoreVolunteerDto,
actor: AuthContext,
request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
const fingerprint = this.sensitive.lookupHash(
'volunteer-review',
JSON.stringify({ publicId, targetStatus: input.targetStatus, note: input.note.trim() }),
);
const replay = await this.prisma.volunteerStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { volunteer: { include: volunteerInclude } },
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return { volunteer: this.profile(replay.volunteer) };
}
const volunteer = await this.prisma.volunteer.findUnique({ where: { publicId } });
if (!volunteer) throw this.notFound();
const allowed: Record<string, VolunteerStatus[]> = {
APPLIED: ['APPROVED', 'REJECTED'],
APPROVED: ['SUSPENDED'],
SUSPENDED: ['APPROVED'],
};
if (!allowed[volunteer.status]?.includes(input.targetStatus)) throw this.invalidTransition();
const result = await this.prisma
.$transaction(async (transaction) => {
const changed = await transaction.volunteer.updateMany({
where: { id: volunteer.id, status: volunteer.status },
data: {
status: input.targetStatus,
approvedById: input.targetStatus === 'APPROVED' ? actor.userId : volunteer.approvedById,
approvedAt: input.targetStatus === 'APPROVED' ? new Date() : volunteer.approvedAt,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (changed.count !== 1) throw new Error('STATE_CONFLICT');
const role = await transaction.role.findUniqueOrThrow({ where: { code: 'VOLUNTEER' } });
if (input.targetStatus === 'APPROVED') {
await transaction.userRole.upsert({
where: { userId_roleId: { userId: volunteer.userId, roleId: role.id } },
create: { userId: volunteer.userId, roleId: role.id, assignedBy: actor.userId },
update: { assignedBy: actor.userId, assignedAt: new Date() },
});
} else {
await transaction.userRole.deleteMany({
where: { userId: volunteer.userId, roleId: role.id },
});
}
await transaction.volunteerStatusHistory.create({
data: {
volunteerId: volunteer.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
fromStatus: volunteer.status,
toStatus: input.targetStatus,
operatorUserId: actor.userId,
reasonCode: `REVIEW_${input.targetStatus}`,
noteEncrypted: this.sensitive.encrypt(input.note.trim()),
},
});
await this.audit.record(
request,
{
action: 'VOLUNTEER_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'VOLUNTEER',
resourcePublicId: publicId,
metadata: { fromStatus: volunteer.status, toStatus: input.targetStatus },
},
transaction,
);
return transaction.volunteer.findUniqueOrThrow({
where: { id: volunteer.id },
include: volunteerInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'STATE_CONFLICT')
throw this.invalidTransition();
throw error;
});
return { volunteer: this.profile(result) };
}
private profile(volunteer: StoredVolunteer): VolunteerProfile {
const mobile = this.sensitive.decrypt(volunteer.mobileEncrypted);
const serviceMinutes = volunteer.registrations.reduce(
(sum, item) => sum + (item.serviceMinutes ?? 0),
0,
);
return {
publicId: volunteer.publicId,
realName: this.sensitive.decrypt(volunteer.realNameEncrypted),
mobile,
mobileMasked: maskMobile(mobile),
regionCode: volunteer.regionCode,
availabilityNote: this.sensitive.decrypt(volunteer.availabilityNoteEncrypted),
skillCodes: volunteer.skills.map((skill) => skill.skillCode as VolunteerSkillCode),
skillLabels: volunteer.skills.map(
(skill) => VOLUNTEER_SKILL_LABELS[skill.skillCode as VolunteerSkillCode] ?? skill.skillCode,
),
interestCategories: volunteer.interests.map((interest) => interest.category),
status: volunteer.status,
statusLabel: VOLUNTEER_STATUS_LABELS[volunteer.status],
revision: volunteer.revision,
totalServiceMinutes: serviceMinutes,
completedActivityCount: volunteer.registrations.length,
history: volunteer.statusHistory.map((history) => ({
status: history.toStatus,
statusLabel: VOLUNTEER_STATUS_LABELS[history.toStatus],
reasonLabel: this.reasonLabel(history.reasonCode),
note: history.noteEncrypted ? this.sensitive.decrypt(history.noteEncrypted) : null,
createdAt: history.createdAt.toISOString(),
})),
createdAt: volunteer.createdAt.toISOString(),
};
}
private reasonLabel(code: string): string {
return (
(
{
APPLIED: '提交申请',
REAPPLIED: '重新申请',
IDENTITY_CHANGED: '身份信息变更,重新审核',
PROFILE_UPDATED: '更新资料',
REVIEW_APPROVED: '审核通过',
REVIEW_REJECTED: '审核未通过',
REVIEW_SUSPENDED: '暂停志愿者资格',
} as Record<string, string>
)[code] ?? '状态更新'
);
}
private assertUnique(values: string[], message: string): void {
if (new Set(values).size !== values.length) throw this.validation(message);
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private notFound(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.VOLUNTEER_NOT_FOUND,
'未找到志愿者资料',
HttpStatus.NOT_FOUND,
);
}
private invalidTransition(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
'当前状态不能执行该操作',
HttpStatus.CONFLICT,
);
}
private conflict(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.RESOURCE_CONFLICT, message, HttpStatus.CONFLICT);
}
private duplicate(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
}
@@ -0,0 +1,34 @@
import {
ForbiddenException,
Inject,
Injectable,
type CanActivate,
type ExecutionContext,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { PermissionCode } from '@lehuo/contracts';
import { REQUIRED_PERMISSIONS_KEY } from '../common/decorators/require-permissions.decorator';
import type { RequestContext } from '../common/request-context';
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<PermissionCode[]>(REQUIRED_PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true;
const request = context.switchToHttp().getRequest<RequestContext>();
if (
!request.auth ||
required.some((permission) => !request.auth?.permissions.includes(permission))
) {
throw new ForbiddenException();
}
return true;
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ResourcePolicyService } from './resource-policy.service';
@Module({
providers: [ResourcePolicyService],
exports: [ResourcePolicyService],
})
export class PermissionsModule {}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { PERMISSION_CODES } from '@lehuo/contracts';
import type { AuthContext } from '../common/request-context';
import { ResourcePolicyService } from './resource-policy.service';
function actor(userId: bigint, permissions: AuthContext['permissions'] = []): AuthContext {
return {
userId,
userPublicId: '37c96323-8d3d-4529-b143-5fa245708fbb',
status: 'ACTIVE',
userType: 'STAFF',
displayName: '测试工作人员',
roles: ['STAFF'],
permissions,
sessionId: 1n,
channel: 'ADMIN',
rawToken: 'test-token',
};
}
describe('ResourcePolicyService', () => {
const service = new ResourcePolicyService();
it('allows owners to access their own resource', () => {
expect(
service.canAccessOwnedResource(actor(10n), 10n, PERMISSION_CODES.RESOURCES_ACCESS_ALL),
).toBe(true);
});
it('denies a different owner without elevated permission', () => {
expect(
service.canAccessOwnedResource(actor(10n), 11n, PERMISSION_CODES.RESOURCES_ACCESS_ALL),
).toBe(false);
});
it('allows explicitly elevated cross-resource access', () => {
expect(
service.canAccessOwnedResource(
actor(10n, [PERMISSION_CODES.RESOURCES_ACCESS_ALL]),
11n,
PERMISSION_CODES.RESOURCES_ACCESS_ALL,
),
).toBe(true);
});
it('enforces strict ownership for public self-service resources', () => {
expect(() =>
service.assertResourceOwner(actor(10n, [PERMISSION_CODES.RESOURCES_ACCESS_ALL]), 11n),
).toThrow();
expect(() => service.assertResourceOwner(actor(10n), 10n)).not.toThrow();
});
});
@@ -0,0 +1,32 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import type { PermissionCode } from '@lehuo/contracts';
import type { AuthContext } from '../common/request-context';
@Injectable()
export class ResourcePolicyService {
assertResourceOwner(actor: AuthContext, ownerUserId: bigint): void {
if (actor.userId !== ownerUserId) {
throw new ForbiddenException();
}
}
canAccessOwnedResource(
actor: AuthContext,
ownerUserId: bigint,
elevatedPermission: PermissionCode,
): boolean {
return actor.userId === ownerUserId || actor.permissions.includes(elevatedPermission);
}
assertOwnedResource(
actor: AuthContext,
ownerUserId: bigint,
elevatedPermission: PermissionCode,
): void {
if (!this.canAccessOwnedResource(actor, ownerUserId, elevatedPermission)) {
throw new ForbiddenException();
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { BootstrapAdminService } from '../users/bootstrap-admin.service';
async function main(): Promise<void> {
const app = await NestFactory.createApplicationContext(AppModule, { logger: false });
try {
const publicId = await app.get(BootstrapAdminService).createInitialAdmin();
process.stdout.write(`Initial administrator created: ${publicId}\n`);
} finally {
await app.close();
}
}
void main();
+10
View File
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { SensitiveDataService } from './sensitive-data.service';
@Global()
@Module({
providers: [SensitiveDataService],
exports: [SensitiveDataService],
})
export class SecurityModule {}
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import type { AppConfigService } from '../config/app-config.service';
import { SensitiveDataService } from './sensitive-data.service';
const config = {
fieldEncryptionKey: Buffer.alloc(32, 3),
sessionTokenPepper: 'test-session-token-pepper',
auditIpHashKey: 'test-audit-ip-hash-key',
} as AppConfigService;
describe('SensitiveDataService', () => {
it('encrypts sensitive values with randomized authenticated ciphertext', () => {
const service = new SensitiveDataService(config);
const first = service.encrypt('sensitive-openid');
const second = service.encrypt('sensitive-openid');
expect(first).not.toContain('sensitive-openid');
expect(first).not.toBe(second);
expect(service.decrypt(first)).toBe('sensitive-openid');
});
it('creates stable domain-separated lookup hashes', () => {
const service = new SensitiveDataService(config);
expect(service.lookupHash('openid', 'value')).toBe(service.lookupHash('openid', 'value'));
expect(service.lookupHash('openid', 'value')).not.toBe(service.lookupHash('unionid', 'value'));
});
});
@@ -0,0 +1,53 @@
import { Inject, Injectable } from '@nestjs/common';
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto';
import { AppConfigService } from '../config/app-config.service';
@Injectable()
export class SensitiveDataService {
constructor(@Inject(AppConfigService) private readonly config: AppConfigService) {}
encrypt(value: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', this.config.fieldEncryptionKey, iv);
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [
'v1',
iv.toString('base64url'),
tag.toString('base64url'),
ciphertext.toString('base64url'),
].join('.');
}
decrypt(value: string): string {
const [version, ivValue, tagValue, ciphertextValue] = value.split('.');
if (version !== 'v1' || !ivValue || !tagValue || !ciphertextValue) {
throw new Error('Unsupported encrypted value format');
}
const decipher = createDecipheriv(
'aes-256-gcm',
this.config.fieldEncryptionKey,
Buffer.from(ivValue, 'base64url'),
);
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'));
return Buffer.concat([
decipher.update(Buffer.from(ciphertextValue, 'base64url')),
decipher.final(),
]).toString('utf8');
}
lookupHash(domain: string, value: string): string {
return createHmac('sha256', this.config.fieldEncryptionKey)
.update(`${domain}\0${value}`)
.digest('hex');
}
sessionTokenHash(token: string): string {
return createHmac('sha256', this.config.sessionTokenPepper).update(token).digest('hex');
}
auditIpHash(ip: string): string {
return createHmac('sha256', this.config.auditIpHashKey).update(ip).digest('hex');
}
}
@@ -0,0 +1,49 @@
import { Controller, Get, Inject, Param, Query, Req, ValidationPipe } from '@nestjs/common';
import {
PERMISSION_CODES,
type PaginatedServiceApplicationsResponse,
type ServiceApplicationDetailResponse,
} from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import { AdminServiceApplicationQueryDto } from './dto/service-application-query.dto';
import { ServiceApplicationsService } from './service-applications.service';
@Controller('api/v1/admin/service-applications')
@RequireSessionChannel(SessionChannel.ADMIN)
@RequirePermissions(PERMISSION_CODES.APPLICATIONS_READ)
export class AdminServiceApplicationsController {
constructor(
@Inject(ServiceApplicationsService)
private readonly applications: ServiceApplicationsService,
) {}
@Get()
list(
@Query(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType: AdminServiceApplicationQueryDto,
}),
)
query: AdminServiceApplicationQueryDto,
): Promise<PaginatedServiceApplicationsResponse> {
return this.applications.listAdmin(query);
}
@Get(':publicId')
get(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ServiceApplicationDetailResponse> {
return this.applications.getAdmin(publicId, actor, request);
}
}

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