feat(old-items): complete reuse workflow and refine miniapp home

This commit is contained in:
2026-08-15 16:38:30 +08:00
parent 65d23eab4a
commit f3737c80be
94 changed files with 7880 additions and 215 deletions
+191
View File
@@ -0,0 +1,191 @@
import { Type } from 'class-transformer';
import {
IsArray,
ArrayMaxSize,
ArrayMinSize,
IsDateString,
IsIn,
IsInt,
IsMobilePhone,
IsOptional,
IsString,
IsUUID,
Length,
Max,
Matches,
Min,
ValidateNested,
} from 'class-validator';
import {
OLD_ITEM_CATEGORIES,
OLD_ITEM_CONDITIONS,
OLD_ITEM_DONATION_METHODS,
OLD_ITEM_DONATION_STATUSES,
OLD_ITEM_NEED_STATUSES,
OLD_ITEM_INVENTORY_STATUSES,
} from '@lehuo/contracts';
import { PaginationDto } from '../../case-management/dto/case-management.dto';
export class OldItemDonationQueryDto extends PaginationDto {
@IsOptional() @IsIn(OLD_ITEM_DONATION_STATUSES) status?: string;
@IsOptional() @IsIn(OLD_ITEM_DONATION_METHODS) method?: string;
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
}
export class OldItemNeedQueryDto extends PaginationDto {
@IsOptional() @IsIn(OLD_ITEM_CATEGORIES) category?: string;
@IsOptional() @IsIn(OLD_ITEM_NEED_STATUSES) status?: string;
}
export class CreateOldItemDonationDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
@IsIn(OLD_ITEM_CONDITIONS) itemCondition!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(9999) quantity!: number;
@IsString() @Length(2, 1000) description!: string;
@IsIn(OLD_ITEM_DONATION_METHODS) method!: 'PICKUP' | 'DROP_OFF';
@IsString() @Length(2, 50) contactName!: string;
@IsMobilePhone('zh-CN') mobile!: string;
@IsOptional() @IsString() @Length(5, 500) address?: string;
@IsOptional() @IsDateString() appointmentAt?: string;
@IsOptional() @IsUUID('4') dropOffPointPublicId?: string;
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
@IsOptional() @IsArray() @IsUUID('4', { each: true }) imagePublicIds?: string[];
}
export class CreateOldItemMediaUploadIntentDto {
@IsUUID('4') clientRequestId!: string;
@IsIn(['OLD_ITEM_DONATION_IMAGE']) businessType!: 'OLD_ITEM_DONATION_IMAGE';
@IsString() @Length(1, 255) filename!: string;
@IsIn(['image/jpeg', 'image/png', 'image/webp']) mimeType!:
'image/jpeg' | 'image/png' | 'image/webp';
@Type(() => Number) @IsInt() @Min(1) @Max(10 * 1024 * 1024) size!: number;
@IsOptional() @Matches(/^[a-fA-F0-9]{64}$/) sha256?: string;
}
export class TransitionOldItemDonationDto {
@IsUUID('4') clientRequestId!: string;
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@IsIn(OLD_ITEM_DONATION_STATUSES) targetStatus!: string;
@IsOptional() @IsString() @Length(1, 500) note?: string;
@IsOptional() @IsUUID('4') assignedUserPublicId?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(999999) assistedPeople?: number;
}
export class CreateOldItemNeedDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(2, 160) title!: string;
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
@IsString() @Length(2, 120) regionText!: string;
@IsString() @Length(2, 160) beneficiaryGroup!: string;
@IsString() @Length(2, 2000) description!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(999999) requestedQuantity!: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) priority?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
}
export class UpdateOldItemNeedDto {
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@IsString() @Length(2, 160) title!: string;
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
@IsString() @Length(2, 120) regionText!: string;
@IsString() @Length(2, 160) beneficiaryGroup!: string;
@IsString() @Length(2, 2000) description!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(999999) requestedQuantity!: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) priority?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
}
export class TransitionOldItemNeedDto {
@IsUUID('4') clientRequestId!: string;
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@IsIn(OLD_ITEM_NEED_STATUSES) targetStatus!: string;
}
export class CreateOldItemDropOffPointDto {
@IsString() @Length(2, 120) name!: string;
@IsString() @Length(2, 120) regionText!: string;
@IsString() @Length(5, 300) addressText!: string;
@IsString() @Length(2, 200) openingHours!: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(-9999) @Max(9999) sortOrder?: number;
}
export class OldItemInventoryQueryDto extends PaginationDto {
@IsOptional() @IsIn(OLD_ITEM_INVENTORY_STATUSES) status?: string;
@IsOptional() @IsIn(OLD_ITEM_CATEGORIES) category?: string;
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
}
export class CreateOldItemInventoryDto {
@IsUUID('4') clientRequestId!: string;
@IsOptional() @IsUUID('4') sourceDonationPublicId?: string;
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
@IsString() @Length(1, 160) itemName!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(999999) quantity!: number;
@IsString() @Length(1, 20) unit!: string;
@IsString() @Length(2, 160) storageLocation!: string;
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
@IsOptional() @IsString() @Length(1, 1000) note?: string;
@IsOptional() @IsDateString() inboundAt?: string;
}
export class OldItemInventoryImportRowDto {
@IsOptional() @IsString() @Length(1, 32) sourceDonationNo?: string;
@IsIn(OLD_ITEM_CATEGORIES) category!: string;
@IsString() @Length(1, 160) itemName!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(999999) quantity!: number;
@IsString() @Length(1, 20) unit!: string;
@IsString() @Length(2, 160) storageLocation!: string;
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
@IsOptional() @IsString() @Length(1, 1000) note?: string;
@IsOptional() @IsDateString() inboundAt?: string;
}
export class ImportOldItemInventoryDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(1, 255) originalFilename!: string;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => OldItemInventoryImportRowDto)
rows!: OldItemInventoryImportRowDto[];
}
export class TransitionOldItemInventoryDto {
@IsUUID('4') clientRequestId!: string;
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@IsIn(OLD_ITEM_INVENTORY_STATUSES) targetStatus!: string;
@IsOptional() @IsDateString() eventAt?: string;
@IsOptional() @IsString() @Length(2, 160) locationText?: string;
@IsOptional() @IsString() @Length(1, 1000) note?: string;
@IsOptional() @IsUUID('4') targetNeedPublicId?: string;
@IsOptional() @IsUUID('4') beneficiaryPublicId?: string;
@IsOptional() @IsString() @Length(2, 120) destinationRegion?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(999999) assistedPeople?: number;
}
export class ActivateOldItemRewardConfigDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(2, 120) certificateTitle!: string;
@IsString() @Length(10, 1000) certificateMessage!: string;
@IsString() @Length(2, 120) issuerName!: string;
@IsString() @Length(10, 500) legalNotice!: string;
@Type(() => Number) @IsInt() @Min(0) @Max(1000) clothingPoints!: number;
@Type(() => Number) @IsInt() @Min(0) @Max(1000) booksPoints!: number;
@Type(() => Number) @IsInt() @Min(0) @Max(1000) householdPoints!: number;
@Type(() => Number) @IsInt() @Min(0) @Max(1000) sportsPoints!: number;
@Type(() => Number) @IsInt() @Min(0) @Max(100000) maxPointsPerDonation!: number;
}
export class IssueOldItemCertificateDto {
@IsUUID('4') clientRequestId!: string;
}
export class RevokeOldItemCertificateDto {
@IsUUID('4') clientRequestId!: string;
@IsString() @Length(2, 500) reason!: string;
}
export class AdjustOldItemPointsDto {
@IsUUID('4') clientRequestId!: string;
@Type(() => Number) @IsInt() @Min(1) expectedRevision!: number;
@Type(() => Number) @IsInt() @Min(-100000) @Max(100000) pointsDelta!: number;
@IsString() @Length(2, 500) reason!: string;
}
export class OldItemPointAccountQueryDto extends PaginationDto {
@IsOptional() @IsString() @Length(1, 100) keyword?: string;
}
@@ -0,0 +1,634 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { API_ERROR_CODES, PERMISSION_CODES, type OldItemInventoryItem } 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 {
CreateOldItemInventoryDto,
ImportOldItemInventoryDto,
OldItemInventoryImportRowDto,
OldItemInventoryQueryDto,
TransitionOldItemInventoryDto,
} from './dto/old-items.dto';
import { OldItemRewardsService } from './old-item-rewards.service';
const TRANSITIONS: Record<string, string[]> = {
IN_STOCK: ['SORTED'],
SORTED: ['DISINFECTED'],
DISINFECTED: ['OUTBOUND'],
OUTBOUND: ['DELIVERED'],
DELIVERED: [],
};
const EVENT_BY_STATUS = {
IN_STOCK: 'INBOUND',
SORTED: 'SORTED',
DISINFECTED: 'DISINFECTED',
OUTBOUND: 'OUTBOUND',
DELIVERED: 'DELIVERED',
} as const;
const INCLUDE = {
sourceDonation: { select: { publicId: true, donationNo: true, status: true, revision: true } },
targetNeed: { select: { publicId: true, title: true } },
beneficiary: { select: { publicId: true } },
events: {
include: { operator: { include: { staffProfile: true } } },
orderBy: { eventAt: 'asc' as const },
},
} satisfies Prisma.OldItemInventoryItemInclude;
type InventoryWithRelations = Prisma.OldItemInventoryItemGetPayload<{ include: typeof INCLUDE }>;
@Injectable()
export class OldItemInventoryService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
) {}
async list(q: OldItemInventoryQueryDto) {
const where: Prisma.OldItemInventoryItemWhereInput = {
...(q.status ? { status: q.status as never } : {}),
...(q.category ? { category: q.category as never } : {}),
...(q.keyword
? {
OR: [
{ inventoryNo: { contains: q.keyword } },
{ itemName: { contains: q.keyword } },
{ sourceDonation: { donationNo: { contains: q.keyword } } },
],
}
: {}),
};
const [total, items] = await this.prisma.$transaction([
this.prisma.oldItemInventoryItem.count({ where }),
this.prisma.oldItemInventoryItem.findMany({
where,
include: INCLUDE,
orderBy: { createdAt: 'desc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return {
items: items.map((item) => this.map(item)),
page: q.page,
pageSize: q.pageSize,
total,
};
}
async get(publicId: string, actor: AuthContext, req: RequestContext) {
const item = await this.find(publicId);
await this.audit.record(req, {
action: 'OLD_ITEM_INVENTORY_VIEWED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_INVENTORY',
resourcePublicId: publicId,
});
return { item: this.map(item) };
}
async create(input: CreateOldItemInventoryDto, actor: AuthContext, req: RequestContext) {
const fp = this.fingerprint('old-item-inventory-create', input);
const replay = await this.prisma.oldItemInventoryEvent.findUnique({
where: { requestId: input.clientRequestId },
include: { inventoryItem: { include: INCLUDE } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { item: this.map(replay.inventoryItem), idempotentReplay: true };
}
const created = await this.prisma.$transaction(async (tx) => {
const links = await this.resolveLinks(
tx,
input.sourceDonationPublicId,
undefined,
input.targetNeedPublicId,
);
const eventAt = this.eventAt(input.inboundAt);
const item = await tx.oldItemInventoryItem.create({
data: {
publicId: randomUUID(),
inventoryNo: this.inventoryNo(),
sourceDonationId: links.donation?.id,
category: input.category as never,
itemName: input.itemName.trim(),
quantity: input.quantity,
unit: input.unit.trim(),
storageLocation: input.storageLocation.trim(),
targetNeedId: links.need?.id,
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await tx.oldItemInventoryEvent.create({
data: {
inventoryItemId: item.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
eventType: 'INBOUND',
toStatus: 'IN_STOCK',
locationText: input.storageLocation.trim(),
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
eventAt,
operatorUserId: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_INVENTORY_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_INVENTORY',
resourcePublicId: item.publicId,
metadata: { category: input.category, quantity: input.quantity, source: 'MANUAL' },
},
tx,
);
return tx.oldItemInventoryItem.findUniqueOrThrow({
where: { id: item.id },
include: INCLUDE,
});
});
return { item: this.map(created), idempotentReplay: false };
}
async import(input: ImportOldItemInventoryDto, actor: AuthContext, req: RequestContext) {
const fp = this.fingerprint('old-item-inventory-import', input);
const replay = await this.prisma.oldItemInventoryImport.findUnique({
where: {
createdById_clientRequestId: {
createdById: actor.userId,
clientRequestId: input.clientRequestId,
},
},
include: { items: { include: INCLUDE, orderBy: { createdAt: 'asc' } } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return {
importPublicId: replay.publicId,
items: replay.items.map((x) => this.map(x)),
idempotentReplay: true,
};
}
this.validateImportDuplicates(input.rows);
const created = await this.prisma.$transaction(async (tx) => {
const batch = await tx.oldItemInventoryImport.create({
data: {
publicId: randomUUID(),
clientRequestId: input.clientRequestId,
requestFingerprint: fp,
originalFilenameEncrypted: this.sensitive.encrypt(input.originalFilename.trim()),
rowCount: input.rows.length,
createdById: actor.userId,
},
});
const items: InventoryWithRelations[] = [];
for (const [index, row] of input.rows.entries()) {
const links = await this.resolveLinks(
tx,
undefined,
row.sourceDonationNo,
row.targetNeedPublicId,
);
const item = await this.createImportRow(
tx,
batch.id,
row,
index,
links,
actor.userId,
input.clientRequestId,
fp,
);
items.push(item);
}
await this.audit.record(
req,
{
action: 'OLD_ITEM_INVENTORY_IMPORTED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_INVENTORY_IMPORT',
resourcePublicId: batch.publicId,
metadata: { rowCount: input.rows.length },
},
tx,
);
return { batch, items };
});
return {
importPublicId: created.batch.publicId,
items: created.items.map((x) => this.map(x)),
idempotentReplay: false,
};
}
async transition(
publicId: string,
input: TransitionOldItemInventoryDto,
actor: AuthContext,
req: RequestContext,
) {
const fp = this.fingerprint('old-item-inventory-transition', { publicId, ...input });
const replay = await this.prisma.oldItemInventoryEvent.findUnique({
where: { requestId: input.clientRequestId },
include: { inventoryItem: { include: INCLUDE } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { item: this.map(replay.inventoryItem), idempotentReplay: true };
}
const current = await this.find(publicId);
if (current.revision !== input.expectedRevision) throw this.conflict();
if (!TRANSITIONS[current.status]?.includes(input.targetStatus)) throw this.invalid();
if (
input.targetStatus === 'OUTBOUND' &&
!input.destinationRegion &&
!input.targetNeedPublicId &&
!input.beneficiaryPublicId
)
throw this.validation('出库时必须绑定物资需求、受助对象或帮扶区域之一');
if (input.targetStatus === 'DELIVERED' && input.assistedPeople === undefined)
throw this.validation('帮扶落地时必须填写实际帮扶人数');
const eventAt = this.eventAt(input.eventAt);
const previousAt = current.events.at(-1)?.eventAt;
if (previousAt && eventAt < previousAt) throw this.validation('流转时间不能早于上一节点');
const updated = await this.prisma.$transaction(async (tx) => {
const links = await this.resolveDestination(tx, current, input, actor);
const changed = await tx.oldItemInventoryItem.updateMany({
where: { id: current.id, revision: input.expectedRevision, status: current.status },
data: {
status: input.targetStatus as never,
revision: { increment: 1 },
updatedById: actor.userId,
...(input.locationText ? { storageLocation: input.locationText.trim() } : {}),
...(input.targetStatus === 'OUTBOUND'
? {
targetNeedId: links.needId,
beneficiaryId: links.beneficiaryId,
destinationRegion: input.destinationRegion?.trim() ?? current.destinationRegion,
}
: {}),
...(input.targetStatus === 'DELIVERED' ? { landingAt: eventAt } : {}),
...(input.note ? { noteEncrypted: this.sensitive.encrypt(input.note.trim()) } : {}),
},
});
if (changed.count !== 1) throw this.conflict();
await tx.oldItemInventoryEvent.create({
data: {
inventoryItemId: current.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
eventType: EVENT_BY_STATUS[input.targetStatus as keyof typeof EVENT_BY_STATUS],
fromStatus: current.status,
toStatus: input.targetStatus as never,
locationText:
input.locationText?.trim() ??
(input.targetStatus === 'OUTBOUND' ? input.destinationRegion?.trim() : null),
noteEncrypted: input.note ? this.sensitive.encrypt(input.note.trim()) : null,
eventAt,
operatorUserId: actor.userId,
},
});
await this.syncDonation(tx, current, input, actor, req, fp, eventAt);
await this.audit.record(
req,
{
action: 'OLD_ITEM_INVENTORY_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_INVENTORY',
resourcePublicId: publicId,
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
},
tx,
);
return tx.oldItemInventoryItem.findUniqueOrThrow({
where: { id: current.id },
include: INCLUDE,
});
});
return { item: this.map(updated), idempotentReplay: false };
}
async export(q: OldItemInventoryQueryDto, actor: AuthContext, req: RequestContext) {
const result = await this.list({
...q,
page: 1,
pageSize: Math.min(5000, Math.max(q.pageSize, 5000)),
});
await this.audit.record(req, {
action: 'OLD_ITEM_INVENTORY_EXPORTED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_INVENTORY_EXPORT',
metadata: {
rowCount: result.items.length,
status: q.status ?? null,
category: q.category ?? null,
},
});
return {
rows: result.items.map((x) => ({
inventoryNo: x.inventoryNo,
sourceDonationNo: x.sourceDonation?.donationNo ?? '',
category: x.category,
itemName: x.itemName,
quantity: x.quantity,
unit: x.unit,
storageLocation: x.storageLocation,
status: x.status,
targetNeedTitle: x.targetNeed?.title ?? '',
beneficiaryPublicId: x.beneficiaryPublicId ?? '',
destinationRegion: x.destinationRegion ?? '',
landingAt: x.landingAt ?? '',
createdAt: x.createdAt,
})),
exportedAt: new Date().toISOString(),
};
}
private async createImportRow(
tx: Prisma.TransactionClient,
importId: bigint,
row: OldItemInventoryImportRowDto,
index: number,
links: { donation: { id: bigint } | null; need: { id: bigint } | null },
actorId: bigint,
requestId: string,
fp: string,
) {
const item = await tx.oldItemInventoryItem.create({
data: {
publicId: randomUUID(),
inventoryNo: this.inventoryNo(),
sourceDonationId: links.donation?.id,
importId,
category: row.category as never,
itemName: row.itemName.trim(),
quantity: row.quantity,
unit: row.unit.trim(),
storageLocation: row.storageLocation.trim(),
targetNeedId: links.need?.id,
noteEncrypted: row.note ? this.sensitive.encrypt(row.note.trim()) : null,
createdById: actorId,
updatedById: actorId,
},
});
await tx.oldItemInventoryEvent.create({
data: {
inventoryItemId: item.id,
requestId: this.derivedRequestId(requestId, index),
requestFingerprint: fp,
eventType: 'INBOUND',
toStatus: 'IN_STOCK',
locationText: row.storageLocation.trim(),
noteEncrypted: row.note ? this.sensitive.encrypt(row.note.trim()) : null,
eventAt: this.eventAt(row.inboundAt),
operatorUserId: actorId,
},
});
return tx.oldItemInventoryItem.findUniqueOrThrow({ where: { id: item.id }, include: INCLUDE });
}
private async resolveLinks(
tx: Prisma.TransactionClient,
donationPublicId?: string,
donationNo?: string,
needPublicId?: string,
) {
const [donation, need] = await Promise.all([
donationPublicId || donationNo
? tx.oldItemDonation.findFirst({
where: donationPublicId ? { publicId: donationPublicId } : { donationNo },
})
: null,
needPublicId ? tx.oldItemNeed.findUnique({ where: { publicId: needPublicId } }) : null,
]);
if ((donationPublicId || donationNo) && !donation) throw this.validation('来源捐赠编号不存在');
if (donation && !['RECEIVED', 'PROCESSING', 'COMPLETED'].includes(donation.status))
throw this.validation('来源捐赠尚未接收,不能入库');
if (donation) {
const existing = await tx.oldItemInventoryItem.findUnique({
where: { sourceDonationId: donation.id },
select: { publicId: true },
});
if (existing) throw this.validation('来源捐赠已完成入库,不能重复绑定');
}
if (needPublicId && !need) throw this.validation('定向物资需求不存在');
return { donation, need };
}
private async resolveDestination(
tx: Prisma.TransactionClient,
current: InventoryWithRelations,
input: TransitionOldItemInventoryDto,
actor: AuthContext,
) {
const need = input.targetNeedPublicId
? await tx.oldItemNeed.findUnique({ where: { publicId: input.targetNeedPublicId } })
: null;
if (
input.beneficiaryPublicId &&
!actor.permissions.includes(PERMISSION_CODES.BENEFICIARIES_READ) &&
!actor.permissions.includes(PERMISSION_CODES.RESOURCES_ACCESS_ALL)
)
throw new ApplicationError(
API_ERROR_CODES.FORBIDDEN,
'无权绑定受助对象',
HttpStatus.FORBIDDEN,
);
const beneficiary = input.beneficiaryPublicId
? await tx.beneficiaryProfile.findFirst({
where: { publicId: input.beneficiaryPublicId, status: 'ACTIVE' },
})
: null;
if (input.targetNeedPublicId && !need) throw this.validation('物资需求不存在');
if (input.beneficiaryPublicId && !beneficiary) throw this.validation('受助对象不存在或不可用');
return {
needId: need?.id ?? (input.targetNeedPublicId ? null : current.targetNeedId),
beneficiaryId: beneficiary?.id ?? (input.beneficiaryPublicId ? null : current.beneficiaryId),
};
}
private async syncDonation(
tx: Prisma.TransactionClient,
current: InventoryWithRelations,
input: TransitionOldItemInventoryDto,
actor: AuthContext,
req: RequestContext,
fp: string,
eventAt: Date,
) {
const donation = current.sourceDonation;
if (
input.targetStatus === 'DELIVERED' &&
current.targetNeedId &&
(!donation || donation.status !== 'COMPLETED')
) {
const need = await tx.oldItemNeed.findUniqueOrThrow({
where: { id: current.targetNeedId },
});
const fulfilled = Math.min(need.requestedQuantity, need.fulfilledQuantity + current.quantity);
await tx.oldItemNeed.update({
where: { id: need.id },
data: {
fulfilledQuantity: fulfilled,
...(fulfilled >= need.requestedQuantity && need.status === 'PUBLISHED'
? { status: 'FULFILLED', revision: { increment: 1 } }
: {}),
},
});
}
if (!donation) return;
if (input.targetStatus === 'SORTED' && donation.status === 'RECEIVED') {
await tx.oldItemDonation.update({
where: { id: current.sourceDonationId! },
data: { status: 'PROCESSING', revision: { increment: 1 } },
});
await tx.oldItemDonationStatusHistory.create({
data: {
donationId: current.sourceDonationId!,
requestId: input.clientRequestId,
requestFingerprint: fp,
fromStatus: 'RECEIVED',
toStatus: 'PROCESSING',
operatorUserId: actor.userId,
createdAt: eventAt,
},
});
}
if (input.targetStatus === 'DELIVERED' && donation.status !== 'COMPLETED') {
const allowed = donation.status === 'PROCESSING' || donation.status === 'RECEIVED';
if (!allowed) throw this.invalid();
await tx.oldItemDonation.update({
where: { id: current.sourceDonationId! },
data: {
status: 'COMPLETED',
revision: { increment: 1 },
completedAt: eventAt,
assistedPeople: input.assistedPeople ?? 0,
},
});
await tx.oldItemDonationStatusHistory.create({
data: {
donationId: current.sourceDonationId!,
requestId: input.clientRequestId,
requestFingerprint: fp,
fromStatus: donation.status,
toStatus: 'COMPLETED',
operatorUserId: actor.userId,
createdAt: eventAt,
},
});
await this.rewards.grantOnCompletion(
tx,
current.sourceDonationId!,
input.clientRequestId,
eventAt,
actor,
req,
);
}
}
private async find(publicId: string) {
const item = await this.prisma.oldItemInventoryItem.findUnique({
where: { publicId },
include: INCLUDE,
});
if (!item)
throw new ApplicationError(
API_ERROR_CODES.RESOURCE_NOT_FOUND,
'未找到该物资台账',
HttpStatus.NOT_FOUND,
);
return item;
}
private map(x: InventoryWithRelations): OldItemInventoryItem {
return {
publicId: x.publicId,
inventoryNo: x.inventoryNo,
sourceDonation: x.sourceDonation
? { publicId: x.sourceDonation.publicId, donationNo: x.sourceDonation.donationNo }
: null,
category: x.category,
itemName: x.itemName,
quantity: x.quantity,
unit: x.unit,
storageLocation: x.storageLocation,
status: x.status,
targetNeed: x.targetNeed,
beneficiaryPublicId: x.beneficiary?.publicId ?? null,
destinationRegion: x.destinationRegion,
note: x.noteEncrypted ? this.sensitive.decrypt(x.noteEncrypted) : null,
landingAt: x.landingAt?.toISOString() ?? null,
revision: x.revision,
createdAt: x.createdAt.toISOString(),
events: x.events.map((e) => ({
eventType: e.eventType,
fromStatus: e.fromStatus,
status: e.toStatus,
locationText: e.locationText,
note: e.noteEncrypted ? this.sensitive.decrypt(e.noteEncrypted) : null,
operatorName: e.operator.staffProfile?.displayName ?? e.operator.nickname ?? '工作人员',
eventAt: e.eventAt.toISOString(),
})),
};
}
private validateImportDuplicates(rows: OldItemInventoryImportRowDto[]) {
const numbers = rows.map((x) => x.sourceDonationNo).filter((x): x is string => Boolean(x));
if (new Set(numbers).size !== numbers.length)
throw this.validation('同一导入文件不能重复绑定来源捐赠编号');
}
private eventAt(value?: string) {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime()) || date.getTime() > Date.now() + 300_000)
throw this.validation('流转时间无效或晚于当前时间');
return date;
}
private inventoryNo() {
return `INV-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}-${randomBytes(5).toString('hex').toUpperCase()}`;
}
private derivedRequestId(requestId: string, index: number) {
const hex = createHash('sha256').update(`${requestId}:${index}`).digest('hex').slice(0, 32);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
private fingerprint(domain: string, value: object) {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private validation(message: string) {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private duplicate() {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
private conflict() {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'台账已被更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private invalid() {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
'当前台账状态不能执行该操作',
HttpStatus.CONFLICT,
);
}
}
@@ -0,0 +1,658 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
OLD_ITEM_CATEGORY_LABELS,
type OldItemCertificateItem,
type OldItemPointAccountItem,
type OldItemPointEntryItem,
type OldItemRewardConfig,
} 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 { OldItemCategory, Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
ActivateOldItemRewardConfigDto,
AdjustOldItemPointsDto,
IssueOldItemCertificateDto,
OldItemPointAccountQueryDto,
RevokeOldItemCertificateDto,
} from './dto/old-items.dto';
const DEFAULT_CONFIG: OldItemRewardConfig = {
publicId: null,
version: 0,
certificateTitle: '爱心捐赠纪念证书',
certificateMessage:
'感谢 {recipient} 完成公益编号 {donationNo} 的旧物捐赠,共捐赠 {quantity} 件{category}物资。您的善意让闲置物品重新拥有温度。',
issuerName: '乐活公益',
legalNotice: '本证书仅用于记录公益参与,不作为捐赠收据、税务凭证或任何财产权利证明。',
clothingPoints: 1,
booksPoints: 1,
householdPoints: 1,
sportsPoints: 1,
maxPointsPerDonation: 500,
createdAt: null,
};
const ALLOWED_PLACEHOLDERS = new Set([
'recipient',
'donationNo',
'quantity',
'category',
'completedDate',
]);
const CERTIFICATE_INCLUDE = { donation: true } satisfies Prisma.OldItemCertificateInclude;
type CertificateWithDonation = Prisma.OldItemCertificateGetPayload<{
include: typeof CERTIFICATE_INCLUDE;
}>;
@Injectable()
export class OldItemRewardsService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
) {}
async config() {
const active = await this.prisma.oldItemRewardConfig.findUnique({
where: { activeKey: 'ACTIVE' },
});
return { config: active ? this.mapConfig(active) : DEFAULT_CONFIG, usesDefault: !active };
}
async activateConfig(
input: ActivateOldItemRewardConfigDto,
actor: AuthContext,
req: RequestContext,
) {
this.validateTemplate(input.certificateMessage);
const fp = this.fingerprint('old-item-reward-config', input);
const replay = await this.prisma.oldItemRewardConfig.findUnique({
where: { clientRequestId: input.clientRequestId },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { config: this.mapConfig(replay), idempotentReplay: true };
}
const config = await this.prisma.$transaction(async (tx) => {
const latest = await tx.oldItemRewardConfig.aggregate({ _max: { version: true } });
await tx.oldItemRewardConfig.updateMany({
where: { activeKey: 'ACTIVE' },
data: { activeKey: null },
});
const created = await tx.oldItemRewardConfig.create({
data: {
publicId: randomUUID(),
clientRequestId: input.clientRequestId,
requestFingerprint: fp,
version: (latest._max.version ?? 0) + 1,
activeKey: 'ACTIVE',
certificateTitle: input.certificateTitle.trim(),
certificateMessage: input.certificateMessage.trim(),
issuerName: input.issuerName.trim(),
legalNotice: input.legalNotice.trim(),
clothingPoints: input.clothingPoints,
booksPoints: input.booksPoints,
householdPoints: input.householdPoints,
sportsPoints: input.sportsPoints,
maxPointsPerDonation: input.maxPointsPerDonation,
createdById: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_REWARD_CONFIG_ACTIVATED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_REWARD_CONFIG',
resourcePublicId: created.publicId,
metadata: { version: created.version },
},
tx,
);
return created;
});
return { config: this.mapConfig(config), idempotentReplay: false };
}
async grantOnCompletion(
tx: Prisma.TransactionClient,
donationId: bigint,
completionRequestId: string,
completedAt: Date,
actor: AuthContext,
req: RequestContext,
) {
const donation = await tx.oldItemDonation.findUniqueOrThrow({
where: { id: donationId },
include: { donor: true },
});
const config = await tx.oldItemRewardConfig.findUnique({ where: { activeKey: 'ACTIVE' } });
const view = config ? this.mapConfig(config) : DEFAULT_CONFIG;
const activeCertificate = await tx.oldItemCertificate.findFirst({
where: { donationId, activeKey: 'ACTIVE' },
});
if (!activeCertificate) {
const certificate = await this.createCertificate(
tx,
donation,
config,
view,
this.derivedRequestId(completionRequestId, 'certificate'),
actor.userId,
completedAt,
);
await this.audit.record(
req,
{
action: 'OLD_ITEM_CERTIFICATE_ISSUED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_CERTIFICATE',
resourcePublicId: certificate.publicId,
metadata: { source: 'DONATION_COMPLETED' },
},
tx,
);
}
const sourceKey = `DONATION:${donation.publicId}`;
const existingPoints = await tx.oldItemPointEntry.findUnique({ where: { sourceKey } });
const points = this.points(view, donation.category, donation.quantity);
if (!existingPoints && points > 0) {
const account = await this.lockAccount(tx, donation.donorUserId);
const balanceAfter = account.balance + points;
await tx.oldItemPointAccount.update({
where: { id: account.id },
data: { balance: balanceAfter, revision: { increment: 1 } },
});
const entry = await tx.oldItemPointEntry.create({
data: {
publicId: randomUUID(),
accountId: account.id,
userId: donation.donorUserId,
donationId,
configId: config?.id,
requestId: this.derivedRequestId(completionRequestId, 'points'),
requestFingerprint: this.fingerprint('old-item-points-earned', {
donationPublicId: donation.publicId,
points,
}),
sourceKey,
entryType: 'EARNED',
pointsDelta: points,
balanceAfter,
noteEncrypted: this.sensitive.encrypt('旧物捐赠完成自动获得公益荣誉积分'),
operatorUserId: actor.userId,
createdAt: completedAt,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_POINTS_EARNED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_POINT_ENTRY',
resourcePublicId: entry.publicId,
metadata: { points, source: 'DONATION_COMPLETED' },
},
tx,
);
}
}
async myRewards(actor: AuthContext) {
const [account, certificates, entries] = await this.prisma.$transaction([
this.prisma.oldItemPointAccount.findUnique({ where: { userId: actor.userId } }),
this.prisma.oldItemCertificate.findMany({
where: { donation: { donorUserId: actor.userId } },
include: CERTIFICATE_INCLUDE,
orderBy: { issuedAt: 'desc' },
take: 50,
}),
this.prisma.oldItemPointEntry.findMany({
where: { userId: actor.userId },
include: { donation: { select: { donationNo: true } } },
orderBy: { createdAt: 'desc' },
take: 100,
}),
]);
return {
balance: account?.balance ?? 0,
certificates: certificates.map((x) => this.mapCertificate(x, true)),
entries: entries.map((x) => this.mapPointEntry(x, true)),
};
}
async myCertificate(publicId: string, actor: AuthContext) {
const certificate = await this.prisma.oldItemCertificate.findFirst({
where: { publicId, donation: { donorUserId: actor.userId } },
include: CERTIFICATE_INCLUDE,
});
if (!certificate) throw this.notFound('未找到该电子证书');
return { certificate: this.mapCertificate(certificate, true) };
}
async accounts(q: OldItemPointAccountQueryDto) {
const where: Prisma.OldItemPointAccountWhereInput = q.keyword
? {
OR: [
{ publicId: { contains: q.keyword } },
{ user: { publicId: { contains: q.keyword } } },
{ user: { nickname: { contains: q.keyword } } },
],
}
: {};
const [total, accounts] = await this.prisma.$transaction([
this.prisma.oldItemPointAccount.count({ where }),
this.prisma.oldItemPointAccount.findMany({
where,
include: { user: true },
orderBy: { updatedAt: 'desc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return {
items: accounts.map((x): OldItemPointAccountItem => ({
publicId: x.publicId,
userPublicId: x.user.publicId,
displayName: x.user.nickname ?? '爱心用户',
balance: x.balance,
revision: x.revision,
updatedAt: x.updatedAt.toISOString(),
})),
page: q.page,
pageSize: q.pageSize,
total,
};
}
async certificates(q: OldItemPointAccountQueryDto) {
const where: Prisma.OldItemCertificateWhereInput = q.keyword
? {
OR: [
{ certificateNo: { contains: q.keyword } },
{ donation: { donationNo: { contains: q.keyword } } },
],
}
: {};
const [total, certificates] = await this.prisma.$transaction([
this.prisma.oldItemCertificate.count({ where }),
this.prisma.oldItemCertificate.findMany({
where,
include: CERTIFICATE_INCLUDE,
orderBy: { issuedAt: 'desc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return {
items: certificates.map((x) => this.mapCertificate(x, false)),
page: q.page,
pageSize: q.pageSize,
total,
};
}
async issue(
donationPublicId: string,
input: IssueOldItemCertificateDto,
actor: AuthContext,
req: RequestContext,
) {
const fp = this.fingerprint('old-item-certificate-issue', { donationPublicId, ...input });
const replay = await this.prisma.oldItemCertificate.findUnique({
where: { issueRequestId: input.clientRequestId },
include: CERTIFICATE_INCLUDE,
});
if (replay) {
if (replay.issueFingerprint !== fp) throw this.duplicate();
return { certificate: this.mapCertificate(replay, false), idempotentReplay: true };
}
const donation = await this.prisma.oldItemDonation.findUnique({
where: { publicId: donationPublicId },
include: { donor: true },
});
if (!donation) throw this.notFound('未找到该捐赠记录');
if (donation.status !== 'COMPLETED' || !donation.completedAt)
throw this.invalid('捐赠完成后才能补发证书');
const certificate = await this.prisma.$transaction(async (tx) => {
const config = await tx.oldItemRewardConfig.findUnique({ where: { activeKey: 'ACTIVE' } });
const active = await tx.oldItemCertificate.findFirst({
where: { donationId: donation.id, activeKey: 'ACTIVE' },
});
if (active) {
await tx.oldItemCertificate.update({
where: { id: active.id },
data: {
activeKey: null,
status: 'REVOKED',
revokedById: actor.userId,
revokedRequestId: this.derivedRequestId(input.clientRequestId, 'reissue-revoke'),
revokedFingerprint: fp,
revokeReasonEncrypted: this.sensitive.encrypt('因后台补发新证书自动撤销旧证书'),
revokedAt: new Date(),
},
});
}
const created = await this.createCertificate(
tx,
donation,
config,
config ? this.mapConfig(config) : DEFAULT_CONFIG,
input.clientRequestId,
actor.userId,
new Date(),
fp,
);
await this.audit.record(
req,
{
action: active ? 'OLD_ITEM_CERTIFICATE_REISSUED' : 'OLD_ITEM_CERTIFICATE_ISSUED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_CERTIFICATE',
resourcePublicId: created.publicId,
metadata: { donationPublicId, replacedCertificate: Boolean(active) },
},
tx,
);
return tx.oldItemCertificate.findUniqueOrThrow({
where: { id: created.id },
include: CERTIFICATE_INCLUDE,
});
});
return { certificate: this.mapCertificate(certificate, false), idempotentReplay: false };
}
async revoke(
publicId: string,
input: RevokeOldItemCertificateDto,
actor: AuthContext,
req: RequestContext,
) {
const fp = this.fingerprint('old-item-certificate-revoke', { publicId, ...input });
const replay = await this.prisma.oldItemCertificate.findUnique({
where: { revokedRequestId: input.clientRequestId },
include: CERTIFICATE_INCLUDE,
});
if (replay) {
if (replay.revokedFingerprint !== fp) throw this.duplicate();
return { certificate: this.mapCertificate(replay, false), idempotentReplay: true };
}
const current = await this.prisma.oldItemCertificate.findUnique({
where: { publicId },
include: CERTIFICATE_INCLUDE,
});
if (!current) throw this.notFound('未找到该电子证书');
if (current.status !== 'ISSUED') throw this.invalid('该证书已撤销');
const certificate = await this.prisma.$transaction(async (tx) => {
const changed = await tx.oldItemCertificate.updateMany({
where: { id: current.id, status: 'ISSUED' },
data: {
activeKey: null,
status: 'REVOKED',
revokedById: actor.userId,
revokedRequestId: input.clientRequestId,
revokedFingerprint: fp,
revokeReasonEncrypted: this.sensitive.encrypt(input.reason.trim()),
revokedAt: new Date(),
},
});
if (changed.count !== 1) throw this.conflict();
const updated = await tx.oldItemCertificate.findUniqueOrThrow({
where: { id: current.id },
include: CERTIFICATE_INCLUDE,
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_CERTIFICATE_REVOKED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_CERTIFICATE',
resourcePublicId: publicId,
},
tx,
);
return updated;
});
return { certificate: this.mapCertificate(certificate, false), idempotentReplay: false };
}
async adjust(
userPublicId: string,
input: AdjustOldItemPointsDto,
actor: AuthContext,
req: RequestContext,
) {
if (input.pointsDelta === 0) throw this.validation('积分调整值不能为 0');
const fp = this.fingerprint('old-item-points-adjust', { userPublicId, ...input });
const replay = await this.prisma.oldItemPointEntry.findUnique({
where: { requestId: input.clientRequestId },
include: { donation: { select: { donationNo: true } } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { entry: this.mapPointEntry(replay, false), idempotentReplay: true };
}
const user = await this.prisma.user.findUnique({ where: { publicId: userPublicId } });
if (!user) throw this.notFound('未找到该用户');
const entry = await this.prisma.$transaction(async (tx) => {
const account = await this.lockAccount(tx, user.id);
if (account.revision !== input.expectedRevision) throw this.conflict();
const balanceAfter = account.balance + input.pointsDelta;
if (balanceAfter < 0) throw this.validation('扣减后积分不能小于 0');
const changed = await tx.oldItemPointAccount.updateMany({
where: { id: account.id, revision: input.expectedRevision },
data: { balance: balanceAfter, revision: { increment: 1 } },
});
if (changed.count !== 1) throw this.conflict();
const created = await tx.oldItemPointEntry.create({
data: {
publicId: randomUUID(),
accountId: account.id,
userId: user.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
sourceKey: `ADJUSTMENT:${input.clientRequestId}`,
entryType: 'ADJUSTMENT',
pointsDelta: input.pointsDelta,
balanceAfter,
noteEncrypted: this.sensitive.encrypt(input.reason.trim()),
operatorUserId: actor.userId,
},
include: { donation: { select: { donationNo: true } } },
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_POINTS_ADJUSTED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_POINT_ENTRY',
resourcePublicId: created.publicId,
metadata: { pointsDelta: input.pointsDelta },
},
tx,
);
return created;
});
return { entry: this.mapPointEntry(entry, false), idempotentReplay: false };
}
private async createCertificate(
tx: Prisma.TransactionClient,
donation: Prisma.OldItemDonationGetPayload<{ include: { donor: true } }>,
config: Prisma.OldItemRewardConfigGetPayload<object> | null,
view: OldItemRewardConfig,
requestId: string,
actorId: bigint,
issuedAt: Date,
fingerprint?: string,
) {
const recipient = donation.donor.nickname?.trim() || '爱心用户';
return tx.oldItemCertificate.create({
data: {
publicId: randomUUID(),
certificateNo: this.certificateNo(),
donationId: donation.id,
configId: config?.id,
issueRequestId: requestId,
issueFingerprint:
fingerprint ??
this.fingerprint('old-item-certificate-auto', {
donationPublicId: donation.publicId,
requestId,
}),
activeKey: 'ACTIVE',
recipientNameEncrypted: this.sensitive.encrypt(recipient),
titleSnapshot: view.certificateTitle,
messageSnapshot: this.render(view.certificateMessage, {
recipient,
donationNo: donation.donationNo,
quantity: String(donation.quantity),
category: OLD_ITEM_CATEGORY_LABELS[donation.category],
completedDate: (donation.completedAt ?? issuedAt).toISOString().slice(0, 10),
}),
issuerSnapshot: view.issuerName,
legalNoticeSnapshot: view.legalNotice,
issuedById: actorId,
issuedAt,
},
});
}
private async lockAccount(tx: Prisma.TransactionClient, userId: bigint) {
await tx.oldItemPointAccount.upsert({
where: { userId },
update: {},
create: { publicId: randomUUID(), userId },
});
await tx.$queryRaw`SELECT id FROM old_item_point_accounts WHERE user_id = ${userId} FOR UPDATE`;
return tx.oldItemPointAccount.findUniqueOrThrow({ where: { userId } });
}
private points(config: OldItemRewardConfig, category: OldItemCategory, quantity: number) {
const perItem = {
CLOTHING: config.clothingPoints,
BOOKS: config.booksPoints,
HOUSEHOLD: config.householdPoints,
SPORTS: config.sportsPoints,
}[category];
return Math.min(config.maxPointsPerDonation, perItem * quantity);
}
private mapConfig(x: Prisma.OldItemRewardConfigGetPayload<object>): OldItemRewardConfig {
return {
publicId: x.publicId,
version: x.version,
certificateTitle: x.certificateTitle,
certificateMessage: x.certificateMessage,
issuerName: x.issuerName,
legalNotice: x.legalNotice,
clothingPoints: x.clothingPoints,
booksPoints: x.booksPoints,
householdPoints: x.householdPoints,
sportsPoints: x.sportsPoints,
maxPointsPerDonation: x.maxPointsPerDonation,
createdAt: x.createdAt.toISOString(),
};
}
private mapCertificate(
x: CertificateWithDonation,
revealRecipient: boolean,
): OldItemCertificateItem {
return {
publicId: x.publicId,
certificateNo: x.certificateNo,
donationPublicId: x.donation.publicId,
donationNo: x.donation.donationNo,
status: x.status,
recipientName: revealRecipient
? this.sensitive.decrypt(x.recipientNameEncrypted)
: '爱心用户',
title: x.titleSnapshot,
message: x.messageSnapshot,
issuerName: x.issuerSnapshot,
legalNotice: x.legalNoticeSnapshot,
issuedAt: x.issuedAt.toISOString(),
revokedAt: x.revokedAt?.toISOString() ?? null,
revokeReason:
revealRecipient && x.revokeReasonEncrypted
? this.sensitive.decrypt(x.revokeReasonEncrypted)
: null,
};
}
private mapPointEntry(
x: Prisma.OldItemPointEntryGetPayload<{
include: { donation: { select: { donationNo: true } } };
}>,
revealNote: boolean,
): OldItemPointEntryItem {
return {
publicId: x.publicId,
entryType: x.entryType,
pointsDelta: x.pointsDelta,
balanceAfter: x.balanceAfter,
donationNo: x.donation?.donationNo ?? null,
note: revealNote && x.noteEncrypted ? this.sensitive.decrypt(x.noteEncrypted) : null,
createdAt: x.createdAt.toISOString(),
};
}
private validateTemplate(template: string) {
for (const match of template.matchAll(/\{([^{}]+)\}/g)) {
if (!ALLOWED_PLACEHOLDERS.has(match[1]!))
throw this.validation(`证书模板包含不支持的占位符:{${match[1]}}`);
}
}
private render(template: string, values: Record<string, string>) {
return template.replace(/\{([^{}]+)\}/g, (all, key: string) => values[key] ?? all);
}
private certificateNo() {
return `CERT-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}-${randomBytes(6).toString('hex').toUpperCase()}`;
}
private derivedRequestId(requestId: string, domain: string) {
const hex = createHash('sha256').update(`${domain}:${requestId}`).digest('hex').slice(0, 32);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
private fingerprint(domain: string, value: object) {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private validation(message: string) {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, message, HttpStatus.BAD_REQUEST);
}
private duplicate() {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
private conflict() {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'积分账户已更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private invalid(message: string) {
return new ApplicationError(
API_ERROR_CODES.INVALID_STATE_TRANSITION,
message,
HttpStatus.CONFLICT,
);
}
private notFound(message: string) {
return new ApplicationError(API_ERROR_CODES.RESOURCE_NOT_FOUND, message, HttpStatus.NOT_FOUND);
}
}
@@ -0,0 +1,331 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Patch,
Post,
Query,
Req,
ValidationPipe,
HttpStatus,
} from '@nestjs/common';
import {
API_ERROR_CODES,
PERMISSION_CODES,
type ConfirmUploadResponse,
type CreateUploadIntentResponse,
} from '@lehuo/contracts';
import { RequireSessionChannel } from '../auth/decorators/require-session-channel.decorator';
import { CurrentAuth } from '../common/decorators/current-auth.decorator';
import { ApplicationError } from '../common/application-error';
import { Public } from '../common/decorators/public.decorator';
import { RequirePermissions } from '../common/decorators/require-permissions.decorator';
import type { AuthContext, RequestContext } from '../common/request-context';
import { SessionChannel } from '../generated/prisma/enums';
import { MediaService } from '../participation/media.service';
import {
CreateOldItemDonationDto,
CreateOldItemMediaUploadIntentDto,
CreateOldItemDropOffPointDto,
CreateOldItemNeedDto,
OldItemDonationQueryDto,
OldItemNeedQueryDto,
TransitionOldItemDonationDto,
TransitionOldItemNeedDto,
UpdateOldItemNeedDto,
CreateOldItemInventoryDto,
ImportOldItemInventoryDto,
OldItemInventoryQueryDto,
TransitionOldItemInventoryDto,
ActivateOldItemRewardConfigDto,
AdjustOldItemPointsDto,
IssueOldItemCertificateDto,
OldItemPointAccountQueryDto,
RevokeOldItemCertificateDto,
} from './dto/old-items.dto';
import { OldItemInventoryService } from './old-item-inventory.service';
import { OldItemRewardsService } from './old-item-rewards.service';
import { OldItemsService } from './old-items.service';
const strict = (expectedType: new () => object) =>
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
expectedType,
});
@Controller('api/v1/old-items')
export class OldItemsController {
constructor(
@Inject(OldItemsService) private readonly service: OldItemsService,
@Inject(MediaService) private readonly media: MediaService,
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
) {}
@Get('stats') @Public() stats() {
return this.service.stats();
}
@Get('needs') @Public() needs(@Query(strict(OldItemNeedQueryDto)) q: OldItemNeedQueryDto) {
return this.service.listNeeds(q);
}
@Get('needs/:publicId') @Public() need(@Param('publicId') id: string) {
return this.service.getNeed(id);
}
@Get('drop-off-points') @Public() points() {
return this.service.dropOffPoints();
}
@Post('media/upload-intents') @RequireSessionChannel(SessionChannel.WECHAT) mediaIntent(
@Body(strict(CreateOldItemMediaUploadIntentDto)) input: CreateOldItemMediaUploadIntentDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
): Promise<CreateUploadIntentResponse> {
if (input.businessType !== 'OLD_ITEM_DONATION_IMAGE')
throw new ApplicationError(
API_ERROR_CODES.VALIDATION_ERROR,
'图片业务类型无效',
HttpStatus.BAD_REQUEST,
);
return this.media.createIntent(input, actor, req);
}
@Post('media/:publicId/confirm') @RequireSessionChannel(SessionChannel.WECHAT) confirm(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
): Promise<ConfirmUploadResponse> {
return this.media.confirm(id, actor, req);
}
@Post('donations') @RequireSessionChannel(SessionChannel.WECHAT) create(
@Body(strict(CreateOldItemDonationDto)) input: CreateOldItemDonationDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.createDonation(input, actor, req);
}
@Get('me/donations') @RequireSessionChannel(SessionChannel.WECHAT) mine(
@Query(strict(OldItemDonationQueryDto)) q: OldItemDonationQueryDto,
@CurrentAuth() actor: AuthContext,
) {
return this.service.myDonations(q, actor);
}
@Get('me/donations/:publicId') @RequireSessionChannel(SessionChannel.WECHAT) mineOne(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
) {
return this.service.myDonation(id, actor);
}
@Get('me/media/:publicId/download')
@RequireSessionChannel(SessionChannel.WECHAT)
image(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.imageDownload(id, actor, req);
}
@Get('me/rewards') @RequireSessionChannel(SessionChannel.WECHAT) myRewards(
@CurrentAuth() actor: AuthContext,
) {
return this.rewards.myRewards(actor);
}
@Get('me/certificates/:publicId')
@RequireSessionChannel(SessionChannel.WECHAT)
myCertificate(@Param('publicId') id: string, @CurrentAuth() actor: AuthContext) {
return this.rewards.myCertificate(id, actor);
}
}
@Controller('api/v1/admin/old-items')
@RequireSessionChannel(SessionChannel.ADMIN)
export class AdminOldItemsController {
constructor(
@Inject(OldItemsService) private readonly service: OldItemsService,
@Inject(OldItemInventoryService) private readonly inventory: OldItemInventoryService,
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
) {}
@Get('assignees') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) assignees() {
return this.service.assignees();
}
@Get('media/:publicId/download') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) image(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.imageDownload(id, actor, req, true);
}
@Get('donations') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) donations(
@Query(strict(OldItemDonationQueryDto)) q: OldItemDonationQueryDto,
) {
return this.service.adminDonations(q);
}
@Get('donations/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) donation(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.adminDonation(id, actor, req);
}
@Post('donations/:publicId/transition')
@RequirePermissions(PERMISSION_CODES.OLD_ITEMS_MANAGE)
transition(
@Param('publicId') id: string,
@Body(strict(TransitionOldItemDonationDto)) input: TransitionOldItemDonationDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.transitionDonation(id, input, actor, req);
}
@Get('needs') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) needs(
@Query(strict(OldItemNeedQueryDto)) q: OldItemNeedQueryDto,
) {
return this.service.listNeeds(q, true);
}
@Get('needs/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_READ) need(
@Param('publicId') id: string,
) {
return this.service.getNeed(id, true);
}
@Post('needs') @RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE) createNeed(
@Body(strict(CreateOldItemNeedDto)) input: CreateOldItemNeedDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.createNeed(input, actor, req);
}
@Patch('needs/:publicId') @RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE) updateNeed(
@Param('publicId') id: string,
@Body(strict(UpdateOldItemNeedDto)) input: UpdateOldItemNeedDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.updateNeed(id, input, actor, req);
}
@Post('needs/:publicId/transition')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_NEEDS_MANAGE)
transitionNeed(
@Param('publicId') id: string,
@Body(strict(TransitionOldItemNeedDto)) input: TransitionOldItemNeedDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.transitionNeed(id, input, actor, req);
}
@Post('drop-off-points') @RequirePermissions(PERMISSION_CODES.OLD_ITEMS_MANAGE) createPoint(
@Body(strict(CreateOldItemDropOffPointDto)) input: CreateOldItemDropOffPointDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.service.createDropOffPoint(input, actor, req);
}
@Get('inventory')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_READ)
inventoryList(@Query(strict(OldItemInventoryQueryDto)) q: OldItemInventoryQueryDto) {
return this.inventory.list(q);
}
@Get('inventory/:publicId')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_READ)
inventoryOne(
@Param('publicId') id: string,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.inventory.get(id, actor, req);
}
@Post('inventory')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_MANAGE)
inventoryCreate(
@Body(strict(CreateOldItemInventoryDto)) input: CreateOldItemInventoryDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.inventory.create(input, actor, req);
}
@Post('inventory/import')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_IMPORT)
inventoryImport(
@Body(strict(ImportOldItemInventoryDto)) input: ImportOldItemInventoryDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.inventory.import(input, actor, req);
}
@Post('inventory/:publicId/transition')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_MANAGE)
inventoryTransition(
@Param('publicId') id: string,
@Body(strict(TransitionOldItemInventoryDto)) input: TransitionOldItemInventoryDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.inventory.transition(id, input, actor, req);
}
@Get('inventory-export/file')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_INVENTORY_EXPORT)
inventoryExport(
@Query(strict(OldItemInventoryQueryDto)) q: OldItemInventoryQueryDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.inventory.export(q, actor, req);
}
@Get('reward-config')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
rewardConfig() {
return this.rewards.config();
}
@Post('reward-config/activate')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
activateRewardConfig(
@Body(strict(ActivateOldItemRewardConfigDto)) input: ActivateOldItemRewardConfigDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.rewards.activateConfig(input, actor, req);
}
@Get('reward-accounts')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
rewardAccounts(@Query(strict(OldItemPointAccountQueryDto)) q: OldItemPointAccountQueryDto) {
return this.rewards.accounts(q);
}
@Post('reward-accounts/:userPublicId/adjust')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
adjustRewardPoints(
@Param('userPublicId') userPublicId: string,
@Body(strict(AdjustOldItemPointsDto)) input: AdjustOldItemPointsDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.rewards.adjust(userPublicId, input, actor, req);
}
@Get('certificates')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_READ)
certificates(@Query(strict(OldItemPointAccountQueryDto)) q: OldItemPointAccountQueryDto) {
return this.rewards.certificates(q);
}
@Post('donations/:publicId/certificate')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
issueCertificate(
@Param('publicId') publicId: string,
@Body(strict(IssueOldItemCertificateDto)) input: IssueOldItemCertificateDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.rewards.issue(publicId, input, actor, req);
}
@Post('certificates/:publicId/revoke')
@RequirePermissions(PERMISSION_CODES.OLD_ITEM_REWARDS_MANAGE)
revokeCertificate(
@Param('publicId') publicId: string,
@Body(strict(RevokeOldItemCertificateDto)) input: RevokeOldItemCertificateDto,
@CurrentAuth() actor: AuthContext,
@Req() req: RequestContext,
) {
return this.rewards.revoke(publicId, input, actor, req);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { ParticipationModule } from '../participation/participation.module';
import { FilesModule } from '../files/files.module';
import { AdminOldItemsController, OldItemsController } from './old-items.controller';
import { OldItemsService } from './old-items.service';
import { OldItemInventoryService } from './old-item-inventory.service';
import { OldItemRewardsService } from './old-item-rewards.service';
@Module({
imports: [AuditModule, ParticipationModule, FilesModule],
controllers: [OldItemsController, AdminOldItemsController],
providers: [OldItemsService, OldItemInventoryService, OldItemRewardsService],
})
export class OldItemsModule {}
+763
View File
@@ -0,0 +1,763 @@
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
API_ERROR_CODES,
type OldItemDonationItem,
type OldItemNeedItem,
type OldItemStats,
} 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 { OBJECT_STORAGE_ADAPTER, type ObjectStorageAdapter } from '../files/object-storage.adapter';
import type { OldItemNeed, Prisma } from '../generated/prisma/client';
import { SensitiveDataService } from '../security/sensitive-data.service';
import type {
CreateOldItemDonationDto,
CreateOldItemDropOffPointDto,
CreateOldItemNeedDto,
OldItemDonationQueryDto,
OldItemNeedQueryDto,
TransitionOldItemDonationDto,
TransitionOldItemNeedDto,
UpdateOldItemNeedDto,
} from './dto/old-items.dto';
import { OldItemRewardsService } from './old-item-rewards.service';
const DONATION_TRANSITIONS: Record<string, string[]> = {
SUBMITTED: ['REJECTED', 'WAITING_PICKUP', 'WAITING_DROP_OFF', 'CANCELLED'],
WAITING_PICKUP: ['RECEIVED', 'CANCELLED'],
WAITING_DROP_OFF: ['RECEIVED', 'CANCELLED'],
RECEIVED: ['PROCESSING'],
PROCESSING: ['COMPLETED'],
REJECTED: [],
COMPLETED: [],
CANCELLED: [],
};
const NEED_TRANSITIONS: Record<string, string[]> = {
DRAFT: ['PUBLISHED', 'CLOSED'],
PUBLISHED: ['FULFILLED', 'CLOSED'],
FULFILLED: ['CLOSED'],
CLOSED: [],
};
const DONATION_INCLUDE = {
history: { orderBy: { createdAt: 'asc' as const } },
targetNeed: true,
dropOffPoint: true,
assignee: true,
donor: true,
files: { include: { file: true }, orderBy: { sortOrder: 'asc' as const } },
inventoryItems: {
include: { events: { orderBy: { eventAt: 'asc' as const } } },
orderBy: { createdAt: 'asc' as const },
},
} satisfies Prisma.OldItemDonationInclude;
type DonationWithRelations = Prisma.OldItemDonationGetPayload<{
include: typeof DONATION_INCLUDE;
}>;
@Injectable()
export class OldItemsService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SensitiveDataService) private readonly sensitive: SensitiveDataService,
@Inject(AuditService) private readonly audit: AuditService,
@Inject(OBJECT_STORAGE_ADAPTER) private readonly storage: ObjectStorageAdapter,
@Inject(OldItemRewardsService) private readonly rewards: OldItemRewardsService,
) {}
async assignees() {
const users = await this.prisma.user.findMany({
where: { userType: 'STAFF', status: 'ACTIVE' },
include: { staffProfile: true },
orderBy: { createdAt: 'asc' },
});
return {
items: users.map((user) => ({
publicId: user.publicId,
displayName: user.staffProfile?.displayName ?? user.nickname ?? '工作人员',
})),
};
}
async imageDownload(
publicId: string,
actor: AuthContext,
request: RequestContext,
admin = false,
) {
if (!this.storage.configured)
throw new ApplicationError(
API_ERROR_CODES.STORAGE_UNAVAILABLE,
'图片服务尚未配置或暂时不可用',
HttpStatus.SERVICE_UNAVAILABLE,
);
const file = await this.prisma.file.findFirst({
where: { publicId, status: 'AVAILABLE', businessType: 'OLD_ITEM_DONATION_IMAGE' },
include: { oldItemDonations: { include: { donation: true } } },
});
const donation = file?.oldItemDonations[0]?.donation;
if (!file || !donation || (!admin && donation.donorUserId !== actor.userId))
throw this.notFound('未找到该捐赠图片');
const expiresIn = 300;
let downloadUrl: string;
try {
downloadUrl = await this.storage.createDownloadUrl(file.objectKey, expiresIn);
} catch {
throw new ApplicationError(
API_ERROR_CODES.STORAGE_UNAVAILABLE,
'图片服务暂时不可用',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
await this.audit.record(request, {
action: 'OLD_ITEM_PRIVATE_IMAGE_URL_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'FILE',
resourcePublicId: publicId,
});
return { downloadUrl, expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString() };
}
async stats(): Promise<OldItemStats> {
const month = new Date();
month.setDate(1);
month.setHours(0, 0, 0, 0);
const [items, people, acts] = await this.prisma.$transaction([
this.prisma.oldItemDonation.aggregate({
_sum: { quantity: true },
where: { status: { in: ['RECEIVED', 'PROCESSING', 'COMPLETED'] } },
}),
this.prisma.oldItemDonation.aggregate({
_sum: { assistedPeople: true },
where: { status: 'COMPLETED' },
}),
this.prisma.oldItemDonation.count({ where: { receivedAt: { gte: month } } }),
]);
return {
totalItems: items._sum.quantity ?? 0,
assistedPeople: people._sum.assistedPeople ?? 0,
monthlyKindActs: acts,
};
}
async dropOffPoints() {
return {
items: await this.prisma.oldItemDropOffPoint.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'desc' }, { createdAt: 'desc' }],
select: {
publicId: true,
name: true,
regionText: true,
addressText: true,
openingHours: true,
},
}),
};
}
async listNeeds(q: OldItemNeedQueryDto, admin = false) {
const where: Prisma.OldItemNeedWhereInput = {
...(admin ? {} : { status: 'PUBLISHED' }),
...(q.status ? { status: q.status as never } : {}),
...(q.category ? { category: q.category as never } : {}),
};
const [total, items] = await this.prisma.$transaction([
this.prisma.oldItemNeed.count({ where }),
this.prisma.oldItemNeed.findMany({
where,
orderBy: [{ priority: 'desc' }, { sortOrder: 'desc' }, { publishedAt: 'desc' }],
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return { items: items.map((x) => this.need(x)), page: q.page, pageSize: q.pageSize, total };
}
async getNeed(publicId: string, admin = false) {
const x = await this.prisma.oldItemNeed.findUnique({ where: { publicId } });
if (!x || (!admin && x.status !== 'PUBLISHED')) throw this.notFound('未找到该物资需求');
return { need: this.need(x) };
}
async createDonation(input: CreateOldItemDonationDto, actor: AuthContext, req: RequestContext) {
this.validateMethod(input);
const fp = this.fingerprint('old-item-donation-create', input);
const replay = await this.prisma.oldItemDonation.findUnique({
where: {
donorUserId_clientRequestId: {
donorUserId: actor.userId,
clientRequestId: input.clientRequestId,
},
},
include: DONATION_INCLUDE,
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { donation: this.mapDonation(replay, true), idempotentReplay: true };
}
const [point, need, files] = await Promise.all([
input.dropOffPointPublicId
? this.prisma.oldItemDropOffPoint.findFirst({
where: { publicId: input.dropOffPointPublicId, isActive: true },
})
: null,
input.targetNeedPublicId
? this.prisma.oldItemNeed.findFirst({
where: { publicId: input.targetNeedPublicId, status: 'PUBLISHED' },
})
: null,
input.imagePublicIds?.length
? this.prisma.file.findMany({
where: {
publicId: { in: input.imagePublicIds },
uploaderUserId: actor.userId,
status: 'AVAILABLE',
businessType: 'OLD_ITEM_DONATION_IMAGE',
caseId: null,
},
})
: [],
]);
if (input.method === 'DROP_OFF' && !point) throw this.validation('请选择有效的定点投递地址');
if (input.targetNeedPublicId && !need) throw this.validation('定向需求已下架或不存在');
if ((input.imagePublicIds?.length ?? 0) !== files.length || files.length > 6)
throw this.validation('捐赠图片无效、未确认或超过 6 张');
const created = await this.prisma.$transaction(async (tx) => {
const donation = await tx.oldItemDonation.create({
data: {
publicId: randomUUID(),
donationNo: this.donationNo(),
donorUserId: actor.userId,
clientRequestId: input.clientRequestId,
requestFingerprint: fp,
category: input.category as never,
itemCondition: input.itemCondition as never,
quantity: input.quantity,
descriptionEncrypted: this.sensitive.encrypt(input.description.trim()),
method: input.method,
contactNameEncrypted: this.sensitive.encrypt(input.contactName.trim()),
mobileEncrypted: this.sensitive.encrypt(input.mobile),
mobileHash: this.sensitive.lookupHash('old-item-mobile', input.mobile),
addressEncrypted: input.address ? this.sensitive.encrypt(input.address.trim()) : null,
appointmentAt: input.appointmentAt ? new Date(input.appointmentAt) : null,
dropOffPointId: point?.id,
targetNeedId: need?.id,
files: { create: files.map((f, i) => ({ fileId: f.id, sortOrder: i })) },
},
});
await tx.oldItemDonationStatusHistory.create({
data: {
donationId: donation.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
toStatus: 'SUBMITTED',
operatorUserId: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_DONATION_SUBMITTED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_DONATION',
resourcePublicId: donation.publicId,
metadata: { category: input.category, method: input.method, quantity: input.quantity },
},
tx,
);
return tx.oldItemDonation.findUniqueOrThrow({
where: { id: donation.id },
include: DONATION_INCLUDE,
});
});
return { donation: this.mapDonation(created, true), idempotentReplay: false };
}
async myDonations(q: OldItemDonationQueryDto, actor: AuthContext) {
return this.listDonations({ ...q }, { donorUserId: actor.userId }, true);
}
async myDonation(publicId: string, actor: AuthContext) {
const x = await this.findDonation(publicId);
if (x.donorUserId !== actor.userId) throw this.notFound('未找到该捐赠记录');
return { donation: this.mapDonation(x, true) };
}
async adminDonations(q: OldItemDonationQueryDto) {
const where: Prisma.OldItemDonationWhereInput = {
...(q.status ? { status: q.status as never } : {}),
...(q.method ? { method: q.method as never } : {}),
...(q.keyword
? {
OR: [
{ donationNo: { contains: q.keyword } },
{ donor: { nickname: { contains: q.keyword } } },
],
}
: {}),
};
return this.listDonations(q, where, false);
}
async adminDonation(publicId: string, actor: AuthContext, req: RequestContext) {
const x = await this.findDonation(publicId);
await this.audit.record(req, {
action: 'OLD_ITEM_DONATION_SENSITIVE_VIEWED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_DONATION',
resourcePublicId: publicId,
});
return { donation: this.mapDonation(x, true) };
}
async transitionDonation(
publicId: string,
input: TransitionOldItemDonationDto,
actor: AuthContext,
req: RequestContext,
) {
const current = await this.findDonation(publicId),
fp = this.fingerprint('old-item-donation-transition', { publicId, ...input });
const replay = await this.prisma.oldItemDonationStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { donation: { include: DONATION_INCLUDE } },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { donation: this.mapDonation(replay.donation, true), idempotentReplay: true };
}
if (current.revision !== input.expectedRevision) throw this.conflict();
if (!DONATION_TRANSITIONS[current.status]?.includes(input.targetStatus))
throw this.invalid('当前捐赠状态不能执行该操作');
if (input.targetStatus === 'WAITING_PICKUP' && current.method !== 'PICKUP')
throw this.invalid('投递订单不能进入待取件');
if (input.targetStatus === 'WAITING_DROP_OFF' && current.method !== 'DROP_OFF')
throw this.invalid('取件订单不能进入待投递');
if (input.targetStatus === 'REJECTED' && !input.note)
throw this.validation('驳回时必须填写原因');
const assignee = input.assignedUserPublicId
? await this.prisma.user.findFirst({
where: { publicId: input.assignedUserPublicId, userType: 'STAFF', status: 'ACTIVE' },
})
: null;
if (input.assignedUserPublicId && !assignee) throw this.validation('分配人员无效');
if (input.targetStatus === 'WAITING_PICKUP' && !assignee && !current.assignedUserId)
throw this.validation('上门取件审核通过时必须分配工作人员');
const updated = await this.prisma.$transaction(async (tx) => {
const transitionedAt = new Date();
const result = await tx.oldItemDonation.updateMany({
where: { id: current.id, revision: input.expectedRevision, status: current.status },
data: {
status: input.targetStatus as never,
revision: { increment: 1 },
...(assignee ? { assignedUserId: assignee.id } : {}),
...(input.targetStatus === 'REJECTED'
? { rejectionReasonEncrypted: this.sensitive.encrypt(input.note!) }
: {}),
...(input.targetStatus === 'RECEIVED' ? { receivedAt: transitionedAt } : {}),
...(input.targetStatus === 'COMPLETED'
? { completedAt: transitionedAt, assistedPeople: input.assistedPeople ?? 0 }
: {}),
},
});
if (result.count !== 1) throw this.conflict();
await tx.oldItemDonationStatusHistory.create({
data: {
donationId: current.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
fromStatus: current.status,
toStatus: input.targetStatus as never,
noteEncrypted: input.note ? this.sensitive.encrypt(input.note) : null,
operatorUserId: actor.userId,
},
});
if (input.targetStatus === 'COMPLETED' && current.targetNeedId) {
const need = await tx.oldItemNeed.findUniqueOrThrow({
where: { id: current.targetNeedId },
});
const fulfilled = Math.min(
need.requestedQuantity,
need.fulfilledQuantity + current.quantity,
);
await tx.oldItemNeed.update({
where: { id: need.id },
data: {
fulfilledQuantity: fulfilled,
...(fulfilled >= need.requestedQuantity && need.status === 'PUBLISHED'
? { status: 'FULFILLED', revision: { increment: 1 } }
: {}),
},
});
}
if (input.targetStatus === 'COMPLETED') {
await this.rewards.grantOnCompletion(
tx,
current.id,
input.clientRequestId,
transitionedAt,
actor,
req,
);
}
await this.audit.record(
req,
{
action: 'OLD_ITEM_DONATION_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_DONATION',
resourcePublicId: publicId,
metadata: { fromStatus: current.status, toStatus: input.targetStatus },
},
tx,
);
return tx.oldItemDonation.findUniqueOrThrow({
where: { id: current.id },
include: DONATION_INCLUDE,
});
});
return { donation: this.mapDonation(updated, true), idempotentReplay: false };
}
async createNeed(input: CreateOldItemNeedDto, actor: AuthContext, req: RequestContext) {
const fp = this.fingerprint('old-item-need-create', input);
const replay = await this.prisma.oldItemNeed.findUnique({
where: {
createdById_clientRequestId: {
createdById: actor.userId,
clientRequestId: input.clientRequestId,
},
},
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { need: this.need(replay), idempotentReplay: true };
}
const x = await this.prisma.$transaction(async (tx) => {
const n = await tx.oldItemNeed.create({
data: {
publicId: randomUUID(),
clientRequestId: input.clientRequestId,
requestFingerprint: fp,
title: input.title.trim(),
category: input.category as never,
regionText: input.regionText.trim(),
beneficiaryGroup: input.beneficiaryGroup.trim(),
description: input.description.trim(),
requestedQuantity: input.requestedQuantity,
priority: input.priority ?? 0,
sortOrder: input.sortOrder ?? 0,
createdById: actor.userId,
updatedById: actor.userId,
},
});
await tx.oldItemNeedStatusHistory.create({
data: {
needId: n.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
toStatus: 'DRAFT',
operatorUserId: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_NEED_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_NEED',
resourcePublicId: n.publicId,
},
tx,
);
return n;
});
return { need: this.need(x), idempotentReplay: false };
}
async updateNeed(
publicId: string,
input: UpdateOldItemNeedDto,
actor: AuthContext,
req: RequestContext,
) {
const n = await this.findNeed(publicId);
if (n.status !== 'DRAFT' || n.revision !== input.expectedRevision) throw this.conflict();
const x = await this.prisma.$transaction(async (tx) => {
const r = await tx.oldItemNeed.updateMany({
where: { id: n.id, status: 'DRAFT', revision: input.expectedRevision },
data: {
title: input.title.trim(),
category: input.category as never,
regionText: input.regionText.trim(),
beneficiaryGroup: input.beneficiaryGroup.trim(),
description: input.description.trim(),
requestedQuantity: input.requestedQuantity,
priority: input.priority ?? 0,
sortOrder: input.sortOrder ?? 0,
updatedById: actor.userId,
revision: { increment: 1 },
},
});
if (r.count !== 1) throw this.conflict();
await this.audit.record(
req,
{
action: 'OLD_ITEM_NEED_UPDATED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_NEED',
resourcePublicId: publicId,
},
tx,
);
return tx.oldItemNeed.findUniqueOrThrow({ where: { id: n.id } });
});
return { need: this.need(x) };
}
async transitionNeed(
publicId: string,
input: TransitionOldItemNeedDto,
actor: AuthContext,
req: RequestContext,
) {
const n = await this.findNeed(publicId),
fp = this.fingerprint('old-item-need-transition', { publicId, ...input });
const replay = await this.prisma.oldItemNeedStatusHistory.findUnique({
where: { requestId: input.clientRequestId },
include: { need: true },
});
if (replay) {
if (replay.requestFingerprint !== fp) throw this.duplicate();
return { need: this.need(replay.need), idempotentReplay: true };
}
if (
n.revision !== input.expectedRevision ||
!NEED_TRANSITIONS[n.status]?.includes(input.targetStatus)
)
throw this.invalid('当前需求状态不能执行该操作');
const x = await this.prisma.$transaction(async (tx) => {
const r = await tx.oldItemNeed.updateMany({
where: { id: n.id, status: n.status, revision: n.revision },
data: {
status: input.targetStatus as never,
updatedById: actor.userId,
revision: { increment: 1 },
...(input.targetStatus === 'PUBLISHED' ? { publishedAt: new Date() } : {}),
},
});
if (r.count !== 1) throw this.conflict();
await tx.oldItemNeedStatusHistory.create({
data: {
needId: n.id,
requestId: input.clientRequestId,
requestFingerprint: fp,
fromStatus: n.status,
toStatus: input.targetStatus as never,
operatorUserId: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_NEED_STATUS_CHANGED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_NEED',
resourcePublicId: publicId,
metadata: { fromStatus: n.status, toStatus: input.targetStatus },
},
tx,
);
return tx.oldItemNeed.findUniqueOrThrow({ where: { id: n.id } });
});
return { need: this.need(x), idempotentReplay: false };
}
async createDropOffPoint(
input: CreateOldItemDropOffPointDto,
actor: AuthContext,
req: RequestContext,
) {
const x = await this.prisma.$transaction(async (tx) => {
const p = await tx.oldItemDropOffPoint.create({
data: {
publicId: randomUUID(),
...input,
sortOrder: input.sortOrder ?? 0,
createdById: actor.userId,
},
});
await this.audit.record(
req,
{
action: 'OLD_ITEM_DROP_OFF_POINT_CREATED',
result: 'SUCCESS',
actor,
resourceType: 'OLD_ITEM_DROP_OFF_POINT',
resourcePublicId: p.publicId,
},
tx,
);
return p;
});
return {
point: {
publicId: x.publicId,
name: x.name,
regionText: x.regionText,
addressText: x.addressText,
openingHours: x.openingHours,
},
};
}
private async listDonations(
q: OldItemDonationQueryDto,
where: Prisma.OldItemDonationWhereInput,
sensitive: boolean,
) {
const [total, items] = await this.prisma.$transaction([
this.prisma.oldItemDonation.count({ where }),
this.prisma.oldItemDonation.findMany({
where,
include: DONATION_INCLUDE,
orderBy: { createdAt: 'desc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
]);
return {
items: items.map((x) => this.mapDonation(x, sensitive)),
page: q.page,
pageSize: q.pageSize,
total,
};
}
private async findDonation(publicId: string) {
const x = await this.prisma.oldItemDonation.findUnique({
where: { publicId },
include: DONATION_INCLUDE,
});
if (!x) throw this.notFound('未找到该捐赠记录');
return x;
}
private async findNeed(publicId: string) {
const x = await this.prisma.oldItemNeed.findUnique({ where: { publicId } });
if (!x) throw this.notFound('未找到该物资需求');
return x;
}
private need(n: OldItemNeed): OldItemNeedItem {
return {
publicId: n.publicId,
title: n.title,
category: n.category,
regionText: n.regionText,
beneficiaryGroup: n.beneficiaryGroup,
description: n.description,
requestedQuantity: n.requestedQuantity,
fulfilledQuantity: n.fulfilledQuantity,
remainingQuantity: Math.max(0, n.requestedQuantity - n.fulfilledQuantity),
priority: n.priority,
status: n.status,
revision: n.revision,
publishedAt: n.publishedAt?.toISOString() ?? null,
};
}
private mapDonation(d: DonationWithRelations, sensitive: boolean): OldItemDonationItem {
return {
publicId: d.publicId,
donationNo: d.donationNo,
category: d.category,
itemCondition: d.itemCondition,
quantity: d.quantity,
description: sensitive ? this.sensitive.decrypt(d.descriptionEncrypted) : '物品捐赠',
method: d.method,
status: d.status,
targetNeed: d.targetNeed
? { publicId: d.targetNeed.publicId, title: d.targetNeed.title }
: null,
dropOffPoint: d.dropOffPoint
? {
publicId: d.dropOffPoint.publicId,
name: d.dropOffPoint.name,
regionText: d.dropOffPoint.regionText,
addressText: d.dropOffPoint.addressText,
openingHours: d.dropOffPoint.openingHours,
}
: null,
appointmentAt: d.appointmentAt?.toISOString() ?? null,
contactName: sensitive ? this.sensitive.decrypt(d.contactNameEncrypted) : '爱心用户',
mobileMasked: this.maskMobile(this.sensitive.decrypt(d.mobileEncrypted)),
address: sensitive && d.addressEncrypted ? this.sensitive.decrypt(d.addressEncrypted) : null,
assigneeName: d.assignee?.nickname ?? null,
rejectionReason:
sensitive && d.rejectionReasonEncrypted
? this.sensitive.decrypt(d.rejectionReasonEncrypted)
: null,
assistedPeople: d.assistedPeople,
revision: d.revision,
createdAt: d.createdAt.toISOString(),
history: d.history.map((h) => ({
fromStatus: h.fromStatus,
toStatus: h.toStatus,
note: sensitive && h.noteEncrypted ? this.sensitive.decrypt(h.noteEncrypted) : null,
createdAt: h.createdAt.toISOString(),
})),
imagePublicIds: d.files.map((item) => item.file.publicId),
inventoryTrace: d.inventoryItems
.flatMap((item) =>
item.events.map((event) => ({
status: event.toStatus,
locationText: ['OUTBOUND', 'DELIVERED'].includes(event.toStatus)
? event.locationText
: null,
eventAt: event.eventAt.toISOString(),
})),
)
.sort((a, b) => a.eventAt.localeCompare(b.eventAt)),
};
}
private validateMethod(i: CreateOldItemDonationDto) {
if (i.method === 'PICKUP' && (!i.address || !i.appointmentAt))
throw this.validation('上门取件需填写地址和预约时间');
if (i.method === 'DROP_OFF' && !i.dropOffPointPublicId)
throw this.validation('定点投递需选择投递点');
if (i.appointmentAt && new Date(i.appointmentAt) <= new Date())
throw this.validation('预约时间必须晚于当前时间');
}
private fingerprint(domain: string, value: object) {
return this.sensitive.lookupHash(domain, JSON.stringify(value));
}
private donationNo() {
return `OI-${new Date().toISOString().slice(0, 7).replace('-', '')}-${randomUUID().slice(0, 8).toUpperCase()}`;
}
private maskMobile(v: string) {
return v.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
}
private validation(m: string) {
return new ApplicationError(API_ERROR_CODES.VALIDATION_ERROR, m, HttpStatus.BAD_REQUEST);
}
private notFound(m: string) {
return new ApplicationError(API_ERROR_CODES.RESOURCE_NOT_FOUND, m, HttpStatus.NOT_FOUND);
}
private invalid(m: string) {
return new ApplicationError(API_ERROR_CODES.INVALID_STATE_TRANSITION, m, HttpStatus.CONFLICT);
}
private conflict() {
return new ApplicationError(
API_ERROR_CODES.RESOURCE_CONFLICT,
'数据已更新,请刷新后重试',
HttpStatus.CONFLICT,
);
}
private duplicate() {
return new ApplicationError(
API_ERROR_CODES.DUPLICATE_REQUEST,
'请求标识已用于其他操作',
HttpStatus.CONFLICT,
);
}
}