feat: complete V1 phases 0-7
This commit is contained in:
@@ -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"]
|
||||
@@ -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,尚未开放。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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('*');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const ADMIN_SESSION_COOKIE = 'lehuo_admin_session';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user