feat: complete V1 phases 0-7
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Equals,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import {
|
||||
APPLICATION_APPLY_MODES,
|
||||
SERVICE_CATEGORIES,
|
||||
type ApplicationApplyMode,
|
||||
type CreateServiceApplicationRequest,
|
||||
type ServiceCategory,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
export class CreateServiceApplicationDto implements CreateServiceApplicationRequest {
|
||||
@IsUUID('4')
|
||||
clientRequestId!: string;
|
||||
|
||||
@IsIn(SERVICE_CATEGORIES)
|
||||
serviceCategory!: ServiceCategory;
|
||||
|
||||
@IsIn(APPLICATION_APPLY_MODES)
|
||||
applyMode!: ApplicationApplyMode;
|
||||
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
beneficiaryName!: string;
|
||||
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: 'contactMobile must be a valid mobile number' })
|
||||
contactMobile!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(2, 100)
|
||||
regionCode!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(1000)
|
||||
description!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(2, 100)
|
||||
preferredContactTime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
needsHomeVisit?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
mobilityDifficulty?: boolean;
|
||||
|
||||
@Equals(true, { message: 'privacyConsent must be accepted' })
|
||||
privacyConsent!: true;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import {
|
||||
SERVICE_APPLICATION_STATUSES,
|
||||
SERVICE_CATEGORIES,
|
||||
type ServiceApplicationStatus,
|
||||
type ServiceCategory,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize = 20;
|
||||
}
|
||||
|
||||
export class AdminServiceApplicationQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
applicationNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(SERVICE_CATEGORIES)
|
||||
serviceCategory?: ServiceCategory;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(SERVICE_APPLICATION_STATUSES)
|
||||
status?: ServiceApplicationStatus;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
ApplicationApplyMode,
|
||||
ServiceApplicationStatus,
|
||||
ServiceCategory,
|
||||
} 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 detailInclude = {
|
||||
statusHistory: { orderBy: { createdAt: 'asc' as const } },
|
||||
} satisfies Prisma.ServiceApplicationInclude;
|
||||
|
||||
export type StoredServiceApplication = Prisma.ServiceApplicationGetPayload<{
|
||||
include: typeof detailInclude;
|
||||
}>;
|
||||
|
||||
export interface CreateStoredServiceApplicationInput {
|
||||
publicId: string;
|
||||
applicationNo: string;
|
||||
applicantUserId: bigint;
|
||||
clientRequestId: string;
|
||||
requestFingerprint: string;
|
||||
applyMode: ApplicationApplyMode;
|
||||
serviceCategory: ServiceCategory;
|
||||
beneficiaryNameEncrypted: string;
|
||||
contactMobileEncrypted: string;
|
||||
contactMobileHash: string;
|
||||
regionCode: string;
|
||||
descriptionEncrypted: string;
|
||||
preferredContactTime?: string;
|
||||
needsHomeVisit?: boolean;
|
||||
mobilityDifficulty?: boolean;
|
||||
submittedAt: Date;
|
||||
consentPublicId: string;
|
||||
consentIpHash: string | null;
|
||||
}
|
||||
|
||||
export interface AdminApplicationFilters {
|
||||
applicationNo?: string;
|
||||
serviceCategory?: ServiceCategory;
|
||||
status?: ServiceApplicationStatus;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ServiceApplicationRepository {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
@Inject(NotificationsService) private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
findByIdempotency(
|
||||
applicantUserId: bigint,
|
||||
clientRequestId: string,
|
||||
): Promise<StoredServiceApplication | null> {
|
||||
return this.prisma.serviceApplication.findUnique({
|
||||
where: { applicantUserId_clientRequestId: { applicantUserId, clientRequestId } },
|
||||
include: detailInclude,
|
||||
});
|
||||
}
|
||||
|
||||
async createWithInitialHistory(
|
||||
input: CreateStoredServiceApplicationInput,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<StoredServiceApplication> {
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const consent = await transaction.consent.create({
|
||||
data: {
|
||||
publicId: input.consentPublicId,
|
||||
userId: input.applicantUserId,
|
||||
consentType: 'SERVICE_APPLICATION',
|
||||
version: 'service-application-v1',
|
||||
grantedBy: input.applyMode === 'SELF' ? 'SELF' : 'APPLICANT',
|
||||
grantedAt: input.submittedAt,
|
||||
evidenceJson: {
|
||||
source: 'WECHAT_MINIPROGRAM',
|
||||
confirmationMethod: 'EXPLICIT_CHECKBOX',
|
||||
},
|
||||
ipHash: input.consentIpHash,
|
||||
},
|
||||
});
|
||||
const application = await transaction.serviceApplication.create({
|
||||
data: {
|
||||
publicId: input.publicId,
|
||||
applicationNo: input.applicationNo,
|
||||
applicantUserId: input.applicantUserId,
|
||||
consentId: consent.id,
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: input.requestFingerprint,
|
||||
applyMode: input.applyMode,
|
||||
serviceCategory: input.serviceCategory,
|
||||
beneficiaryNameEncrypted: input.beneficiaryNameEncrypted,
|
||||
contactMobileEncrypted: input.contactMobileEncrypted,
|
||||
contactMobileHash: input.contactMobileHash,
|
||||
regionCode: input.regionCode,
|
||||
descriptionEncrypted: input.descriptionEncrypted,
|
||||
preferredContactTime: input.preferredContactTime,
|
||||
needsHomeVisit: input.needsHomeVisit,
|
||||
mobilityDifficulty: input.mobilityDifficulty,
|
||||
status: 'SUBMITTED',
|
||||
source: 'WECHAT_MINIPROGRAM',
|
||||
submittedAt: input.submittedAt,
|
||||
},
|
||||
});
|
||||
await transaction.serviceApplicationStatusHistory.create({
|
||||
data: {
|
||||
applicationId: application.id,
|
||||
fromStatus: null,
|
||||
toStatus: 'SUBMITTED',
|
||||
operatorUserId: actor.userId,
|
||||
reasonCode: 'APPLICATION_SUBMITTED',
|
||||
createdAt: input.submittedAt,
|
||||
},
|
||||
});
|
||||
await this.notifications.create(transaction, {
|
||||
userId: input.applicantUserId,
|
||||
eventKey: `application:${input.publicId}:submitted`,
|
||||
type: 'APPLICATION_SUBMITTED',
|
||||
title: '服务申请已提交',
|
||||
body: '我们已收到您的申请,工作人员将尽快联系。',
|
||||
resourceType: 'SERVICE_APPLICATION',
|
||||
resourcePublicId: input.publicId,
|
||||
});
|
||||
await this.audit.record(
|
||||
request,
|
||||
{
|
||||
action: 'SERVICE_APPLICATION_SUBMITTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'SERVICE_APPLICATION',
|
||||
resourcePublicId: input.publicId,
|
||||
metadata: {
|
||||
serviceCategory: input.serviceCategory,
|
||||
applyMode: input.applyMode,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
return transaction.serviceApplication.findUniqueOrThrow({
|
||||
where: { id: application.id },
|
||||
include: detailInclude,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
findOwner(publicId: string): Promise<{ applicantUserId: bigint } | null> {
|
||||
return this.prisma.serviceApplication.findUnique({
|
||||
where: { publicId },
|
||||
select: { applicantUserId: true },
|
||||
});
|
||||
}
|
||||
|
||||
findByPublicId(publicId: string): Promise<StoredServiceApplication | null> {
|
||||
return this.prisma.serviceApplication.findUnique({
|
||||
where: { publicId },
|
||||
include: detailInclude,
|
||||
});
|
||||
}
|
||||
|
||||
async listOwned(
|
||||
applicantUserId: bigint,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<{ items: StoredServiceApplication[]; total: number }> {
|
||||
const where = { applicantUserId } satisfies Prisma.ServiceApplicationWhereInput;
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.serviceApplication.findMany({
|
||||
where,
|
||||
include: detailInclude,
|
||||
orderBy: [{ submittedAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.serviceApplication.count({ where }),
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async listAdmin(
|
||||
filters: AdminApplicationFilters,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<{ items: StoredServiceApplication[]; total: number }> {
|
||||
const where: Prisma.ServiceApplicationWhereInput = {
|
||||
...(filters.applicationNo ? { applicationNo: { startsWith: filters.applicationNo } } : {}),
|
||||
...(filters.serviceCategory ? { serviceCategory: filters.serviceCategory } : {}),
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
};
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.serviceApplication.findMany({
|
||||
where,
|
||||
include: detailInclude,
|
||||
orderBy: [{ submittedAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.serviceApplication.count({ where }),
|
||||
]);
|
||||
return { items, total };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
|
||||
import type {
|
||||
CreateServiceApplicationResponse,
|
||||
PaginatedServiceApplicationsResponse,
|
||||
ServiceApplicationDetailResponse,
|
||||
} 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 { CreateServiceApplicationDto } from './dto/create-service-application.dto';
|
||||
import { PaginationQueryDto } from './dto/service-application-query.dto';
|
||||
import { ServiceApplicationsService } from './service-applications.service';
|
||||
|
||||
@Controller('api/v1/service-applications')
|
||||
@RequireSessionChannel(SessionChannel.WECHAT)
|
||||
export class ServiceApplicationsController {
|
||||
constructor(
|
||||
@Inject(ServiceApplicationsService)
|
||||
private readonly applications: ServiceApplicationsService,
|
||||
) {}
|
||||
|
||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||
@Post()
|
||||
create(
|
||||
@Body(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: CreateServiceApplicationDto,
|
||||
}),
|
||||
)
|
||||
input: CreateServiceApplicationDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<CreateServiceApplicationResponse> {
|
||||
return this.applications.create(input, actor, request);
|
||||
}
|
||||
|
||||
@Get()
|
||||
listMine(
|
||||
@Query(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: PaginationQueryDto,
|
||||
}),
|
||||
)
|
||||
query: PaginationQueryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
): Promise<PaginatedServiceApplicationsResponse> {
|
||||
return this.applications.listMine(actor, query.page, query.pageSize);
|
||||
}
|
||||
|
||||
@Get(':publicId')
|
||||
getMine(
|
||||
@Param('publicId') publicId: string,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
): Promise<ServiceApplicationDetailResponse> {
|
||||
return this.applications.getMine(publicId, actor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { ContentNotificationsModule } from '../content-notifications/content-notifications.module';
|
||||
import { AdminServiceApplicationsController } from './admin-service-applications.controller';
|
||||
import { ServiceApplicationRepository } from './service-application.repository';
|
||||
import { ServiceApplicationsController } from './service-applications.controller';
|
||||
import { ServiceApplicationsService } from './service-applications.service';
|
||||
import { ServiceCategoriesController } from './service-categories.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, PermissionsModule, ContentNotificationsModule],
|
||||
controllers: [
|
||||
ServiceCategoriesController,
|
||||
ServiceApplicationsController,
|
||||
AdminServiceApplicationsController,
|
||||
],
|
||||
providers: [ServiceApplicationRepository, ServiceApplicationsService],
|
||||
})
|
||||
export class ServiceApplicationsModule {}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
APPLICATION_APPLY_MODE_LABELS,
|
||||
SERVICE_APPLICATION_STATUS_LABELS,
|
||||
SERVICE_CATEGORY_LABELS,
|
||||
type CreateServiceApplicationResponse,
|
||||
type PaginatedServiceApplicationsResponse,
|
||||
type ServiceApplicationDetail,
|
||||
type ServiceApplicationDetailResponse,
|
||||
type ServiceApplicationListItem,
|
||||
type ServiceCategoriesResponse,
|
||||
} from '@lehuo/contracts';
|
||||
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { ResourcePolicyService } from '../permissions/resource-policy.service';
|
||||
import { SensitiveDataService } from '../security/sensitive-data.service';
|
||||
import type { CreateServiceApplicationDto } from './dto/create-service-application.dto';
|
||||
import type { AdminServiceApplicationQueryDto } from './dto/service-application-query.dto';
|
||||
import {
|
||||
ServiceApplicationRepository,
|
||||
type StoredServiceApplication,
|
||||
} from './service-application.repository';
|
||||
|
||||
const CATEGORY_DESCRIPTIONS = {
|
||||
OLD_AGE: '探访陪伴、生活协助和健康关怀等为老服务',
|
||||
WOMEN: '面向困境妇女的生活支持和关爱服务',
|
||||
CHILD: '面向困境儿童及家庭的关爱与支持',
|
||||
DISABILITY: '面向残障人士的生活支持和关爱服务',
|
||||
LIFE_DIFFICULTY: '面向暂时生活困难的家庭或个人',
|
||||
OTHER: '其他需要工作人员进一步了解的服务需求',
|
||||
} as const;
|
||||
|
||||
function maskMobile(value: string): string {
|
||||
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown): error is { code: 'P2002' } {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
|
||||
}
|
||||
|
||||
function applicationDatePrefix(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 ServiceApplicationsService {
|
||||
constructor(
|
||||
@Inject(ServiceApplicationRepository)
|
||||
private readonly repository: ServiceApplicationRepository,
|
||||
@Inject(SensitiveDataService) private readonly sensitiveData: SensitiveDataService,
|
||||
@Inject(ResourcePolicyService) private readonly policy: ResourcePolicyService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
categories(): ServiceCategoriesResponse {
|
||||
return {
|
||||
items: Object.entries(SERVICE_CATEGORY_LABELS).map(([code, label]) => ({
|
||||
code: code as keyof typeof SERVICE_CATEGORY_LABELS,
|
||||
label,
|
||||
description: CATEGORY_DESCRIPTIONS[code as keyof typeof CATEGORY_DESCRIPTIONS],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async create(
|
||||
input: CreateServiceApplicationDto,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<CreateServiceApplicationResponse> {
|
||||
const normalized = this.normalizeInput(input);
|
||||
const fingerprint = this.fingerprint(normalized);
|
||||
const previous = await this.repository.findByIdempotency(actor.userId, input.clientRequestId);
|
||||
if (previous) return this.replay(previous, fingerprint);
|
||||
|
||||
const submittedAt = new Date();
|
||||
try {
|
||||
const created = await this.repository.createWithInitialHistory(
|
||||
{
|
||||
publicId: randomUUID(),
|
||||
applicationNo: `LH${applicationDatePrefix(submittedAt)}${randomBytes(5).toString('hex').toUpperCase()}`,
|
||||
applicantUserId: actor.userId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
requestFingerprint: fingerprint,
|
||||
applyMode: normalized.applyMode,
|
||||
serviceCategory: normalized.serviceCategory,
|
||||
beneficiaryNameEncrypted: this.sensitiveData.encrypt(normalized.beneficiaryName),
|
||||
contactMobileEncrypted: this.sensitiveData.encrypt(normalized.contactMobile),
|
||||
contactMobileHash: this.sensitiveData.lookupHash(
|
||||
'service-application-mobile',
|
||||
normalized.contactMobile,
|
||||
),
|
||||
regionCode: normalized.regionCode,
|
||||
descriptionEncrypted: this.sensitiveData.encrypt(normalized.description),
|
||||
...(normalized.preferredContactTime
|
||||
? { preferredContactTime: normalized.preferredContactTime }
|
||||
: {}),
|
||||
...(normalized.needsHomeVisit === undefined
|
||||
? {}
|
||||
: { needsHomeVisit: normalized.needsHomeVisit }),
|
||||
...(normalized.mobilityDifficulty === undefined
|
||||
? {}
|
||||
: { mobilityDifficulty: normalized.mobilityDifficulty }),
|
||||
submittedAt,
|
||||
consentPublicId: randomUUID(),
|
||||
consentIpHash: request.ip ? this.sensitiveData.auditIpHash(request.ip) : null,
|
||||
},
|
||||
actor,
|
||||
request,
|
||||
);
|
||||
return { application: this.toDetail(created), idempotentReplay: false };
|
||||
} catch (error) {
|
||||
if (!isUniqueConstraintError(error)) throw error;
|
||||
const concurrent = await this.repository.findByIdempotency(
|
||||
actor.userId,
|
||||
input.clientRequestId,
|
||||
);
|
||||
if (!concurrent) throw error;
|
||||
return this.replay(concurrent, fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
async listMine(
|
||||
actor: AuthContext,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<PaginatedServiceApplicationsResponse> {
|
||||
const result = await this.repository.listOwned(actor.userId, page, pageSize);
|
||||
return {
|
||||
items: result.items.map((item) => this.toListItem(item)),
|
||||
page,
|
||||
pageSize,
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
async getMine(publicId: string, actor: AuthContext): Promise<ServiceApplicationDetailResponse> {
|
||||
const owner = await this.repository.findOwner(publicId);
|
||||
if (!owner) throw this.notFound();
|
||||
this.policy.assertResourceOwner(actor, owner.applicantUserId);
|
||||
const application = await this.repository.findByPublicId(publicId);
|
||||
if (!application) throw this.notFound();
|
||||
return { application: this.toDetail(application) };
|
||||
}
|
||||
|
||||
async listAdmin(
|
||||
query: AdminServiceApplicationQueryDto,
|
||||
): Promise<PaginatedServiceApplicationsResponse> {
|
||||
const result = await this.repository.listAdmin(
|
||||
{
|
||||
...(query.applicationNo?.trim()
|
||||
? { applicationNo: query.applicationNo.trim().toUpperCase() }
|
||||
: {}),
|
||||
...(query.serviceCategory ? { serviceCategory: query.serviceCategory } : {}),
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
},
|
||||
query.page,
|
||||
query.pageSize,
|
||||
);
|
||||
return {
|
||||
items: result.items.map((item) => this.toListItem(item)),
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
async getAdmin(
|
||||
publicId: string,
|
||||
actor: AuthContext,
|
||||
request: RequestContext,
|
||||
): Promise<ServiceApplicationDetailResponse> {
|
||||
const application = await this.repository.findByPublicId(publicId);
|
||||
if (!application) throw this.notFound();
|
||||
await this.audit.record(request, {
|
||||
action: 'SERVICE_APPLICATION_DETAIL_VIEWED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'SERVICE_APPLICATION',
|
||||
resourcePublicId: application.publicId,
|
||||
});
|
||||
return { application: this.toDetail(application) };
|
||||
}
|
||||
|
||||
private normalizeInput(input: CreateServiceApplicationDto) {
|
||||
return {
|
||||
clientRequestId: input.clientRequestId,
|
||||
serviceCategory: input.serviceCategory,
|
||||
applyMode: input.applyMode,
|
||||
beneficiaryName: input.beneficiaryName.trim(),
|
||||
contactMobile: input.contactMobile.trim(),
|
||||
regionCode: input.regionCode.trim(),
|
||||
description: input.description.trim(),
|
||||
preferredContactTime: input.preferredContactTime?.trim() || undefined,
|
||||
needsHomeVisit: input.needsHomeVisit,
|
||||
mobilityDifficulty: input.mobilityDifficulty,
|
||||
};
|
||||
}
|
||||
|
||||
private fingerprint(input: ReturnType<ServiceApplicationsService['normalizeInput']>): string {
|
||||
return this.sensitiveData.lookupHash(
|
||||
'service-application-request',
|
||||
JSON.stringify([
|
||||
input.serviceCategory,
|
||||
input.applyMode,
|
||||
input.beneficiaryName,
|
||||
input.contactMobile,
|
||||
input.regionCode,
|
||||
input.description,
|
||||
input.preferredContactTime ?? null,
|
||||
input.needsHomeVisit ?? null,
|
||||
input.mobilityDifficulty ?? null,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private replay(
|
||||
application: StoredServiceApplication,
|
||||
fingerprint: string,
|
||||
): CreateServiceApplicationResponse {
|
||||
if (application.requestFingerprint !== fingerprint) {
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.DUPLICATE_REQUEST,
|
||||
'本次提交标识已用于其他内容,请返回后重新提交',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
return { application: this.toDetail(application), idempotentReplay: true };
|
||||
}
|
||||
|
||||
private toListItem(application: StoredServiceApplication): ServiceApplicationListItem {
|
||||
const contactMobile = this.sensitiveData.decrypt(application.contactMobileEncrypted);
|
||||
return {
|
||||
publicId: application.publicId,
|
||||
applicationNo: application.applicationNo,
|
||||
serviceCategory: application.serviceCategory,
|
||||
serviceCategoryLabel: SERVICE_CATEGORY_LABELS[application.serviceCategory],
|
||||
applyMode: application.applyMode,
|
||||
applyModeLabel: APPLICATION_APPLY_MODE_LABELS[application.applyMode],
|
||||
beneficiaryName: this.sensitiveData.decrypt(application.beneficiaryNameEncrypted),
|
||||
contactMobileMasked: maskMobile(contactMobile),
|
||||
regionCode: application.regionCode,
|
||||
status: application.status,
|
||||
statusLabel: SERVICE_APPLICATION_STATUS_LABELS[application.status],
|
||||
submittedAt: application.submittedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toDetail(application: StoredServiceApplication): ServiceApplicationDetail {
|
||||
return {
|
||||
...this.toListItem(application),
|
||||
contactMobile: this.sensitiveData.decrypt(application.contactMobileEncrypted),
|
||||
description: this.sensitiveData.decrypt(application.descriptionEncrypted),
|
||||
preferredContactTime: application.preferredContactTime,
|
||||
needsHomeVisit: application.needsHomeVisit,
|
||||
mobilityDifficulty: application.mobilityDifficulty,
|
||||
history: application.statusHistory.map((history) => ({
|
||||
status: history.toStatus,
|
||||
statusLabel: SERVICE_APPLICATION_STATUS_LABELS[history.toStatus],
|
||||
reasonLabel:
|
||||
history.reasonCode === 'APPLICATION_SUBMITTED' ? '我们已收到您的服务申请' : '状态已更新',
|
||||
createdAt: history.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private notFound(): ApplicationError {
|
||||
return new ApplicationError(
|
||||
API_ERROR_CODES.APPLICATION_NOT_FOUND,
|
||||
'未找到该服务申请',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
|
||||
import type { ServiceCategoriesResponse } from '@lehuo/contracts';
|
||||
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { ServiceApplicationsService } from './service-applications.service';
|
||||
|
||||
@Controller('api/v1/service-categories')
|
||||
export class ServiceCategoriesController {
|
||||
constructor(
|
||||
@Inject(ServiceApplicationsService)
|
||||
private readonly applications: ServiceApplicationsService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
list(): ServiceCategoriesResponse {
|
||||
return this.applications.categories();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Controller, Get, Header, Inject, Query, Req, ValidationPipe } from '@nestjs/common';
|
||||
import { PERMISSION_CODES, type StatisticsOverview } from '@lehuo/contracts';
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
|
||||
import { RawResponse } from '../common/decorators/raw-response.decorator';
|
||||
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
|
||||
import type { AuthContext, RequestContext } from '../common/request-context';
|
||||
import { SessionChannel } from '../generated/prisma/enums';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { StatisticsQueryDto } from './statistics.dto';
|
||||
import { StatisticsService } from './statistics.service';
|
||||
const strict = new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
expectedType: StatisticsQueryDto,
|
||||
});
|
||||
@Controller('api/v1/admin/statistics')
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
export class StatisticsController {
|
||||
constructor(
|
||||
@Inject(StatisticsService) private readonly service: StatisticsService,
|
||||
@Inject(AuditService) private readonly audit: AuditService,
|
||||
) {}
|
||||
@Get('overview') @RequirePermissions(PERMISSION_CODES.STATISTICS_READ) overview(
|
||||
@Query(strict) q: StatisticsQueryDto,
|
||||
): Promise<StatisticsOverview> {
|
||||
return this.service.overview(q);
|
||||
}
|
||||
@Get('export.csv')
|
||||
@RawResponse()
|
||||
@Header('content-type', 'text/csv; charset=utf-8')
|
||||
@Header('content-disposition', 'attachment; filename="statistics-summary.csv"')
|
||||
@RequirePermissions(PERMISSION_CODES.EXPORTS_CREATE)
|
||||
async export(
|
||||
@Query(strict) q: StatisticsQueryDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
) {
|
||||
const s = await this.service.overview(q);
|
||||
await this.audit.record(request, {
|
||||
action: 'STATISTICS_EXPORTED',
|
||||
result: 'SUCCESS',
|
||||
actor,
|
||||
resourceType: 'STATISTICS_EXPORT',
|
||||
metadata: { startDate: s.period.startDate, endDate: s.period.endDate, masked: true },
|
||||
});
|
||||
const rows = [
|
||||
['指标', '数值'],
|
||||
['申请数量', s.applicationCount],
|
||||
['已受理数量', s.acceptedCount],
|
||||
['已完成服务', s.completedServiceCount],
|
||||
['服务人次', s.servicePersonTimes],
|
||||
['独立服务对象', s.uniqueBeneficiaryCount],
|
||||
['志愿者数量', s.volunteerCount],
|
||||
['活动数量', s.activityCount],
|
||||
['志愿服务分钟', s.volunteerMinutes],
|
||||
];
|
||||
return '\uFEFF' + rows.map((r) => r.join(',')).join('\r\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsOptional, Matches } from 'class-validator';
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
export class StatisticsQueryDto {
|
||||
@IsOptional() @Matches(DATE) startDate?: string;
|
||||
@IsOptional() @Matches(DATE) endDate?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { StatisticsController } from './statistics.controller';
|
||||
import { StatisticsService } from './statistics.service';
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [StatisticsController],
|
||||
providers: [StatisticsService],
|
||||
})
|
||||
export class StatisticsModule {}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { API_ERROR_CODES, type StatisticsOverview } from '@lehuo/contracts';
|
||||
import { ApplicationError } from '../common/application-error';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import type { StatisticsQueryDto } from './statistics.dto';
|
||||
type CountRow = { count: bigint };
|
||||
type CategoryRow = { category: string; count: bigint };
|
||||
type MonthRow = { month: string; applications: bigint; completed: bigint };
|
||||
@Injectable()
|
||||
export class StatisticsService {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
async overview(q: StatisticsQueryDto): Promise<StatisticsOverview> {
|
||||
const end = q.endDate ? new Date(`${q.endDate}T23:59:59.999Z`) : new Date();
|
||||
const start = q.startDate
|
||||
? new Date(`${q.startDate}T00:00:00.000Z`)
|
||||
: new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth() - 11, 1));
|
||||
if (start > end || end.getTime() - start.getTime() > 366 * 86400000)
|
||||
throw new ApplicationError(
|
||||
API_ERROR_CODES.VALIDATION_ERROR,
|
||||
'统计日期范围必须有效且不超过 366 天',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
const [
|
||||
applications,
|
||||
accepted,
|
||||
completed,
|
||||
personTimes,
|
||||
beneficiaries,
|
||||
volunteers,
|
||||
activities,
|
||||
minutes,
|
||||
byCategory,
|
||||
monthly,
|
||||
] = await Promise.all([
|
||||
this
|
||||
.count`SELECT COUNT(*) count FROM service_applications WHERE submitted_at BETWEEN ${start} AND ${end}`,
|
||||
this
|
||||
.count`SELECT COUNT(*) count FROM service_applications WHERE status IN ('ACCEPTED','SERVICE_PLANNED','IN_SERVICE','SERVICE_COMPLETED','FOLLOW_UP_PENDING','CLOSED') AND submitted_at BETWEEN ${start} AND ${end}`,
|
||||
this
|
||||
.count`SELECT COUNT(*) count FROM service_cases WHERE status='SERVICE_COMPLETED' AND completed_at BETWEEN ${start} AND ${end}`,
|
||||
this
|
||||
.count`SELECT COUNT(*) count FROM service_records WHERE service_start_at BETWEEN ${start} AND ${end}`,
|
||||
this
|
||||
.count`SELECT COUNT(DISTINCT beneficiary_id) count FROM service_cases WHERE status='SERVICE_COMPLETED' AND completed_at BETWEEN ${start} AND ${end}`,
|
||||
this
|
||||
.count`SELECT COUNT(*) count FROM volunteers WHERE status='APPROVED' AND created_at <= ${end}`,
|
||||
this
|
||||
.count`SELECT COUNT(*) count FROM activities WHERE created_at BETWEEN ${start} AND ${end}`,
|
||||
this
|
||||
.count`SELECT COALESCE(SUM(service_minutes),0) count FROM activity_registrations WHERE service_minutes_recorded_at BETWEEN ${start} AND ${end}`,
|
||||
this.prisma.$queryRaw<
|
||||
CategoryRow[]
|
||||
>`SELECT service_category category, COUNT(*) count FROM service_applications WHERE submitted_at BETWEEN ${start} AND ${end} GROUP BY service_category ORDER BY count DESC`,
|
||||
this.prisma.$queryRaw<
|
||||
MonthRow[]
|
||||
>`SELECT DATE_FORMAT(m.month,'%Y-%m') month, SUM(m.applications) applications, SUM(m.completed) completed FROM (SELECT DATE_FORMAT(submitted_at,'%Y-%m-01') month,COUNT(*) applications,0 completed FROM service_applications WHERE submitted_at BETWEEN ${start} AND ${end} GROUP BY month UNION ALL SELECT DATE_FORMAT(completed_at,'%Y-%m-01') month,0 applications,COUNT(*) completed FROM service_cases WHERE completed_at BETWEEN ${start} AND ${end} GROUP BY month) m GROUP BY m.month ORDER BY m.month`,
|
||||
]);
|
||||
return {
|
||||
period: {
|
||||
startDate: start.toISOString().slice(0, 10),
|
||||
endDate: end.toISOString().slice(0, 10),
|
||||
},
|
||||
applicationCount: applications,
|
||||
acceptedCount: accepted,
|
||||
completedServiceCount: completed,
|
||||
servicePersonTimes: personTimes,
|
||||
uniqueBeneficiaryCount: beneficiaries,
|
||||
volunteerCount: volunteers,
|
||||
activityCount: activities,
|
||||
volunteerMinutes: minutes,
|
||||
byCategory: byCategory.map((r) => ({
|
||||
category: r.category as never,
|
||||
count: Number(r.count),
|
||||
})),
|
||||
monthly: monthly.map((r) => ({
|
||||
month: r.month,
|
||||
applications: Number(r.applications),
|
||||
completedServices: Number(r.completed),
|
||||
})),
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
private async count(strings: TemplateStringsArray, ...values: unknown[]) {
|
||||
const rows = await this.prisma.$queryRaw<CountRow[]>(strings, ...values);
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { PasswordService } from '../auth/password.service';
|
||||
|
||||
@Injectable()
|
||||
export class BootstrapAdminService {
|
||||
constructor(
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||
) {}
|
||||
|
||||
async createInitialAdmin(): Promise<string> {
|
||||
const username = process.env.BOOTSTRAP_ADMIN_USERNAME?.trim().toLowerCase();
|
||||
const password = process.env.BOOTSTRAP_ADMIN_PASSWORD;
|
||||
const displayName = process.env.BOOTSTRAP_ADMIN_DISPLAY_NAME?.trim();
|
||||
if (!username || !/^[a-z0-9._-]{3,64}$/.test(username)) {
|
||||
throw new Error('BOOTSTRAP_ADMIN_USERNAME is invalid');
|
||||
}
|
||||
if (!password || password.length < 12 || password.length > 128) {
|
||||
throw new Error('BOOTSTRAP_ADMIN_PASSWORD must contain 12 to 128 characters');
|
||||
}
|
||||
if (!displayName || displayName.length > 100) {
|
||||
throw new Error('BOOTSTRAP_ADMIN_DISPLAY_NAME is invalid');
|
||||
}
|
||||
const passwordHash = await this.passwords.hash(password);
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
if ((await transaction.adminCredential.count()) > 0) {
|
||||
throw new Error('An administrator credential already exists; bootstrap is disabled');
|
||||
}
|
||||
const role = await transaction.role.findUniqueOrThrow({ where: { code: 'SUPER_ADMIN' } });
|
||||
const publicId = randomUUID();
|
||||
const user = await transaction.user.create({
|
||||
data: {
|
||||
publicId,
|
||||
userType: 'STAFF',
|
||||
staffProfile: { create: { displayName } },
|
||||
adminCredential: { create: { username, passwordHash } },
|
||||
},
|
||||
});
|
||||
await transaction.userRole.create({
|
||||
data: { userId: user.id, roleId: role.id },
|
||||
});
|
||||
await transaction.auditLog.create({
|
||||
data: {
|
||||
requestId: randomUUID(),
|
||||
actorUserId: user.id,
|
||||
actorRole: 'SUPER_ADMIN',
|
||||
action: 'BOOTSTRAP_ADMIN_CREATED',
|
||||
resourceType: 'USER',
|
||||
resourcePublicId: publicId,
|
||||
result: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
return publicId;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ArrayMaxSize, ArrayUnique, IsArray, IsIn } from 'class-validator';
|
||||
|
||||
import { ROLE_CODES, type RoleCode, type UpdateUserRolesRequest } from '@lehuo/contracts';
|
||||
|
||||
export class UpdateUserRolesDto implements UpdateUserRolesRequest {
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(ROLE_CODES.length)
|
||||
@IsIn(ROLE_CODES, { each: true })
|
||||
roles!: RoleCode[];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Inject, Param, Put, Req, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { PERMISSION_CODES, type UpdateUserRolesResponse } 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 { SessionChannel } from '../generated/prisma/enums';
|
||||
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
|
||||
import { AdminOriginGuard } from '../auth/admin-origin.guard';
|
||||
import { UpdateUserRolesDto } from './dto/update-user-roles.dto';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Controller('api/v1/admin/users')
|
||||
@RequireSessionChannel(SessionChannel.ADMIN)
|
||||
export class UsersController {
|
||||
constructor(@Inject(UsersService) private readonly users: UsersService) {}
|
||||
|
||||
@RequirePermissions(PERMISSION_CODES.ROLES_MANAGE)
|
||||
@UseGuards(AdminOriginGuard)
|
||||
@Put(':publicId/roles')
|
||||
replaceRoles(
|
||||
@Param('publicId') publicId: string,
|
||||
@Body() input: UpdateUserRolesDto,
|
||||
@CurrentAuth() actor: AuthContext,
|
||||
@Req() request: RequestContext,
|
||||
): Promise<UpdateUserRolesResponse> {
|
||||
return this.users.replaceRoles(publicId, input.roles, 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