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
+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,
);
}
}