feat: complete V1 phases 0-7

This commit is contained in:
2026-08-12 14:35:12 +08:00
commit c3dd9be674
311 changed files with 38750 additions and 0 deletions
@@ -0,0 +1,987 @@
import { ForbiddenException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomBytes, randomUUID } from 'node:crypto';
import {
ACTIVITY_STATUS_LABELS,
API_ERROR_CODES,
CHECK_IN_STATUS_LABELS,
PERMISSION_CODES,
REGISTRATION_STATUS_LABELS,
type ActivityDetail,
type ActivityDetailResponse,
type ActivityListItem,
type ActivityRegistrationItem,
type ActivityRegistrationResponse,
type PaginatedActivitiesResponse,
type PaginatedActivityRegistrationsResponse,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
ActivityQueryDto,
CheckInRegistrationDto,
CreateActivityDto,
PublicActivityQueryDto,
RecordServiceMinutesDto,
RegisterActivityDto,
RegistrationQueryDto,
ReviewRegistrationDto,
TransitionActivityDto,
UpdateActivityDto,
} from './dto/participation.dto';
import { MediaService } from './media.service';
import { NotificationsService } from '../content-notifications/notifications.service';
const activityInclude = {
coverFile: true,
project: true,
statusHistory: { orderBy: { createdAt: 'desc' as const } },
registrations: {
where: { status: { in: ['PENDING' as const, 'APPROVED' as const] } },
select: { id: true },
},
} satisfies Prisma.ActivityInclude;
const registrationInclude = {
activity: true,
volunteer: true,
} satisfies Prisma.ActivityRegistrationInclude;
type StoredActivity = Prisma.ActivityGetPayload<{ include: typeof activityInclude }>;
type StoredRegistration = Prisma.ActivityRegistrationGetPayload<{
include: typeof registrationInclude;
}>;
function dateNoPrefix(date: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${value.year}${value.month}${value.day}`;
}
function maskMobile(value: string): string {
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
}
@Injectable()
export class ActivitiesService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
@Inject(MediaService) private readonly media: MediaService,
@Inject(NotificationsService) private readonly notifications: NotificationsService,
) {}
listAdmin(query: ActivityQueryDto, actor: AuthContext): Promise<PaginatedActivitiesResponse> {
const scoped: Prisma.ActivityWhereInput = actor.permissions.includes(
PERMISSION_CODES.RESOURCES_ACCESS_ALL,
)
? {}
: { OR: [{ createdById: actor.userId }, { project: { managerUserId: actor.userId } }] };
return this.list(
{
...scoped,
...(query.status ? { status: query.status as never } : {}),
...(query.projectPublicId
? {
project: {
publicId: query.projectPublicId,
...(!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
? { managerUserId: actor.userId }
: {}),
},
}
: {}),
},
query.page,
query.pageSize,
);
}
listPublic(query: PublicActivityQueryDto): Promise<PaginatedActivitiesResponse> {
return this.list(
{
status: { in: ['PUBLISHED', 'REGISTRATION_CLOSED', 'IN_PROGRESS', 'COMPLETED'] },
...(query.projectPublicId
? { project: { publicId: query.projectPublicId, isPublic: true } }
: {}),
OR: [
{ projectId: null },
{ project: { isPublic: true, status: { in: ['PUBLISHED', 'COMPLETED'] } } },
],
},
query.page,
query.pageSize,
);
}
async getAdmin(publicId: string, actor: AuthContext): Promise<ActivityDetailResponse> {
const activity = await this.find(publicId);
this.assertAccess(activity, actor);
return { activity: await this.detail(activity), currentUserRegistration: null };
}
async getPublic(publicId: string): Promise<ActivityDetailResponse> {
const activity = await this.find(publicId);
if (activity.status === 'DRAFT' || (activity.project && !activity.project.isPublic))
throw this.notFound();
return { activity: await this.detail(activity), currentUserRegistration: null };
}
async create(
input: CreateActivityDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityDetailResponse> {
const values = await this.prepare(input, actor);
const fingerprint = this.fingerprint('activity-create', {
...input,
title: input.title.trim(),
summary: input.summary.trim(),
content: input.content.trim(),
locationText: input.locationText.trim(),
});
const replay = await this.prisma.activity.findUnique({
where: {
createdById_clientRequestId: {
createdById: actor.userId,
clientRequestId: input.clientRequestId,
},
},
include: activityInclude,
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return {
activity: await this.detail(replay),
currentUserRegistration: null,
idempotentReplay: true,
};
}
const activity = await this.prisma.$transaction(async (transaction) => {
const created = await transaction.activity.create({
data: {
publicId: randomUUID(),
activityNo: `HD${dateNoPrefix(new Date())}${randomBytes(4).toString('hex').toUpperCase()}`,
clientRequestId: input.clientRequestId,
requestFingerprint: fingerprint,
...values,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await transaction.activityStatusHistory.create({
data: {
activityId: created.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
toStatus: 'DRAFT',
operatorUserId: actor.userId,
reasonCode: 'CREATED',
},
});
await this.audit.record(
request,
{
action: 'ACTIVITY_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY',
resourcePublicId: created.publicId,
metadata: { status: 'DRAFT' },
},
transaction,
);
return transaction.activity.findUniqueOrThrow({
where: { id: created.id },
include: activityInclude,
});
});
return {
activity: await this.detail(activity),
currentUserRegistration: null,
idempotentReplay: false,
};
}
async update(
publicId: string,
input: UpdateActivityDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityDetailResponse> {
const activity = await this.find(publicId);
this.assertAccess(activity, actor);
if (activity.status !== 'DRAFT') throw this.invalidTransition('只有草稿活动可以编辑');
if (activity.revision !== input.expectedRevision) throw this.conflict();
const values = await this.prepare(input, actor);
const updated = await this.prisma
.$transaction(async (transaction) => {
const changed = await transaction.activity.updateMany({
where: { id: activity.id, status: 'DRAFT', revision: input.expectedRevision },
data: { ...values, updatedById: actor.userId, revision: { increment: 1 } },
});
if (changed.count !== 1) throw new Error('ACTIVITY_CONFLICT');
await this.audit.record(
request,
{
action: 'ACTIVITY_UPDATED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY',
resourcePublicId: publicId,
},
transaction,
);
return transaction.activity.findUniqueOrThrow({
where: { id: activity.id },
include: activityInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'ACTIVITY_CONFLICT') throw this.conflict();
throw error;
});
return { activity: await this.detail(updated), currentUserRegistration: null };
}
async transition(
publicId: string,
input: TransitionActivityDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityDetailResponse> {
const activity = await this.find(publicId);
this.assertAccess(activity, actor);
const fingerprint = this.fingerprint('activity-transition', {
publicId,
targetStatus: input.targetStatus,
});
const replay = await this.prisma.activityStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { activity: { include: activityInclude } },
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return {
activity: await this.detail(replay.activity),
currentUserRegistration: null,
idempotentReplay: true,
};
}
const allowed: Record<string, string[]> = {
DRAFT: ['PUBLISHED', 'CANCELLED'],
PUBLISHED: ['REGISTRATION_CLOSED', 'CANCELLED'],
REGISTRATION_CLOSED: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['COMPLETED'],
};
if (!allowed[activity.status]?.includes(input.targetStatus)) throw this.invalidTransition();
if (input.targetStatus === 'PUBLISHED' && activity.registrationDeadline <= new Date()) {
throw this.validation('报名截止时间必须晚于当前时间');
}
const updated = await this.prisma
.$transaction(async (transaction) => {
const changed = await transaction.activity.updateMany({
where: { id: activity.id, status: activity.status },
data: {
status: input.targetStatus as never,
publishedAt: input.targetStatus === 'PUBLISHED' ? new Date() : activity.publishedAt,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (changed.count !== 1) throw new Error('ACTIVITY_STATE_CONFLICT');
await transaction.activityStatusHistory.create({
data: {
activityId: activity.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
fromStatus: activity.status,
toStatus: input.targetStatus as never,
operatorUserId: actor.userId,
reasonCode: input.targetStatus,
},
});
await this.audit.record(
request,
{
action: 'ACTIVITY_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY',
resourcePublicId: publicId,
metadata: { fromStatus: activity.status, toStatus: input.targetStatus },
},
transaction,
);
return transaction.activity.findUniqueOrThrow({
where: { id: activity.id },
include: activityInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'ACTIVITY_STATE_CONFLICT')
throw this.invalidTransition();
throw error;
});
return {
activity: await this.detail(updated),
currentUserRegistration: null,
idempotentReplay: false,
};
}
async register(
activityPublicId: string,
input: RegisterActivityDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityRegistrationResponse> {
const fingerprint = this.fingerprint('activity-registration', { activityPublicId });
const replay = await this.prisma.activityRegistration.findUnique({
where: {
userId_clientRequestId: { userId: actor.userId, clientRequestId: input.clientRequestId },
},
include: registrationInclude,
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return { registration: this.registration(replay), idempotentReplay: true };
}
const volunteer = await this.prisma.volunteer.findUnique({ where: { userId: actor.userId } });
if (!volunteer || volunteer.status !== 'APPROVED') {
throw new ApplicationError(
API_ERROR_CODES.VOLUNTEER_NOT_APPROVED,
'仅已认证志愿者可以报名',
HttpStatus.FORBIDDEN,
);
}
try {
const created = await this.prisma.$transaction(async (transaction) => {
const rows = await transaction.$queryRaw<
Array<{ id: bigint }>
>`SELECT id FROM activities WHERE public_id = ${activityPublicId} FOR UPDATE`;
if (!rows.length) throw new Error('ACTIVITY_NOT_FOUND');
const activity = await transaction.activity.findUniqueOrThrow({
where: { id: rows[0]!.id },
});
if (activity.status !== 'PUBLISHED' || activity.registrationDeadline <= new Date())
throw new Error('REGISTRATION_CLOSED');
const active = await transaction.activityRegistration.count({
where: { activityId: activity.id, status: { in: ['PENDING', 'APPROVED'] } },
});
if (activity.capacity !== null && active >= activity.capacity)
throw new Error('ACTIVITY_FULL');
const registration = await transaction.activityRegistration.create({
data: {
publicId: randomUUID(),
activityId: activity.id,
userId: actor.userId,
volunteerId: volunteer.id,
clientRequestId: input.clientRequestId,
requestFingerprint: fingerprint,
},
});
await transaction.activityRegistrationHistory.create({
data: {
registrationId: registration.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
toStatus: 'PENDING',
operatorUserId: actor.userId,
actionCode: 'REGISTERED',
},
});
await this.audit.record(
request,
{
action: 'ACTIVITY_REGISTRATION_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY_REGISTRATION',
resourcePublicId: registration.publicId,
metadata: { activityPublicId },
},
transaction,
);
return transaction.activityRegistration.findUniqueOrThrow({
where: { id: registration.id },
include: registrationInclude,
});
});
return { registration: this.registration(created), idempotentReplay: false };
} catch (error) {
if (error instanceof Error && error.message === 'ACTIVITY_NOT_FOUND') throw this.notFound();
if (error instanceof Error && error.message === 'REGISTRATION_CLOSED')
throw this.registrationClosed();
if (error instanceof Error && error.message === 'ACTIVITY_FULL') throw this.full();
if (this.uniqueError(error)) {
const existing = await this.prisma.activityRegistration.findFirst({
where: { activity: { publicId: activityPublicId }, userId: actor.userId },
include: registrationInclude,
});
if (
existing?.clientRequestId === input.clientRequestId &&
existing.requestFingerprint === fingerprint
) {
return { registration: this.registration(existing), idempotentReplay: true };
}
throw this.duplicate('您已报名该活动');
}
throw error;
}
}
async myRegistrations(
query: RegistrationQueryDto,
actor: AuthContext,
): Promise<PaginatedActivityRegistrationsResponse> {
return this.listRegistrations(
{
userId: actor.userId,
...(query.status ? { status: query.status as never } : {}),
...(query.activityPublicId ? { activity: { publicId: query.activityPublicId } } : {}),
},
query.page,
query.pageSize,
);
}
async adminRegistrations(
query: RegistrationQueryDto,
): Promise<PaginatedActivityRegistrationsResponse> {
return this.listRegistrations(
{
...(query.status ? { status: query.status as never } : {}),
...(query.activityPublicId ? { activity: { publicId: query.activityPublicId } } : {}),
},
query.page,
query.pageSize,
);
}
async cancel(
publicId: string,
input: RegisterActivityDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityRegistrationResponse> {
const registration = await this.findRegistration(publicId);
if (registration.userId !== actor.userId) throw new ForbiddenException();
const replay = await this.replayRegistration(
input.clientRequestId,
this.fingerprint('registration-status', {
publicId,
targetStatus: 'CANCELLED',
note: '',
}),
);
if (replay) return { registration: this.registration(replay), idempotentReplay: true };
if (
!['PENDING', 'APPROVED'].includes(registration.status) ||
registration.activity.startAt <= new Date()
)
throw this.invalidTransition();
return this.changeRegistrationStatus(
registration,
input.clientRequestId,
'CANCELLED',
'',
actor,
request,
'REGISTRATION_CANCELLED',
);
}
async reviewRegistration(
publicId: string,
input: ReviewRegistrationDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityRegistrationResponse> {
const registration = await this.findRegistration(publicId);
const replay = await this.replayRegistration(
input.clientRequestId,
this.fingerprint('registration-status', {
publicId,
targetStatus: input.targetStatus,
note: input.note.trim(),
}),
);
if (replay) return { registration: this.registration(replay), idempotentReplay: true };
if (registration.status !== 'PENDING') throw this.invalidTransition();
return this.changeRegistrationStatus(
registration,
input.clientRequestId,
input.targetStatus,
input.note,
actor,
request,
'REGISTRATION_REVIEWED',
);
}
async checkIn(
publicId: string,
input: CheckInRegistrationDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityRegistrationResponse> {
const registration = await this.findRegistration(publicId);
const fingerprint = this.fingerprint('registration-check-in', {
publicId,
targetStatus: input.targetStatus,
});
const replay = await this.replayRegistration(input.clientRequestId, fingerprint);
if (replay) return { registration: this.registration(replay), idempotentReplay: true };
if (
registration.status !== 'APPROVED' ||
registration.checkInStatus !== 'NOT_CHECKED_IN' ||
registration.activity.status !== 'IN_PROGRESS'
) {
throw this.invalidTransition();
}
const updated = await this.prisma
.$transaction(async (transaction) => {
const changed = await transaction.activityRegistration.updateMany({
where: { id: registration.id, status: 'APPROVED', checkInStatus: 'NOT_CHECKED_IN' },
data: {
checkInStatus: input.targetStatus,
checkedInAt: input.targetStatus === 'CHECKED_IN' ? new Date() : null,
checkedInById: actor.userId,
},
});
if (changed.count !== 1) throw new Error('REGISTRATION_STATE_CONFLICT');
await transaction.activityRegistrationHistory.create({
data: {
registrationId: registration.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
fromCheckInStatus: 'NOT_CHECKED_IN',
toCheckInStatus: input.targetStatus,
operatorUserId: actor.userId,
actionCode: 'CHECK_IN_RECORDED',
},
});
await this.audit.record(
request,
{
action: 'ACTIVITY_CHECK_IN_RECORDED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY_REGISTRATION',
resourcePublicId: publicId,
metadata: { checkInStatus: input.targetStatus },
},
transaction,
);
return transaction.activityRegistration.findUniqueOrThrow({
where: { id: registration.id },
include: registrationInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'REGISTRATION_STATE_CONFLICT')
throw this.invalidTransition();
throw error;
});
return { registration: this.registration(updated), idempotentReplay: false };
}
async recordMinutes(
publicId: string,
input: RecordServiceMinutesDto,
actor: AuthContext,
request: RequestContext,
): Promise<ActivityRegistrationResponse> {
const registration = await this.findRegistration(publicId);
if (
registration.status !== 'APPROVED' ||
registration.checkInStatus !== 'CHECKED_IN' ||
registration.activity.status !== 'COMPLETED'
) {
throw this.invalidTransition();
}
const durationMinutes = Math.ceil(
(registration.activity.endAt.getTime() - registration.activity.startAt.getTime()) / 60_000,
);
if (input.serviceMinutes > durationMinutes)
throw this.validation('服务时长不能超过活动持续时间');
const fingerprint = this.fingerprint('registration-service-minutes', {
publicId,
serviceMinutes: input.serviceMinutes,
});
const replay = await this.replayRegistration(input.clientRequestId, fingerprint);
if (replay) return { registration: this.registration(replay), idempotentReplay: true };
const updated = await this.prisma.$transaction(async (transaction) => {
const current = await transaction.activityRegistration.findUniqueOrThrow({
where: { id: registration.id },
});
await transaction.activityRegistration.update({
where: { id: registration.id },
data: {
serviceMinutes: input.serviceMinutes,
serviceMinutesRecordedAt: new Date(),
serviceMinutesRecordedById: actor.userId,
},
});
await transaction.activityRegistrationHistory.create({
data: {
registrationId: registration.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
serviceMinutesBefore: current.serviceMinutes,
serviceMinutesAfter: input.serviceMinutes,
operatorUserId: actor.userId,
actionCode: 'SERVICE_MINUTES_RECORDED',
},
});
await this.audit.record(
request,
{
action: 'VOLUNTEER_SERVICE_MINUTES_RECORDED',
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY_REGISTRATION',
resourcePublicId: publicId,
metadata: {
serviceMinutesBefore: current.serviceMinutes,
serviceMinutesAfter: input.serviceMinutes,
},
},
transaction,
);
return transaction.activityRegistration.findUniqueOrThrow({
where: { id: registration.id },
include: registrationInclude,
});
});
return { registration: this.registration(updated), idempotentReplay: false };
}
private async changeRegistrationStatus(
registration: StoredRegistration,
requestId: string,
targetStatus: 'APPROVED' | 'REJECTED' | 'CANCELLED',
note: string,
actor: AuthContext,
request: RequestContext,
action: string,
): Promise<ActivityRegistrationResponse> {
const fingerprint = this.fingerprint('registration-status', {
publicId: registration.publicId,
targetStatus,
note: note.trim(),
});
const replay = await this.replayRegistration(requestId, fingerprint);
if (replay) return { registration: this.registration(replay), idempotentReplay: true };
const updated = await this.prisma
.$transaction(async (transaction) => {
const changed = await transaction.activityRegistration.updateMany({
where: { id: registration.id, status: registration.status },
data: {
status: targetStatus,
reviewedById: targetStatus === 'CANCELLED' ? null : actor.userId,
reviewedAt: targetStatus === 'CANCELLED' ? null : new Date(),
reviewNoteEncrypted: note ? this.sensitive.encrypt(note.trim()) : null,
},
});
if (changed.count !== 1) throw new Error('REGISTRATION_STATE_CONFLICT');
await transaction.activityRegistrationHistory.create({
data: {
registrationId: registration.id,
requestId,
requestFingerprint: fingerprint,
fromStatus: registration.status,
toStatus: targetStatus,
operatorUserId: actor.userId,
actionCode: action,
},
});
await this.audit.record(
request,
{
action,
result: 'SUCCESS',
actor,
resourceType: 'ACTIVITY_REGISTRATION',
resourcePublicId: registration.publicId,
metadata: { fromStatus: registration.status, toStatus: targetStatus },
},
transaction,
);
if (targetStatus !== 'CANCELLED')
await this.notifications.create(transaction, {
userId: registration.userId,
eventKey: `registration:${registration.publicId}:${targetStatus}`,
type: 'ACTIVITY_REGISTRATION_RESULT',
title: targetStatus === 'APPROVED' ? '活动报名已通过' : '活动报名未通过',
body:
targetStatus === 'APPROVED'
? '您的活动报名已通过,请留意活动时间和地点。'
: '很抱歉,本次活动报名未通过,您可以继续关注其他活动。',
resourceType: 'ACTIVITY_REGISTRATION',
resourcePublicId: registration.publicId,
});
return transaction.activityRegistration.findUniqueOrThrow({
where: { id: registration.id },
include: registrationInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'REGISTRATION_STATE_CONFLICT')
throw this.invalidTransition();
throw error;
});
return { registration: this.registration(updated), idempotentReplay: false };
}
private async prepare(input: CreateActivityDto | UpdateActivityDto, actor: AuthContext) {
const startAt = new Date(input.startAt),
endAt = new Date(input.endAt),
registrationDeadline = new Date(input.registrationDeadline);
if (endAt <= startAt) throw this.validation('活动结束时间必须晚于开始时间');
if (registrationDeadline >= startAt) throw this.validation('报名截止时间必须早于活动开始时间');
let projectId: bigint | undefined;
if (input.projectPublicId) {
const project = await this.prisma.project.findUnique({
where: { publicId: input.projectPublicId },
});
if (!project || project.status === 'COMPLETED') throw this.validation('关联项目无效或已完成');
if (
project.managerUserId !== actor.userId &&
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
)
throw new ForbiddenException();
projectId = project.id;
}
const cover = await this.media.requireCover(input.coverFilePublicId, 'ACTIVITY_COVER', actor);
return {
projectId,
title: input.title.trim(),
summary: input.summary.trim(),
content: input.content.trim(),
coverFileId: cover?.id,
startAt,
endAt,
registrationDeadline,
locationText: input.locationText.trim(),
capacity: input.capacity,
};
}
private async list(
where: Prisma.ActivityWhereInput,
page: number,
pageSize: number,
): Promise<PaginatedActivitiesResponse> {
const [total, activities] = await this.prisma.$transaction([
this.prisma.activity.count({ where }),
this.prisma.activity.findMany({
where,
include: activityInclude,
orderBy: { startAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
items: await Promise.all(activities.map((activity) => this.item(activity))),
page,
pageSize,
total,
};
}
private async listRegistrations(
where: Prisma.ActivityRegistrationWhereInput,
page: number,
pageSize: number,
): Promise<PaginatedActivityRegistrationsResponse> {
const [total, registrations] = await this.prisma.$transaction([
this.prisma.activityRegistration.count({ where }),
this.prisma.activityRegistration.findMany({
where,
include: registrationInclude,
orderBy: { registeredAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return { items: registrations.map((item) => this.registration(item)), page, pageSize, total };
}
private async find(publicId: string): Promise<StoredActivity> {
const activity = await this.prisma.activity.findUnique({
where: { publicId },
include: activityInclude,
});
if (!activity) throw this.notFound();
return activity;
}
private async findRegistration(publicId: string): Promise<StoredRegistration> {
const registration = await this.prisma.activityRegistration.findUnique({
where: { publicId },
include: registrationInclude,
});
if (!registration)
throw new ApplicationError(
API_ERROR_CODES.REGISTRATION_NOT_FOUND,
'未找到该报名记录',
HttpStatus.NOT_FOUND,
);
return registration;
}
private replayRegistration(
requestId: string,
fingerprint: string,
): Promise<StoredRegistration | null> {
return this.prisma.activityRegistrationHistory
.findUnique({
where: { requestId },
include: { registration: { include: registrationInclude } },
})
.then((history) => {
if (!history) return null;
if (history.requestFingerprint !== fingerprint) throw this.duplicate();
return history.registration;
});
}
private assertAccess(activity: StoredActivity, actor: AuthContext): void {
if (
activity.createdById !== actor.userId &&
activity.project?.managerUserId !== actor.userId &&
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
) {
throw new ForbiddenException();
}
}
private async item(activity: StoredActivity): Promise<ActivityListItem> {
const active = activity.registrations.length;
return {
publicId: activity.publicId,
activityNo: activity.activityNo,
projectPublicId: activity.project?.publicId ?? null,
projectName: activity.project?.name ?? null,
title: activity.title,
summary: activity.summary,
coverImageUrl: await this.media.publicUrl(activity.coverFile),
startAt: activity.startAt.toISOString(),
endAt: activity.endAt.toISOString(),
registrationDeadline: activity.registrationDeadline.toISOString(),
locationText: activity.locationText,
capacity: activity.capacity,
activeRegistrationCount: active,
remainingCapacity:
activity.capacity === null ? null : Math.max(0, activity.capacity - active),
status: activity.status,
statusLabel: ACTIVITY_STATUS_LABELS[activity.status],
canRegister:
activity.status === 'PUBLISHED' &&
activity.registrationDeadline > new Date() &&
(activity.capacity === null || active < activity.capacity),
revision: activity.revision,
createdAt: activity.createdAt.toISOString(),
};
}
private async detail(activity: StoredActivity): Promise<ActivityDetail> {
return {
...(await this.item(activity)),
content: activity.content,
coverFilePublicId: activity.coverFile?.publicId ?? null,
history: activity.statusHistory.map((history) => ({
status: history.toStatus,
statusLabel: ACTIVITY_STATUS_LABELS[history.toStatus],
reasonLabel:
(
{
CREATED: '创建活动',
PUBLISHED: '发布活动',
REGISTRATION_CLOSED: '关闭报名',
IN_PROGRESS: '开始活动',
COMPLETED: '完成活动',
CANCELLED: '取消活动',
} as Record<string, string>
)[history.reasonCode] ?? '状态更新',
createdAt: history.createdAt.toISOString(),
})),
};
}
private registration(item: StoredRegistration): ActivityRegistrationItem {
const mobile = this.sensitive.decrypt(item.volunteer.mobileEncrypted);
return {
publicId: item.publicId,
activityPublicId: item.activity.publicId,
activityTitle: item.activity.title,
volunteerPublicId: item.volunteer.publicId,
volunteerName: this.sensitive.decrypt(item.volunteer.realNameEncrypted),
mobileMasked: maskMobile(mobile),
status: item.status,
statusLabel: REGISTRATION_STATUS_LABELS[item.status],
checkInStatus: item.checkInStatus,
checkInStatusLabel: CHECK_IN_STATUS_LABELS[item.checkInStatus],
serviceMinutes: item.serviceMinutes,
registeredAt: item.registeredAt.toISOString(),
};
}
private fingerprint(domain: string, value: object): string {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private uniqueError(error: unknown): error is { code: 'P2002' } {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private notFound(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.ACTIVITY_NOT_FOUND,
'未找到该公益活动',
HttpStatus.NOT_FOUND,
);
}
private conflict(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'活动已更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private invalidTransition(message = '当前状态不能执行该操作'): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
message,
HttpStatus.CONFLICT,
);
}
private duplicate(message = '请求标识已用于其他操作'): ApplicationError {
return new ApplicationError(API_ERROR_CODES.DUPLICATE_REQUEST, message, HttpStatus.CONFLICT);
}
private registrationClosed(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.REGISTRATION_CLOSED,
'活动报名已截止',
HttpStatus.CONFLICT,
);
}
private full(): ApplicationError {
return new ApplicationError(API_ERROR_CODES.ACTIVITY_FULL, '活动名额已满', HttpStatus.CONFLICT);
}
}
@@ -0,0 +1,294 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Patch,
Post,
Put,
Query,
Req,
ValidationPipe,
} from '@nestjs/common';
import {
PERMISSION_CODES,
type ActivityDetailResponse,
type ActivityRegistrationResponse,
type AssigneeOption,
type AdminVolunteerDetailResponse,
type ConfirmUploadResponse,
type CreateUploadIntentResponse,
type PaginatedActivitiesResponse,
type PaginatedActivityRegistrationsResponse,
type PaginatedProjectsResponse,
type PaginatedVolunteersResponse,
type ProjectDetailResponse,
} from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import { ActivitiesService } from './activities.service';
import {
ActivityQueryDto,
CheckInRegistrationDto,
CreateActivityDto,
CreateMediaUploadIntentDto,
CreateProjectDto,
ProjectQueryDto,
RecordServiceMinutesDto,
RegistrationQueryDto,
ReplaceProjectCasesDto,
RestoreVolunteerDto,
ReviewRegistrationDto,
ReviewVolunteerDto,
TransitionActivityDto,
TransitionProjectDto,
UpdateActivityDto,
UpdateProjectDto,
VolunteerQueryDto,
} from './dto/participation.dto';
import { MediaService } from './media.service';
import { ProjectsService } from './projects.service';
import { VolunteersService } from './volunteers.service';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Controller('api/v1/admin')
@RequireSessionChannel(SessionChannel.ADMIN)
export class AdminParticipationController {
constructor(
@Inject(VolunteersService) private readonly volunteers: VolunteersService,
@Inject(ProjectsService) private readonly projects: ProjectsService,
@Inject(ActivitiesService) private readonly activities: ActivitiesService,
@Inject(MediaService) private readonly media: MediaService,
) {}
@Get('volunteers')
@RequirePermissions(PERMISSION_CODES.VOLUNTEERS_READ)
volunteersList(
@Query(strict(VolunteerQueryDto)) query: VolunteerQueryDto,
): Promise<PaginatedVolunteersResponse> {
return this.volunteers.list(query);
}
@Get('volunteers/:publicId')
@RequirePermissions(PERMISSION_CODES.VOLUNTEERS_READ)
volunteer(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
return this.volunteers.get(publicId, actor, request);
}
@Post('volunteers/:publicId/review')
@RequirePermissions(PERMISSION_CODES.VOLUNTEERS_REVIEW)
reviewVolunteer(
@Param('publicId') publicId: string,
@Body(strict(ReviewVolunteerDto)) input: ReviewVolunteerDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
return this.volunteers.review(publicId, input, actor, request);
}
@Post('volunteers/:publicId/restore')
@RequirePermissions(PERMISSION_CODES.VOLUNTEERS_REVIEW)
restoreVolunteer(
@Param('publicId') publicId: string,
@Body(strict(RestoreVolunteerDto)) input: RestoreVolunteerDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
return this.volunteers.review(publicId, input, actor, request);
}
@Get('projects')
@RequirePermissions(PERMISSION_CODES.PROJECTS_READ)
projectsList(
@Query(strict(ProjectQueryDto)) query: ProjectQueryDto,
@CurrentAuth() actor: AuthContext,
): Promise<PaginatedProjectsResponse> {
return this.projects.listAdmin(query, actor);
}
@Get('project-manager-options')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
projectManagerOptions(): Promise<AssigneeOption[]> {
return this.projects.managerOptions();
}
@Post('projects')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
createProject(
@Body(strict(CreateProjectDto)) input: CreateProjectDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ProjectDetailResponse> {
return this.projects.create(input, actor, request);
}
@Get('projects/:publicId')
@RequirePermissions(PERMISSION_CODES.PROJECTS_READ)
project(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
): Promise<ProjectDetailResponse> {
return this.projects.getAdmin(publicId, actor);
}
@Patch('projects/:publicId')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
updateProject(
@Param('publicId') publicId: string,
@Body(strict(UpdateProjectDto)) input: UpdateProjectDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ProjectDetailResponse> {
return this.projects.update(publicId, input, actor, request);
}
@Post('projects/:publicId/transition')
@RequirePermissions(PERMISSION_CODES.PROJECTS_PUBLISH)
transitionProject(
@Param('publicId') publicId: string,
@Body(strict(TransitionProjectDto)) input: TransitionProjectDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ProjectDetailResponse> {
return this.projects.transition(publicId, input, actor, request);
}
@Put('projects/:publicId/service-cases')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
replaceProjectCases(
@Param('publicId') publicId: string,
@Body(strict(ReplaceProjectCasesDto)) input: ReplaceProjectCasesDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ProjectDetailResponse> {
return this.projects.replaceCases(publicId, input, actor, request);
}
@Get('activities')
@RequirePermissions(PERMISSION_CODES.ACTIVITIES_READ)
activitiesList(
@Query(strict(ActivityQueryDto)) query: ActivityQueryDto,
@CurrentAuth() actor: AuthContext,
): Promise<PaginatedActivitiesResponse> {
return this.activities.listAdmin(query, actor);
}
@Post('activities')
@RequirePermissions(PERMISSION_CODES.ACTIVITIES_MANAGE)
createActivity(
@Body(strict(CreateActivityDto)) input: CreateActivityDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityDetailResponse> {
return this.activities.create(input, actor, request);
}
@Get('activities/:publicId')
@RequirePermissions(PERMISSION_CODES.ACTIVITIES_READ)
activity(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
): Promise<ActivityDetailResponse> {
return this.activities.getAdmin(publicId, actor);
}
@Patch('activities/:publicId')
@RequirePermissions(PERMISSION_CODES.ACTIVITIES_MANAGE)
updateActivity(
@Param('publicId') publicId: string,
@Body(strict(UpdateActivityDto)) input: UpdateActivityDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityDetailResponse> {
return this.activities.update(publicId, input, actor, request);
}
@Post('activities/:publicId/transition')
@RequirePermissions(PERMISSION_CODES.ACTIVITIES_PUBLISH)
transitionActivity(
@Param('publicId') publicId: string,
@Body(strict(TransitionActivityDto)) input: TransitionActivityDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityDetailResponse> {
return this.activities.transition(publicId, input, actor, request);
}
@Get('activity-registrations')
@RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
registrationList(
@Query(strict(RegistrationQueryDto)) query: RegistrationQueryDto,
): Promise<PaginatedActivityRegistrationsResponse> {
return this.activities.adminRegistrations(query);
}
@Post('activity-registrations/:publicId/review')
@RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
reviewRegistration(
@Param('publicId') publicId: string,
@Body(strict(ReviewRegistrationDto)) input: ReviewRegistrationDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityRegistrationResponse> {
return this.activities.reviewRegistration(publicId, input, actor, request);
}
@Post('activity-registrations/:publicId/check-in')
@RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
checkIn(
@Param('publicId') publicId: string,
@Body(strict(CheckInRegistrationDto)) input: CheckInRegistrationDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityRegistrationResponse> {
return this.activities.checkIn(publicId, input, actor, request);
}
@Post('activity-registrations/:publicId/service-minutes')
@RequirePermissions(PERMISSION_CODES.REGISTRATIONS_MANAGE)
serviceMinutes(
@Param('publicId') publicId: string,
@Body(strict(RecordServiceMinutesDto)) input: RecordServiceMinutesDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityRegistrationResponse> {
return this.activities.recordMinutes(publicId, input, actor, request);
}
@Post('media/upload-intents')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
mediaIntent(
@Body(strict(CreateMediaUploadIntentDto)) input: CreateMediaUploadIntentDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<CreateUploadIntentResponse> {
return this.media.createIntent(input, actor, request);
}
@Post('media/:publicId/confirm')
@RequirePermissions(PERMISSION_CODES.PROJECTS_MANAGE)
confirmMedia(
@Param('publicId') publicId: string,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ConfirmUploadResponse> {
return this.media.confirm(publicId, actor, request);
}
}
@@ -0,0 +1,169 @@
import { Type } from 'class-transformer';
import { OmitType } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
IsUUID,
Length,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
import {
ACTIVITY_STATUSES,
CHECK_IN_STATUSES,
PROJECT_STATUSES,
REGISTRATION_STATUSES,
SERVICE_CATEGORIES,
VOLUNTEER_SKILL_CODES,
VOLUNTEER_STATUSES,
} from '@lehuo/contracts';
import { PaginationDto } from '../../case-management/dto/case-management.dto';
const MOBILE_PATTERN = /^1[3-9]\d{9}$/;
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export class ApplyVolunteerDto {
@IsUUID('4') clientRequestId!: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) expectedRevision?: number;
@IsString() @Length(2, 50) realName!: string;
@Matches(MOBILE_PATTERN) mobile!: string;
@IsString() @Length(2, 100) regionCode!: string;
@IsString() @Length(2, 500) availabilityNote!: string;
@IsArray() @ArrayMaxSize(12) @IsIn(VOLUNTEER_SKILL_CODES, { each: true }) skillCodes!: string[];
@IsArray()
@ArrayMaxSize(12)
@IsIn(SERVICE_CATEGORIES, { each: true })
interestCategories!: string[];
@IsIn([true]) privacyConsent!: true;
}
export class VolunteerQueryDto extends PaginationDto {
@IsOptional() @IsIn(VOLUNTEER_STATUSES) status?: string;
@IsOptional() @IsString() @MaxLength(100) regionCode?: string;
}
export class ReviewVolunteerDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['APPROVED', 'REJECTED', 'SUSPENDED']) targetStatus!: 'APPROVED' | 'REJECTED' | 'SUSPENDED';
@IsString() @Length(2, 500) note!: string;
}
export class RestoreVolunteerDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['APPROVED']) targetStatus!: 'APPROVED';
@IsString() @Length(2, 500) note!: string;
}
export class ProjectQueryDto extends PaginationDto {
@IsOptional() @IsIn(PROJECT_STATUSES) status?: string;
@IsOptional() @IsIn(SERVICE_CATEGORIES) category?: string;
}
export class PublicProjectQueryDto extends PaginationDto {
@IsOptional() @IsIn(SERVICE_CATEGORIES) category?: string;
}
export class CreateProjectDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(2, 200) name!: string;
@IsIn(SERVICE_CATEGORIES) category!: string;
@IsString() @Length(10, 500) summary!: string;
@IsString() @Length(20, 20000) content!: string;
@IsOptional() @IsUUID('4') coverFilePublicId?: string;
@IsUUID('4') managerUserPublicId!: string;
@Matches(DATE_PATTERN) startDate!: string;
@Matches(DATE_PATTERN) endDate!: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1_000_000) targetPeople?: number;
@IsBoolean() isPublic!: boolean;
@Type(() => Number) @IsInt() @Min(-10_000) @Max(10_000) sortOrder!: number;
}
export class UpdateProjectDto extends OmitType(CreateProjectDto, ['clientRequestId'] as const) {
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
}
export class TransitionProjectDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['PUBLISHED', 'COMPLETED']) targetStatus!: 'PUBLISHED' | 'COMPLETED';
}
export class ReplaceProjectCasesDto {
@IsArray() @ArrayMaxSize(500) @IsUUID('4', { each: true }) serviceCasePublicIds!: string[];
}
export class ActivityQueryDto extends PaginationDto {
@IsOptional() @IsIn(ACTIVITY_STATUSES) status?: string;
@IsOptional() @IsUUID('4') projectPublicId?: string;
}
export class PublicActivityQueryDto extends PaginationDto {
@IsOptional() @IsUUID('4') projectPublicId?: string;
}
export class CreateActivityDto {
@IsUUID('4') clientRequestId!: string;
@IsOptional() @IsUUID('4') projectPublicId?: string;
@IsString() @Length(2, 200) title!: string;
@IsString() @Length(10, 500) summary!: string;
@IsString() @Length(20, 20000) content!: string;
@IsOptional() @IsUUID('4') coverFilePublicId?: string;
@IsISO8601({ strict: true }) startAt!: string;
@IsISO8601({ strict: true }) endAt!: string;
@IsISO8601({ strict: true }) registrationDeadline!: string;
@IsString() @Length(2, 300) locationText!: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100_000) capacity?: number;
}
export class UpdateActivityDto extends OmitType(CreateActivityDto, ['clientRequestId'] as const) {
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
}
export class TransitionActivityDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(ACTIVITY_STATUSES.filter((status) => status !== 'DRAFT')) targetStatus!: string;
}
export class RegisterActivityDto {
@IsUUID('4') clientRequestId!: string;
}
export class RegistrationQueryDto extends PaginationDto {
@IsOptional() @IsIn(REGISTRATION_STATUSES) status?: string;
@IsOptional() @IsUUID('4') activityPublicId?: string;
}
export class ReviewRegistrationDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['APPROVED', 'REJECTED']) targetStatus!: 'APPROVED' | 'REJECTED';
@IsString() @Length(2, 500) note!: string;
}
export class CheckInRegistrationDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(CHECK_IN_STATUSES.filter((status) => status !== 'NOT_CHECKED_IN')) targetStatus!:
'CHECKED_IN' | 'ABSENT';
}
export class RecordServiceMinutesDto {
@IsUUID('4') clientRequestId!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(100_800) serviceMinutes!: number;
}
export class CreateMediaUploadIntentDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['PROJECT_COVER', 'ACTIVITY_COVER']) businessType!: 'PROJECT_COVER' | 'ACTIVITY_COVER';
@IsString() @Length(1, 255) filename!: string;
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
}
@@ -0,0 +1,89 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Post,
Put,
Query,
Req,
ValidationPipe,
} from '@nestjs/common';
import type {
ActivityRegistrationResponse,
PaginatedActivityRegistrationsResponse,
VolunteerProfileResponse,
} from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import { ActivitiesService } from './activities.service';
import {
ApplyVolunteerDto,
RegisterActivityDto,
RegistrationQueryDto,
} from './dto/participation.dto';
import { VolunteersService } from './volunteers.service';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Controller('api/v1')
@RequireSessionChannel(SessionChannel.WECHAT)
export class MeParticipationController {
constructor(
@Inject(VolunteersService) private readonly volunteers: VolunteersService,
@Inject(ActivitiesService) private readonly activities: ActivitiesService,
) {}
@Get('me/volunteer')
mine(@CurrentAuth() actor: AuthContext): Promise<VolunteerProfileResponse> {
return this.volunteers.mine(actor);
}
@Put('me/volunteer')
apply(
@Body(strict(ApplyVolunteerDto)) input: ApplyVolunteerDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<VolunteerProfileResponse> {
return this.volunteers.apply(input, actor, request);
}
@Get('me/activity-registrations')
registrations(
@Query(strict(RegistrationQueryDto)) query: RegistrationQueryDto,
@CurrentAuth() actor: AuthContext,
): Promise<PaginatedActivityRegistrationsResponse> {
return this.activities.myRegistrations(query, actor);
}
@Post('activities/:publicId/registrations')
register(
@Param('publicId') publicId: string,
@Body(strict(RegisterActivityDto)) input: RegisterActivityDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityRegistrationResponse> {
return this.activities.register(publicId, input, actor, request);
}
@Post('me/activity-registrations/:publicId/cancel')
cancel(
@Param('publicId') publicId: string,
@Body(strict(RegisterActivityDto)) input: RegisterActivityDto,
@CurrentAuth() actor: AuthContext,
@Req() request: RequestContext,
): Promise<ActivityRegistrationResponse> {
return this.activities.cancel(publicId, input, actor, request);
}
}
+234
View File
@@ -0,0 +1,234 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
type ConfirmUploadResponse,
type CreateUploadIntentResponse,
type ServiceRecordAttachment,
} from '@lehuo/contracts';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { FilesRepository } from '../files/files.repository';
import { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
import type { File, FileBusinessType } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type { CreateMediaUploadIntentDto } from './dto/participation.dto';
const EXTENSIONS: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};
@Injectable()
export class MediaService {
constructor(
@Inject(FilesRepository) private readonly files: FilesRepository,
@Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
) {}
async createIntent(
input: CreateMediaUploadIntentDto,
actor: AuthContext,
request: RequestContext,
): Promise<CreateUploadIntentResponse> {
this.assertConfigured();
const filename = this.safeFilename(input.filename);
const extension = EXTENSIONS[input.mimeType];
if (!extension || !this.extensionMatches(filename, extension)) {
throw this.validation('文件扩展名与图片类型不一致');
}
const fingerprint = this.sensitive.lookupHash(
'cover-upload-intent',
JSON.stringify({ ...input, filename }),
);
const previous = await this.files.findByIdempotency(actor.userId, input.clientRequestId);
if (previous) {
if (
previous.requestFingerprint !== fingerprint ||
previous.businessType !== input.businessType
) {
throw this.duplicate();
}
return this.uploadResponse(previous, true);
}
const now = new Date();
const objectKey = `public-covers/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}/${randomUUID()}.${extension}`;
const file = await this.files.createPending(
{
publicId: randomUUID(),
businessType: input.businessType,
clientRequestId: input.clientRequestId,
requestFingerprint: fingerprint,
provider: this.storage.providerName,
bucket: this.storage.bucket ?? '',
objectKey,
filenameEncrypted: this.sensitive.encrypt(filename),
mimeType: input.mimeType,
size: BigInt(input.size),
sha256: input.sha256,
},
actor,
request,
);
return this.uploadResponse(file, false);
}
async confirm(
publicId: string,
actor: AuthContext,
request: RequestContext,
): Promise<ConfirmUploadResponse> {
this.assertConfigured();
const file = await this.files.findByPublicId(publicId);
if (!file || file.caseId !== null || file.uploaderUserId !== actor.userId)
throw this.notFound();
if (file.businessType !== 'PROJECT_COVER' && file.businessType !== 'ACTIVITY_COVER') {
throw this.notFound();
}
if (file.status === 'AVAILABLE') return { file: this.attachment(file) };
if (file.status !== 'UPLOAD_PENDING') throw this.notReady();
let metadata;
try {
metadata = await this.storage.headObject(file.objectKey);
} catch {
throw this.storageUnavailable();
}
if (!metadata) throw this.notReady();
if (
metadata.contentLength !== Number(file.size) ||
metadata.contentType !== file.mimeType ||
(file.sha256 !== null && metadata.sha256 !== file.sha256)
) {
await this.files.quarantine(publicId, actor, request, 'OBJECT_METADATA_MISMATCH');
throw this.validation('图片校验未通过,请重新选择文件');
}
return { file: this.attachment(await this.files.markAvailable(publicId, actor, request)) };
}
async requireCover(
publicId: string | undefined,
businessType: FileBusinessType,
actor: AuthContext,
): Promise<File | null> {
if (!publicId) return null;
const file = await this.files.findByPublicId(publicId);
if (
!file ||
file.status !== 'AVAILABLE' ||
file.businessType !== businessType ||
file.uploaderUserId !== actor.userId ||
file.caseId !== null
) {
throw this.validation('封面图片无效或尚未确认上传');
}
return file;
}
async publicUrl(file: Pick<File, 'objectKey' | 'status'> | null): Promise<string | null> {
if (!file || file.status !== 'AVAILABLE' || !this.storage.configured) return null;
try {
return await this.storage.createDownloadUrl(file.objectKey, 600);
} catch {
return null;
}
}
private async uploadResponse(
file: File,
idempotentReplay: boolean,
): Promise<CreateUploadIntentResponse> {
const expiresIn = 600;
let uploadUrl: string;
try {
uploadUrl = await this.storage.createUploadUrl(
file.objectKey,
file.mimeType,
file.sha256 ?? undefined,
expiresIn,
);
} catch {
throw this.storageUnavailable();
}
return {
file: this.attachment(file),
uploadUrl,
uploadHeaders: {
'Content-Type': file.mimeType,
...(file.sha256 ? { 'x-amz-meta-sha256': file.sha256 } : {}),
},
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
idempotentReplay,
};
}
private attachment(file: File): ServiceRecordAttachment {
return {
publicId: file.publicId,
filename: this.sensitive.decrypt(file.originalFilenameEncrypted),
mimeType: file.mimeType,
size: Number(file.size),
};
}
private safeFilename(value: string): string {
const filename = value.trim();
if (
!filename ||
filename.includes('/') ||
filename.includes('\\') ||
Array.from(filename).some((character) => character.charCodeAt(0) <= 31)
) {
throw this.validation('文件名不合法');
}
return filename;
}
private extensionMatches(filename: string, extension: string): boolean {
const lower = filename.toLowerCase();
return lower.endsWith(`.${extension}`) || (extension === 'jpg' && lower.endsWith('.jpeg'));
}
private assertConfigured(): void {
if (!this.storage.configured || !this.storage.bucket) throw this.storageUnavailable();
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private duplicate(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他文件',
HttpStatus.CONFLICT,
);
}
private notFound(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.FILE_NOT_FOUND,
'未找到该图片',
HttpStatus.NOT_FOUND,
);
}
private notReady(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.UPLOAD_NOT_READY,
'尚未收到上传文件,请完成上传后重试',
HttpStatus.CONFLICT,
);
}
private storageUnavailable(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.STORAGE_UNAVAILABLE,
'图片服务尚未配置或暂时不可用',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { ContentNotificationsModule } from '../content-notifications/content-notifications.module';
import { FilesModule } from '../files/files.module';
import { ActivitiesService } from './activities.service';
import { AdminParticipationController } from './admin-participation.controller';
import { MeParticipationController } from './me-participation.controller';
import { MediaService } from './media.service';
import { ProjectsService } from './projects.service';
import { PublicParticipationController } from './public-participation.controller';
import { VolunteersService } from './volunteers.service';
@Module({
imports: [AuditModule, FilesModule, ContentNotificationsModule],
controllers: [
PublicParticipationController,
MeParticipationController,
AdminParticipationController,
],
providers: [VolunteersService, ProjectsService, ActivitiesService, MediaService],
})
export class ParticipationModule {}
@@ -0,0 +1,496 @@
import { ForbiddenException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomBytes, randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
PERMISSION_CODES,
PROJECT_STATUS_LABELS,
type PaginatedProjectsResponse,
type ProjectDetail,
type ProjectDetailResponse,
type ProjectListItem,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
CreateProjectDto,
ProjectQueryDto,
PublicProjectQueryDto,
ReplaceProjectCasesDto,
TransitionProjectDto,
UpdateProjectDto,
} from './dto/participation.dto';
import { MediaService } from './media.service';
const projectInclude = {
coverFile: true,
manager: { include: { staffProfile: true } },
statusHistory: { orderBy: { createdAt: 'desc' as const } },
_count: { select: { activities: true, serviceCases: true } },
} satisfies Prisma.ProjectInclude;
type StoredProject = Prisma.ProjectGetPayload<{ include: typeof projectInclude }>;
function dateNoPrefix(date: Date): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${value.year}${value.month}${value.day}`;
}
@Injectable()
export class ProjectsService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
@Inject(MediaService) private readonly media: MediaService,
) {}
async managerOptions() {
const users = await this.prisma.user.findMany({
where: {
status: 'ACTIVE',
staffProfile: { is: { status: 'ACTIVE' } },
userRoles: { some: { role: { code: 'PROJECT_MANAGER' } } },
},
include: { staffProfile: true },
orderBy: { staffProfile: { displayName: 'asc' } },
});
return users.map((user) => ({
userPublicId: user.publicId,
displayName: user.staffProfile?.displayName ?? '项目负责人',
type: 'STAFF' as const,
department: user.staffProfile?.department ?? null,
}));
}
async listAdmin(query: ProjectQueryDto, actor: AuthContext): Promise<PaginatedProjectsResponse> {
const where: Prisma.ProjectWhereInput = {
...(query.status ? { status: query.status as never } : {}),
...(query.category ? { category: query.category as never } : {}),
...(!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
? { managerUserId: actor.userId }
: {}),
};
return this.list(where, query.page, query.pageSize);
}
async listPublic(query: PublicProjectQueryDto): Promise<PaginatedProjectsResponse> {
return this.list(
{
isPublic: true,
status: { in: ['PUBLISHED', 'COMPLETED'] },
...(query.category ? { category: query.category as never } : {}),
},
query.page,
query.pageSize,
);
}
async getAdmin(publicId: string, actor: AuthContext): Promise<ProjectDetailResponse> {
const project = await this.find(publicId);
this.assertAccess(project, actor);
return { project: await this.detail(project) };
}
async getPublic(publicId: string): Promise<ProjectDetailResponse> {
const project = await this.find(publicId);
if (!project.isPublic || !['PUBLISHED', 'COMPLETED'].includes(project.status))
throw this.notFound();
return { project: await this.detail(project) };
}
async create(
input: CreateProjectDto,
actor: AuthContext,
request: RequestContext,
): Promise<ProjectDetailResponse> {
const values = await this.prepare(input, actor);
const fingerprint = this.fingerprint('project-create', {
...input,
name: input.name.trim(),
summary: input.summary.trim(),
content: input.content.trim(),
});
const replay = await this.prisma.project.findUnique({
where: {
createdById_clientRequestId: {
createdById: actor.userId,
clientRequestId: input.clientRequestId,
},
},
include: projectInclude,
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return { project: await this.detail(replay), idempotentReplay: true };
}
const project = await this.prisma.$transaction(async (transaction) => {
const created = await transaction.project.create({
data: {
publicId: randomUUID(),
projectNo: `XM${dateNoPrefix(new Date())}${randomBytes(4).toString('hex').toUpperCase()}`,
clientRequestId: input.clientRequestId,
requestFingerprint: fingerprint,
...values,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await transaction.projectStatusHistory.create({
data: {
projectId: created.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
toStatus: 'DRAFT',
operatorUserId: actor.userId,
reasonCode: 'CREATED',
},
});
await this.audit.record(
request,
{
action: 'PROJECT_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'PROJECT',
resourcePublicId: created.publicId,
metadata: { status: 'DRAFT' },
},
transaction,
);
return transaction.project.findUniqueOrThrow({
where: { id: created.id },
include: projectInclude,
});
});
return { project: await this.detail(project), idempotentReplay: false };
}
async update(
publicId: string,
input: UpdateProjectDto,
actor: AuthContext,
request: RequestContext,
): Promise<ProjectDetailResponse> {
const project = await this.find(publicId);
this.assertAccess(project, actor);
if (project.status !== 'DRAFT') throw this.invalidTransition('只有草稿项目可以编辑');
if (project.revision !== input.expectedRevision) throw this.conflict();
const values = await this.prepare(input, actor);
const updated = await this.prisma
.$transaction(async (transaction) => {
const result = await transaction.project.updateMany({
where: { id: project.id, revision: input.expectedRevision, status: 'DRAFT' },
data: { ...values, updatedById: actor.userId, revision: { increment: 1 } },
});
if (result.count !== 1) throw new Error('PROJECT_CONFLICT');
await this.audit.record(
request,
{
action: 'PROJECT_UPDATED',
result: 'SUCCESS',
actor,
resourceType: 'PROJECT',
resourcePublicId: publicId,
},
transaction,
);
return transaction.project.findUniqueOrThrow({
where: { id: project.id },
include: projectInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'PROJECT_CONFLICT') throw this.conflict();
throw error;
});
return { project: await this.detail(updated) };
}
async transition(
publicId: string,
input: TransitionProjectDto,
actor: AuthContext,
request: RequestContext,
): Promise<ProjectDetailResponse> {
const project = await this.find(publicId);
this.assertAccess(project, actor);
const fingerprint = this.fingerprint('project-transition', {
publicId,
targetStatus: input.targetStatus,
});
const replay = await this.prisma.projectStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { project: { include: projectInclude } },
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return { project: await this.detail(replay.project), idempotentReplay: true };
}
const expected = input.targetStatus === 'PUBLISHED' ? 'DRAFT' : 'PUBLISHED';
if (project.status !== expected) throw this.invalidTransition();
const updated = await this.prisma
.$transaction(async (transaction) => {
const result = await transaction.project.updateMany({
where: { id: project.id, status: expected },
data: {
status: input.targetStatus,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (result.count !== 1) throw new Error('PROJECT_STATE_CONFLICT');
await transaction.projectStatusHistory.create({
data: {
projectId: project.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
fromStatus: expected,
toStatus: input.targetStatus,
operatorUserId: actor.userId,
reasonCode: input.targetStatus === 'PUBLISHED' ? 'PUBLISHED' : 'COMPLETED',
},
});
await this.audit.record(
request,
{
action: 'PROJECT_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'PROJECT',
resourcePublicId: publicId,
metadata: { fromStatus: expected, toStatus: input.targetStatus },
},
transaction,
);
return transaction.project.findUniqueOrThrow({
where: { id: project.id },
include: projectInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'PROJECT_STATE_CONFLICT')
throw this.invalidTransition();
throw error;
});
return { project: await this.detail(updated), idempotentReplay: false };
}
async replaceCases(
publicId: string,
input: ReplaceProjectCasesDto,
actor: AuthContext,
request: RequestContext,
): Promise<ProjectDetailResponse> {
const project = await this.find(publicId);
this.assertAccess(project, actor);
if (project.status === 'COMPLETED') throw this.invalidTransition('已完成项目不能调整服务工单');
if (new Set(input.serviceCasePublicIds).size !== input.serviceCasePublicIds.length) {
throw this.validation('服务工单不能重复');
}
const cases = await this.prisma.serviceCase.findMany({
where: { publicId: { in: input.serviceCasePublicIds } },
select: { id: true, projectId: true },
});
if (cases.length !== input.serviceCasePublicIds.length)
throw this.validation('包含无效的服务工单');
if (
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL) &&
cases.some(
(serviceCase) => serviceCase.projectId !== null && serviceCase.projectId !== project.id,
)
) {
throw new ForbiddenException();
}
const updated = await this.prisma.$transaction(async (transaction) => {
await transaction.serviceCase.updateMany({
where: { projectId: project.id },
data: { projectId: null },
});
if (cases.length) {
await transaction.serviceCase.updateMany({
where: { id: { in: cases.map((item) => item.id) } },
data: { projectId: project.id },
});
}
await this.audit.record(
request,
{
action: 'PROJECT_CASES_REPLACED',
result: 'SUCCESS',
actor,
resourceType: 'PROJECT',
resourcePublicId: publicId,
metadata: { serviceCaseCount: cases.length },
},
transaction,
);
return transaction.project.findUniqueOrThrow({
where: { id: project.id },
include: projectInclude,
});
});
return { project: await this.detail(updated) };
}
private async prepare(input: CreateProjectDto | UpdateProjectDto, actor: AuthContext) {
const startDate = new Date(`${input.startDate}T00:00:00.000Z`);
const endDate = new Date(`${input.endDate}T00:00:00.000Z`);
if (endDate < startDate) throw this.validation('项目结束日期不能早于开始日期');
const manager = await this.prisma.user.findFirst({
where: {
publicId: input.managerUserPublicId,
status: 'ACTIVE',
staffProfile: { isNot: null },
userRoles: { some: { role: { code: 'PROJECT_MANAGER' } } },
},
});
if (!manager) throw this.validation('负责人必须是有效的项目管理员');
const cover = await this.media.requireCover(input.coverFilePublicId, 'PROJECT_COVER', actor);
return {
name: input.name.trim(),
category: input.category as never,
summary: input.summary.trim(),
content: input.content.trim(),
coverFileId: cover?.id,
managerUserId: manager.id,
startDate,
endDate,
targetPeople: input.targetPeople,
isPublic: input.isPublic,
sortOrder: input.sortOrder,
};
}
private async list(
where: Prisma.ProjectWhereInput,
page: number,
pageSize: number,
): Promise<PaginatedProjectsResponse> {
const [total, projects] = await this.prisma.$transaction([
this.prisma.project.count({ where }),
this.prisma.project.findMany({
where,
include: projectInclude,
orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
items: await Promise.all(projects.map((project) => this.item(project))),
page,
pageSize,
total,
};
}
private async find(publicId: string): Promise<StoredProject> {
const project = await this.prisma.project.findUnique({
where: { publicId },
include: projectInclude,
});
if (!project) throw this.notFound();
return project;
}
private assertAccess(project: StoredProject, actor: AuthContext): void {
if (
project.managerUserId !== actor.userId &&
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
) {
throw new ForbiddenException();
}
}
private async item(project: StoredProject): Promise<ProjectListItem> {
return {
publicId: project.publicId,
projectNo: project.projectNo,
name: project.name,
category: project.category,
summary: project.summary,
coverImageUrl: await this.media.publicUrl(project.coverFile),
managerUserPublicId: project.manager.publicId,
managerDisplayName:
project.manager.staffProfile?.displayName ?? project.manager.nickname ?? '项目负责人',
startDate: project.startDate.toISOString().slice(0, 10),
endDate: project.endDate.toISOString().slice(0, 10),
targetPeople: project.targetPeople,
status: project.status,
statusLabel: PROJECT_STATUS_LABELS[project.status],
isPublic: project.isPublic,
sortOrder: project.sortOrder,
revision: project.revision,
activityCount: project._count.activities,
serviceCaseCount: project._count.serviceCases,
createdAt: project.createdAt.toISOString(),
};
}
private async detail(project: StoredProject): Promise<ProjectDetail> {
return {
...(await this.item(project)),
content: project.content,
coverFilePublicId: project.coverFile?.publicId ?? null,
history: project.statusHistory.map((history) => ({
status: history.toStatus,
statusLabel: PROJECT_STATUS_LABELS[history.toStatus],
reasonLabel:
(
{ CREATED: '创建项目', PUBLISHED: '发布项目', COMPLETED: '完成项目' } as Record<
string,
string
>
)[history.reasonCode] ?? '状态更新',
createdAt: history.createdAt.toISOString(),
})),
};
}
private fingerprint(domain: string, value: object): string {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private notFound(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.PROJECT_NOT_FOUND,
'未找到该公益项目',
HttpStatus.NOT_FOUND,
);
}
private conflict(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'项目已更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private invalidTransition(message = '当前项目状态不能执行该操作'): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
message,
HttpStatus.CONFLICT,
);
}
private duplicate(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
}
@@ -0,0 +1,54 @@
import { Controller, Get, Inject, Param, Query, ValidationPipe } from '@nestjs/common';
import type {
ActivityDetailResponse,
PaginatedActivitiesResponse,
PaginatedProjectsResponse,
ProjectDetailResponse,
} from '@lehuo/contracts';
import { Public } from '../common/decorators/public.decorator';
import { ActivitiesService } from './activities.service';
import { PublicActivityQueryDto, PublicProjectQueryDto } from './dto/participation.dto';
import { ProjectsService } from './projects.service';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Public()
@Controller('api/v1/public')
export class PublicParticipationController {
constructor(
@Inject(ProjectsService) private readonly projects: ProjectsService,
@Inject(ActivitiesService) private readonly activities: ActivitiesService,
) {}
@Get('projects')
projectsList(
@Query(strict(PublicProjectQueryDto)) query: PublicProjectQueryDto,
): Promise<PaginatedProjectsResponse> {
return this.projects.listPublic(query);
}
@Get('projects/:publicId')
project(@Param('publicId') publicId: string): Promise<ProjectDetailResponse> {
return this.projects.getPublic(publicId);
}
@Get('activities')
activitiesList(
@Query(strict(PublicActivityQueryDto)) query: PublicActivityQueryDto,
): Promise<PaginatedActivitiesResponse> {
return this.activities.listPublic(query);
}
@Get('activities/:publicId')
activity(@Param('publicId') publicId: string): Promise<ActivityDetailResponse> {
return this.activities.getPublic(publicId);
}
}
@@ -0,0 +1,474 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
VOLUNTEER_SKILL_LABELS,
VOLUNTEER_STATUS_LABELS,
type AdminVolunteerDetailResponse,
type PaginatedVolunteersResponse,
type VolunteerProfile,
type VolunteerProfileResponse,
type VolunteerSkillCode,
type VolunteerStatus,
} from '@lehuo/contracts';
import { AuditService } from '../audit/audit.service';
import { ApplicationError } from '../common/application-error';
import type { AuthContext, RequestContext } from '../common/request-context';
import { PrismaService } from '../database/prisma.service';
import type { Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
ApplyVolunteerDto,
RestoreVolunteerDto,
ReviewVolunteerDto,
VolunteerQueryDto,
} from './dto/participation.dto';
const volunteerInclude = {
skills: true,
interests: true,
statusHistory: { orderBy: { createdAt: 'desc' as const } },
registrations: {
where: { status: 'APPROVED' as const, serviceMinutes: { not: null } },
select: { serviceMinutes: true },
},
} satisfies Prisma.VolunteerInclude;
type StoredVolunteer = Prisma.VolunteerGetPayload<{ include: typeof volunteerInclude }>;
function maskMobile(value: string): string {
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
}
@Injectable()
export class VolunteersService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
) {}
async mine(actor: AuthContext): Promise<VolunteerProfileResponse> {
const volunteer = await this.prisma.volunteer.findUnique({
where: { userId: actor.userId },
include: volunteerInclude,
});
return { volunteer: volunteer ? this.profile(volunteer) : null };
}
async apply(
input: ApplyVolunteerDto,
actor: AuthContext,
request: RequestContext,
): Promise<VolunteerProfileResponse> {
this.assertUnique(input.skillCodes, '技能不能重复');
this.assertUnique(input.interestCategories, '服务意向不能重复');
const normalized = {
realName: input.realName.trim(),
mobile: input.mobile,
regionCode: input.regionCode.trim(),
availabilityNote: input.availabilityNote.trim(),
skillCodes: [...input.skillCodes].sort(),
interestCategories: [...input.interestCategories].sort(),
expectedRevision: input.expectedRevision ?? null,
privacyConsent: input.privacyConsent,
};
const fingerprint = this.sensitive.lookupHash(
'volunteer-application',
JSON.stringify(normalized),
);
const existing = await this.prisma.volunteer.findUnique({
where: { userId: actor.userId },
include: volunteerInclude,
});
const replay = await this.prisma.volunteerStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { volunteer: { include: volunteerInclude } },
});
if (replay) {
if (replay.operatorUserId !== actor.userId || replay.requestFingerprint !== fingerprint) {
throw this.duplicate();
}
return { volunteer: this.profile(replay.volunteer), idempotentReplay: true };
}
if (existing && input.expectedRevision !== existing.revision) {
throw this.conflict('志愿者资料已更新,请刷新后重试');
}
if (!existing && input.expectedRevision !== undefined) {
throw this.validation('首次申请不应包含资料版本');
}
const identityChanged = Boolean(
existing &&
(existing.realNameHash !== this.sensitive.lookupHash('volunteer-name', normalized.realName) ||
existing.mobileHash !== this.sensitive.lookupHash('volunteer-mobile', normalized.mobile)),
);
const nextStatus: VolunteerStatus =
existing?.status === 'REJECTED' || existing?.status === 'INACTIVE'
? 'APPLIED'
: existing?.status === 'APPROVED' && identityChanged
? 'APPLIED'
: (existing?.status ?? 'APPLIED');
const created = await this.prisma
.$transaction(async (transaction) => {
const consent = await transaction.consent.create({
data: {
publicId: randomUUID(),
userId: actor.userId,
consentType: 'VOLUNTEER_APPLICATION',
version: 'volunteer-application-v1',
grantedBy: 'SELF',
grantedAt: new Date(),
evidenceJson: { affirmativeAction: true },
ipHash: request.ip ? this.sensitive.auditIpHash(request.ip) : null,
},
});
let volunteer;
if (existing) {
const updated = await transaction.volunteer.updateMany({
where: { id: existing.id, revision: existing.revision },
data: {
consentId: consent.id,
realNameEncrypted: this.sensitive.encrypt(normalized.realName),
realNameHash: this.sensitive.lookupHash('volunteer-name', normalized.realName),
mobileEncrypted: this.sensitive.encrypt(normalized.mobile),
mobileHash: this.sensitive.lookupHash('volunteer-mobile', normalized.mobile),
regionCode: normalized.regionCode,
availabilityNoteEncrypted: this.sensitive.encrypt(normalized.availabilityNote),
status: nextStatus,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (updated.count !== 1) throw new Error('REVISION_CONFLICT');
await transaction.volunteerSkill.deleteMany({ where: { volunteerId: existing.id } });
await transaction.volunteerInterest.deleteMany({ where: { volunteerId: existing.id } });
volunteer = await transaction.volunteer.findUniqueOrThrow({ where: { id: existing.id } });
} else {
volunteer = await transaction.volunteer.create({
data: {
publicId: randomUUID(),
userId: actor.userId,
consentId: consent.id,
realNameEncrypted: this.sensitive.encrypt(normalized.realName),
realNameHash: this.sensitive.lookupHash('volunteer-name', normalized.realName),
mobileEncrypted: this.sensitive.encrypt(normalized.mobile),
mobileHash: this.sensitive.lookupHash('volunteer-mobile', normalized.mobile),
regionCode: normalized.regionCode,
availabilityNoteEncrypted: this.sensitive.encrypt(normalized.availabilityNote),
status: nextStatus,
createdById: actor.userId,
updatedById: actor.userId,
},
});
}
if (normalized.skillCodes.length) {
await transaction.volunteerSkill.createMany({
data: normalized.skillCodes.map((skillCode) => ({
volunteerId: volunteer.id,
skillCode,
})),
});
}
if (normalized.interestCategories.length) {
await transaction.volunteerInterest.createMany({
data: normalized.interestCategories.map((category) => ({
volunteerId: volunteer.id,
category: category as never,
})),
});
}
if (existing?.status === 'APPROVED' && nextStatus === 'APPLIED') {
const role = await transaction.role.findUniqueOrThrow({
where: { code: 'VOLUNTEER' },
});
await transaction.userRole.deleteMany({
where: { userId: actor.userId, roleId: role.id },
});
}
await transaction.volunteerStatusHistory.create({
data: {
volunteerId: volunteer.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
fromStatus: existing?.status,
toStatus: nextStatus,
operatorUserId: actor.userId,
reasonCode: existing
? nextStatus === 'APPLIED'
? identityChanged
? 'IDENTITY_CHANGED'
: 'REAPPLIED'
: 'PROFILE_UPDATED'
: 'APPLIED',
},
});
await this.audit.record(
request,
{
action: existing ? 'VOLUNTEER_PROFILE_UPDATED' : 'VOLUNTEER_APPLICATION_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'VOLUNTEER',
resourcePublicId: volunteer.publicId,
metadata: { status: nextStatus },
},
transaction,
);
return transaction.volunteer.findUniqueOrThrow({
where: { id: volunteer.id },
include: volunteerInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'REVISION_CONFLICT') {
throw this.conflict('志愿者资料已更新,请刷新后重试');
}
throw error;
});
return { volunteer: this.profile(created), idempotentReplay: false };
}
async list(query: VolunteerQueryDto): Promise<PaginatedVolunteersResponse> {
const where = {
...(query.status ? { status: query.status as never } : {}),
...(query.regionCode ? { regionCode: query.regionCode } : {}),
};
const [total, volunteers] = await this.prisma.$transaction([
this.prisma.volunteer.count({ where }),
this.prisma.volunteer.findMany({
where,
include: volunteerInclude,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
return {
items: volunteers.map((volunteer) => {
const item = this.profile(volunteer);
return {
publicId: item.publicId,
realName: item.realName,
mobileMasked: item.mobileMasked,
regionCode: item.regionCode,
skillLabels: item.skillLabels,
interestCategories: item.interestCategories,
status: item.status,
statusLabel: item.statusLabel,
totalServiceMinutes: item.totalServiceMinutes,
completedActivityCount: item.completedActivityCount,
createdAt: item.createdAt,
};
}),
page: query.page,
pageSize: query.pageSize,
total,
};
}
async get(
publicId: string,
actor: AuthContext,
request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
const volunteer = await this.prisma.volunteer.findUnique({
where: { publicId },
include: volunteerInclude,
});
if (!volunteer) throw this.notFound();
await this.audit.record(request, {
action: 'VOLUNTEER_PROFILE_VIEWED',
result: 'SUCCESS',
actor,
resourceType: 'VOLUNTEER',
resourcePublicId: publicId,
});
return { volunteer: this.profile(volunteer) };
}
review(
publicId: string,
input: ReviewVolunteerDto | RestoreVolunteerDto,
actor: AuthContext,
request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
return this.changeStatus(publicId, input, actor, request);
}
private async changeStatus(
publicId: string,
input: ReviewVolunteerDto | RestoreVolunteerDto,
actor: AuthContext,
request: RequestContext,
): Promise<AdminVolunteerDetailResponse> {
const fingerprint = this.sensitive.lookupHash(
'volunteer-review',
JSON.stringify({ publicId, targetStatus: input.targetStatus, note: input.note.trim() }),
);
const replay = await this.prisma.volunteerStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { volunteer: { include: volunteerInclude } },
});
if (replay) {
if (replay.requestFingerprint !== fingerprint) throw this.duplicate();
return { volunteer: this.profile(replay.volunteer) };
}
const volunteer = await this.prisma.volunteer.findUnique({ where: { publicId } });
if (!volunteer) throw this.notFound();
const allowed: Record<string, VolunteerStatus[]> = {
APPLIED: ['APPROVED', 'REJECTED'],
APPROVED: ['SUSPENDED'],
SUSPENDED: ['APPROVED'],
};
if (!allowed[volunteer.status]?.includes(input.targetStatus)) throw this.invalidTransition();
const result = await this.prisma
.$transaction(async (transaction) => {
const changed = await transaction.volunteer.updateMany({
where: { id: volunteer.id, status: volunteer.status },
data: {
status: input.targetStatus,
approvedById: input.targetStatus === 'APPROVED' ? actor.userId : volunteer.approvedById,
approvedAt: input.targetStatus === 'APPROVED' ? new Date() : volunteer.approvedAt,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (changed.count !== 1) throw new Error('STATE_CONFLICT');
const role = await transaction.role.findUniqueOrThrow({ where: { code: 'VOLUNTEER' } });
if (input.targetStatus === 'APPROVED') {
await transaction.userRole.upsert({
where: { userId_roleId: { userId: volunteer.userId, roleId: role.id } },
create: { userId: volunteer.userId, roleId: role.id, assignedBy: actor.userId },
update: { assignedBy: actor.userId, assignedAt: new Date() },
});
} else {
await transaction.userRole.deleteMany({
where: { userId: volunteer.userId, roleId: role.id },
});
}
await transaction.volunteerStatusHistory.create({
data: {
volunteerId: volunteer.id,
requestId: input.clientRequestId,
requestFingerprint: fingerprint,
fromStatus: volunteer.status,
toStatus: input.targetStatus,
operatorUserId: actor.userId,
reasonCode: `REVIEW_${input.targetStatus}`,
noteEncrypted: this.sensitive.encrypt(input.note.trim()),
},
});
await this.audit.record(
request,
{
action: 'VOLUNTEER_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'VOLUNTEER',
resourcePublicId: publicId,
metadata: { fromStatus: volunteer.status, toStatus: input.targetStatus },
},
transaction,
);
return transaction.volunteer.findUniqueOrThrow({
where: { id: volunteer.id },
include: volunteerInclude,
});
})
.catch((error: unknown) => {
if (error instanceof Error && error.message === 'STATE_CONFLICT')
throw this.invalidTransition();
throw error;
});
return { volunteer: this.profile(result) };
}
private profile(volunteer: StoredVolunteer): VolunteerProfile {
const mobile = this.sensitive.decrypt(volunteer.mobileEncrypted);
const serviceMinutes = volunteer.registrations.reduce(
(sum, item) => sum + (item.serviceMinutes ?? 0),
0,
);
return {
publicId: volunteer.publicId,
realName: this.sensitive.decrypt(volunteer.realNameEncrypted),
mobile,
mobileMasked: maskMobile(mobile),
regionCode: volunteer.regionCode,
availabilityNote: this.sensitive.decrypt(volunteer.availabilityNoteEncrypted),
skillCodes: volunteer.skills.map((skill) => skill.skillCode as VolunteerSkillCode),
skillLabels: volunteer.skills.map(
(skill) => VOLUNTEER_SKILL_LABELS[skill.skillCode as VolunteerSkillCode] ?? skill.skillCode,
),
interestCategories: volunteer.interests.map((interest) => interest.category),
status: volunteer.status,
statusLabel: VOLUNTEER_STATUS_LABELS[volunteer.status],
revision: volunteer.revision,
totalServiceMinutes: serviceMinutes,
completedActivityCount: volunteer.registrations.length,
history: volunteer.statusHistory.map((history) => ({
status: history.toStatus,
statusLabel: VOLUNTEER_STATUS_LABELS[history.toStatus],
reasonLabel: this.reasonLabel(history.reasonCode),
note: history.noteEncrypted ? this.sensitive.decrypt(history.noteEncrypted) : null,
createdAt: history.createdAt.toISOString(),
})),
createdAt: volunteer.createdAt.toISOString(),
};
}
private reasonLabel(code: string): string {
return (
(
{
APPLIED: '提交申请',
REAPPLIED: '重新申请',
IDENTITY_CHANGED: '身份信息变更,重新审核',
PROFILE_UPDATED: '更新资料',
REVIEW_APPROVED: '审核通过',
REVIEW_REJECTED: '审核未通过',
REVIEW_SUSPENDED: '暂停志愿者资格',
} as Record<string, string>
)[code] ?? '状态更新'
);
}
private assertUnique(values: string[], message: string): void {
if (new Set(values).size !== values.length) throw this.validation(message);
}
private validation(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private notFound(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.VOLUNTEER_NOT_FOUND,
'未找到志愿者资料',
HttpStatus.NOT_FOUND,
);
}
private invalidTransition(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
'当前状态不能执行该操作',
HttpStatus.CONFLICT,
);
}
private conflict(message: string): ApplicationError {
return new ApplicationError(API_ERROR_CODES.RESOURCE_CONFLICT, message, HttpStatus.CONFLICT);
}
private duplicate(): ApplicationError {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
}