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