feat: complete V1 phases 0-7
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user