import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { calcRedeemSettleAmount, generateRedeemNo, generateRedeemPendingNo, REDEEM_WEAKNET_FAIL_THRESHOLD, validateRedeemAmount, allocateBenefitCoupons, } from '@dukang/domain'; import { ClientApp, REDEEM_PENDING_SNAPSHOT_TTL_SECONDS, REDEEM_PHONE_SESSION_TTL_SECONDS, REDEEM_RESULT_TTL_SECONDS, REDEEM_TOKEN_TTL_SECONDS, SmsScene, } from '@dukang/shared-types'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { RedisService } from '../../common/redis/redis.service'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AnalyticsService } from '../analytics/analytics.service'; import { SettlementService } from '../settlement/settlement.service'; import { BenefitService } from '../benefit/benefit.service'; import { AuthService } from '../iam/auth.service'; import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service'; type TokenPayload = { userId: string; couponId?: string; amount: number; storeId?: string | null; allocations?: Array<{ couponId: string; amount: number }>; }; type PendingSnapshot = TokenPayload & { redeemType: 'DIRECT' | 'COUPON'; }; type RedeemResultPayload = { recordId: string; redeemNo: string; userId: string; amount: number; storeId: string; storeName: string; createdAt: string; }; type PhoneRedeemSession = { userId: string; phone: string; storeAccountId: string; storeId: string; amount?: number; allocations?: Array<{ couponId: string; amount: number }>; confirmPrepared?: boolean; }; @Injectable() export class RedeemService { constructor( private readonly prisma: PrismaService, private readonly redis: RedisService, private readonly settlementService: SettlementService, private readonly benefitService: BenefitService, private readonly analyticsService: AnalyticsService, private readonly authService: AuthService, private readonly payRedeemAnomaly: PayRedeemAnomalyService, ) {} private maskPhoneForStore(phone: string) { if (phone.length < 7) return phone; return `${phone.slice(0, 3)}****${phone.slice(-4)}`; } private normalizeMobilePhone(phone: string) { const normalized = String(phone ?? '').trim(); if (!/^1\d{10}$/.test(normalized)) { throw new BadRequestException('手机号格式无效'); } return normalized; } private phoneSessionKey(sessionId: string) { return `redeem:phone-session:${sessionId}`; } private async loadOpenStoreAccount(storeAccountId: bigint, storeId: bigint) { const binding = await this.prisma.storeAccountStore.findUnique({ where: { storeAccountId_storeId: { storeAccountId, storeId }, }, include: { storeAccount: true, store: true, }, }); if (!binding) throw new BadRequestException('无权访问该门店'); if (binding.storeAccount.status !== 'ACTIVE') { throw new BadRequestException('门店账号已停用'); } if (binding.store.status !== 'OPEN') { throw new BadRequestException('门店未营业'); } return { ...binding.storeAccount, storeId: binding.store.id, store: binding.store, }; } private async resolveUserByPhone(phone: string) { const user = await this.prisma.user.findFirst({ where: { phone, mergedIntoUserId: null, status: 1 }, select: { id: true, userNo: true, phone: true, nickname: true, phoneVerifiedAt: true }, }); if (!user) throw new NotFoundException('该手机号未注册好客用户'); if (!user.phoneVerifiedAt) throw new BadRequestException('用户手机号未验证,无法核销'); return user; } private async computeDirectAllocations(userId: bigint, amount: number) { const coupons = await this.prisma.benefitCoupon.findMany({ where: { userId, status: 'ACTIVE' }, orderBy: { createdAt: 'asc' }, }); const totalBalance = coupons.reduce((s, c) => s + Number(c.balance), 0); const result = allocateBenefitCoupons( coupons.map((c) => ({ id: c.id.toString(), balance: Number(c.balance), createdAt: c.createdAt.getTime(), })), amount, ); if (!result.ok) throw new BadRequestException(result.message); const check = validateRedeemAmount(totalBalance, amount); if (!check.ok) throw new BadRequestException(check.message); return { allocations: result.allocations, totalBalance }; } private async validateAllocations(allocations: Array<{ couponId: string; amount: number }>) { for (const alloc of allocations) { let couponId: bigint; try { couponId = BigInt(alloc.couponId); } catch { throw new BadRequestException('核销分摊数据异常'); } const coupon = await this.prisma.benefitCoupon.findUnique({ where: { id: couponId } }); if (!coupon) throw new BadRequestException('券不存在'); const check = validateRedeemAmount(Number(coupon.balance), alloc.amount, Number(coupon.balance)); if (!check.ok) throw new BadRequestException(check.message); } } private async executeRedeem( account: Awaited>, userId: bigint, amount: number, normalizedAllocations: Array<{ couponId: string; amount: number }>, analyticsExtra?: { channel: 'token' | 'phone'; sessionId?: string; tokenSuffix?: string }, ) { const settlementRate = Number(account.store.settlementRate); const settleAmount = calcRedeemSettleAmount(amount, settlementRate); let record; try { record = await this.prisma.$transaction(async (tx) => { await this.benefitService.deductCoupons(tx, normalizedAllocations, 'STORE', account.storeId); const redeemRecord = await tx.redeemRecord.create({ data: { redeemNo: generateRedeemNo(), userId, couponId: BigInt(normalizedAllocations[0].couponId), storeId: account.storeId, amount, settleAmount, allocations: { create: normalizedAllocations.map((item, index) => ({ couponId: BigInt(item.couponId), amount: item.amount, sortOrder: index, })), }, }, }); await this.settlementService.createStorePayout( redeemRecord.id, account.storeId, amount, settleAmount, settlementRate, tx, ); return redeemRecord; }); } catch (e) { if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') { throw new BadRequestException('核销失败,请重试'); } if (e instanceof Error && e.message === 'BENEFIT_ALLOC_INVALID') { throw new BadRequestException('核销分摊数据异常'); } throw e; } const redeemExtra = { redeemRecordId: record.id.toString(), storeId: account.storeId.toString(), amount, channel: analyticsExtra?.channel ?? 'token', ...(analyticsExtra?.sessionId ? { sessionId: analyticsExtra.sessionId } : {}), ...(analyticsExtra?.tokenSuffix ? { tokenSuffix: analyticsExtra.tokenSuffix } : {}), }; this.analyticsService.trackStoreOneSafe(account.id, ClientApp.SHOP_H5, { storeId: account.storeId, eventName: 'store_redeem_confirm', refType: 'REDEEM_RECORD', refId: record.id, extraJson: { redeemNo: record.redeemNo, amount, userId: userId.toString(), channel: analyticsExtra?.channel ?? 'token', }, }); this.analyticsService.trackOneSafe(userId, ClientApp.SHOP_H5, { eventName: 'benefit_redeem_success', refType: 'STORE', refId: account.storeId, extraJson: redeemExtra, }); this.analyticsService.trackOneSafe(userId, ClientApp.USER_H5, { eventName: 'benefit_redeem_success', refType: 'STORE', refId: account.storeId, extraJson: redeemExtra, }); return record; } async sendPhoneLookupSms(storeAccountId: bigint, storeId: bigint, phone: string) { const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const normalizedPhone = this.normalizeMobilePhone(phone); await this.resolveUserByPhone(normalizedPhone); await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_LOOKUP, { clientApp: ClientApp.SHOP_H5, }); this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { storeId: account.storeId, eventName: 'store_redeem_phone_lookup_sms', extraJson: { phone: this.maskPhoneForStore(normalizedPhone) }, }); return { ok: true, maskedPhone: this.maskPhoneForStore(normalizedPhone) }; } async verifyPhoneAndGetBalance(storeAccountId: bigint, storeId: bigint, phone: string, code: string) { const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const normalizedPhone = this.normalizeMobilePhone(phone); const user = await this.resolveUserByPhone(normalizedPhone); await this.authService.verifySmsCode(normalizedPhone, code, SmsScene.REDEEM_PHONE_LOOKUP); const coupons = await this.prisma.benefitCoupon.findMany({ where: { userId: user.id, status: 'ACTIVE' }, }); const balance = coupons.reduce((sum, c) => sum + Number(c.balance), 0); const sessionId = randomBytes(16).toString('hex'); await this.redis.setJson( this.phoneSessionKey(sessionId), { userId: user.id.toString(), phone: normalizedPhone, storeAccountId: storeAccountId.toString(), storeId: account.storeId.toString(), } satisfies PhoneRedeemSession, REDEEM_PHONE_SESSION_TTL_SECONDS, ); this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { storeId: account.storeId, eventName: 'store_redeem_phone_balance', extraJson: { phone: this.maskPhoneForStore(normalizedPhone), totalBalance: balance, sessionId, }, }); return serializeBigInt({ sessionId, totalBalance: balance, maskedPhone: this.maskPhoneForStore(normalizedPhone), user: { id: user.id, userNo: user.userNo, nickname: user.nickname, phone: this.maskPhoneForStore(normalizedPhone), }, }); } private async loadPhoneSession(sessionId: string, storeAccountId: bigint): Promise { const session = await this.redis.getJson(this.phoneSessionKey(sessionId)); if (!session) throw new BadRequestException('核销会话已过期,请重新验证手机号'); if (session.storeAccountId !== storeAccountId.toString()) { throw new BadRequestException('核销会话无效'); } return session; } async preparePhoneRedeemDirect( storeAccountId: bigint, storeId: bigint, phone: string, amount: number, ) { const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const normalizedPhone = this.normalizeMobilePhone(phone); const user = await this.resolveUserByPhone(normalizedPhone); const { allocations, totalBalance } = await this.computeDirectAllocations(user.id, amount); const sessionId = randomBytes(16).toString('hex'); await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_CONFIRM, { clientApp: ClientApp.SHOP_H5, }); await this.redis.setJson( this.phoneSessionKey(sessionId), { userId: user.id.toString(), phone: normalizedPhone, storeAccountId: storeAccountId.toString(), storeId: account.storeId.toString(), amount, allocations, confirmPrepared: true, } satisfies PhoneRedeemSession, REDEEM_PHONE_SESSION_TTL_SECONDS, ); this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { storeId: account.storeId, eventName: 'store_redeem_phone_prepare', extraJson: { sessionId, amount, phone: this.maskPhoneForStore(normalizedPhone), flow: 'direct', }, }); return serializeBigInt({ sessionId, amount, totalBalance, maskedPhone: this.maskPhoneForStore(normalizedPhone), expireInSeconds: REDEEM_PHONE_SESSION_TTL_SECONDS, user: { id: user.id, userNo: user.userNo, nickname: user.nickname, phone: this.maskPhoneForStore(normalizedPhone), }, }); } async preparePhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, amount: number) { const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const session = await this.loadPhoneSession(sessionId, storeAccountId); const userId = BigInt(session.userId); const { allocations } = await this.computeDirectAllocations(userId, amount); await this.authService.sendSms(session.phone, SmsScene.REDEEM_PHONE_CONFIRM, { clientApp: ClientApp.SHOP_H5, }); const nextSession: PhoneRedeemSession = { ...session, amount, allocations, confirmPrepared: true, }; await this.redis.setJson( this.phoneSessionKey(sessionId), nextSession, REDEEM_PHONE_SESSION_TTL_SECONDS, ); this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { storeId: account.storeId, eventName: 'store_redeem_phone_prepare', extraJson: { sessionId, amount, phone: this.maskPhoneForStore(session.phone), }, }); return { sessionId, amount, expireInSeconds: REDEEM_PHONE_SESSION_TTL_SECONDS, }; } async confirmPhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, code: string) { const meta = { storeId, userId: undefined as string | undefined }; try { const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const session = await this.loadPhoneSession(sessionId, storeAccountId); if (!session.confirmPrepared || session.amount == null || !session.allocations?.length) { throw new BadRequestException('请先选择核销金额并发送确认验证码'); } meta.userId = session.userId; await this.authService.verifySmsCode(session.phone, code, SmsScene.REDEEM_PHONE_CONFIRM); const normalizedAllocations = session.allocations.map((item) => ({ couponId: String(item.couponId), amount: Number(item.amount), })); const amount = Number(session.amount); const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0); if (Math.abs(allocSum - amount) > 0.001) { throw new BadRequestException('核销分摊数据异常'); } await this.validateAllocations(normalizedAllocations); this.payRedeemAnomaly.onRedeemAttempt(amount, { storeId, userId: session.userId, }); const record = await this.executeRedeem( account, BigInt(session.userId), amount, normalizedAllocations, { channel: 'phone', sessionId }, ); await this.redis.del(this.phoneSessionKey(sessionId)); return serializeBigInt(record); } catch (e) { this.payRedeemAnomaly.onRedeemFail(e instanceof Error ? e.message : '手机号核销失败', meta); throw e; } } async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) { let allocations: Array<{ couponId: string; amount: number }>; if (body.couponId) { const coupon = await this.prisma.benefitCoupon.findFirst({ where: { id: BigInt(body.couponId), userId, status: 'ACTIVE' }, }); if (!coupon) throw new NotFoundException('券不存在'); const balance = Number(coupon.balance); const check = validateRedeemAmount(balance, body.amount, balance); if (!check.ok) throw new BadRequestException(check.message); allocations = [{ couponId: coupon.id.toString(), amount: body.amount }]; } else { const coupons = await this.prisma.benefitCoupon.findMany({ where: { userId, status: 'ACTIVE' }, orderBy: { createdAt: 'asc' }, }); const totalBalance = coupons.reduce((s, c) => s + Number(c.balance), 0); const result = allocateBenefitCoupons( coupons.map((c) => ({ id: c.id.toString(), balance: Number(c.balance), createdAt: c.createdAt.getTime(), })), body.amount, ); if (!result.ok) throw new BadRequestException(result.message); const check = validateRedeemAmount(totalBalance, body.amount); if (!check.ok) throw new BadRequestException(check.message); allocations = result.allocations; } const primaryCouponId = BigInt(allocations[0].couponId); const token = randomBytes(16).toString('hex'); const expireAt = new Date(Date.now() + REDEEM_TOKEN_TTL_SECONDS * 1000); await this.redis.setJson( `redeem:token:${token}`, { userId: userId.toString(), couponId: primaryCouponId.toString(), amount: body.amount, storeId: body.storeId ?? null, allocations, }, REDEEM_TOKEN_TTL_SECONDS, ); return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null }; } async getToken(token: string) { const cached = await this.redis.getJson>(`redeem:token:${token}`); if (!cached) throw new NotFoundException('核销码已过期'); return cached; } async getTokenStatus(token: string, userId: bigint) { const result = await this.redis.getJson(`redeem:result:${token}`); if (result) { if (result.userId !== userId.toString()) { throw new NotFoundException('核销码不存在'); } return { status: 'CONSUMED' as const, record: { id: result.recordId, redeemNo: result.redeemNo, amount: result.amount, storeId: result.storeId, storeName: result.storeName, createdAt: result.createdAt, }, }; } const cached = await this.redis.getJson(`redeem:token:${token}`); if (cached) { if (cached.userId !== userId.toString()) { throw new NotFoundException('核销码不存在'); } const ttl = await this.redis.ttl(`redeem:token:${token}`); return { status: 'PENDING' as const, expireInSeconds: ttl > 0 ? ttl : 0, amount: cached.amount, }; } return { status: 'EXPIRED' as const }; } async previewRedeem(storeAccountId: bigint, storeId: bigint, token: string) { const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const cached = await this.redis.getJson(`redeem:token:${token}`); if (!cached) throw new BadRequestException('核销码无效或已过期'); if (cached.storeId && cached.storeId !== account.storeId.toString()) { throw new BadRequestException('该核销码仅限指定门店使用'); } const user = await this.prisma.user.findUnique({ where: { id: BigInt(cached.userId) }, select: { id: true, userNo: true, phone: true, nickname: true }, }); const ttl = await this.redis.ttl(`redeem:token:${token}`); const redeemType: 'DIRECT' | 'COUPON' = cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT'; await this.redis.setJson( `redeem:pending-snapshot:${token}`, { ...cached, redeemType, } satisfies PendingSnapshot, REDEEM_PENDING_SNAPSHOT_TTL_SECONDS, ); this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', { storeId: account.storeId, eventName: 'store_redeem_preview', extraJson: { tokenSuffix: token.slice(-8), amount: cached.amount, userId: cached.userId, redeemType, }, }); return serializeBigInt({ token, amount: cached.amount, user, boundStoreId: cached.storeId, redeemType, expireInSeconds: ttl > 0 ? ttl : 0, storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(), }); } async confirmRedeem(storeAccountId: bigint, storeId: bigint, body: { token: string }) { const meta = { storeId, userId: undefined as string | undefined }; try { const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const token = body.token?.trim(); if (!token) throw new BadRequestException('请提供核销码'); const existingResult = await this.redis.getJson(`redeem:result:${token}`); if (existingResult) { const record = await this.prisma.redeemRecord.findUnique({ where: { id: BigInt(existingResult.recordId) }, }); if (record) { return serializeBigInt(record); } } const cached = await this.redis.getJson(`redeem:token:${token}`); if (!cached) throw new BadRequestException('核销码无效或已过期'); if (cached.storeId && cached.storeId !== account.storeId.toString()) { throw new BadRequestException('该核销码仅限指定门店使用'); } const allocations = cached.allocations ?? (cached.couponId ? [{ couponId: cached.couponId, amount: cached.amount }] : []); if (allocations.length === 0) { throw new BadRequestException('核销码数据异常'); } const normalizedAllocations = allocations.map((item) => ({ couponId: String(item.couponId), amount: Number(item.amount), })); const tokenAmount = Number(cached.amount); if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) { throw new BadRequestException('核销码数据异常'); } const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0); if (Math.abs(allocSum - tokenAmount) > 0.001) { throw new BadRequestException('核销码数据异常'); } await this.validateAllocations(normalizedAllocations); meta.userId = cached.userId; this.payRedeemAnomaly.onRedeemAttempt(tokenAmount, { storeId, userId: cached.userId, }); const record = await this.executeRedeem( account, BigInt(cached.userId), tokenAmount, normalizedAllocations, { channel: 'token', tokenSuffix: token.slice(-8) }, ); await this.redis.setJson( `redeem:result:${token}`, { recordId: record.id.toString(), redeemNo: record.redeemNo, userId: cached.userId, amount: tokenAmount, storeId: account.storeId.toString(), storeName: account.store.name, createdAt: record.createdAt.toISOString(), } satisfies RedeemResultPayload, REDEEM_RESULT_TTL_SECONDS, ); await this.redis.del(`redeem:token:${token}`); await this.redis.del(`redeem:netfail:${storeAccountId}:${token}`); return serializeBigInt(record); } catch (e) { this.payRedeemAnomaly.onRedeemFail(e instanceof Error ? e.message : '核销失败', meta); throw e; } } private netFailKey(storeAccountId: bigint, token: string) { return `redeem:netfail:${storeAccountId}:${token}`; } async reportNetworkFailure( storeAccountId: bigint, storeId: bigint, body: { token: string; errorClass: 'NETWORK' | 'BUSINESS'; message?: string; step: 'preview' | 'confirm'; }, ) { await this.loadOpenStoreAccount(storeAccountId, storeId); const token = body.token.trim(); if (!token) throw new BadRequestException('请提供核销码'); let failCount = 0; if (body.errorClass === 'NETWORK') { failCount = await this.redis.incr(this.netFailKey(storeAccountId, token), REDEEM_TOKEN_TTL_SECONDS); } else { const raw = await this.redis.get(this.netFailKey(storeAccountId, token)); failCount = raw ? Number(raw) || 0 : 0; } const thresholdReached = failCount >= REDEEM_WEAKNET_FAIL_THRESHOLD; this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { storeId, eventName: 'store_redeem_confirm_fail', extraJson: { token, tokenSuffix: token.slice(-8), errorClass: body.errorClass, failCount, step: body.step, message: body.message?.slice(0, 200) ?? null, }, }); this.payRedeemAnomaly.onRedeemFail( body.message?.slice(0, 200) || `弱网核销失败:${body.errorClass}/${body.step}`, { storeId }, ); if (thresholdReached && body.errorClass === 'NETWORK') { this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { storeId, eventName: 'store_redeem_weaknet_threshold', extraJson: { token, tokenSuffix: token.slice(-8), failCount, threshold: REDEEM_WEAKNET_FAIL_THRESHOLD, }, }); this.payRedeemAnomaly.notifyRedeemOps( '弱网核销达阈值', `门店 ${storeId}\n失败次数 ${failCount}(阈值 ${REDEEM_WEAKNET_FAIL_THRESHOLD})\ntoken …${token.slice(-8)}`, `redeem_weaknet|${storeId}|${token.slice(-8)}`, ); } return { failCount, thresholdReached, threshold: REDEEM_WEAKNET_FAIL_THRESHOLD, }; } private async resolvePendingSnapshot(token: string): Promise { const live = await this.redis.getJson(`redeem:token:${token}`); if (live) { return { ...live, redeemType: live.allocations && live.allocations.length > 1 ? 'DIRECT' : live.couponId ? 'COUPON' : 'DIRECT', }; } const snap = await this.redis.getJson(`redeem:pending-snapshot:${token}`); if (!snap) throw new BadRequestException('核销码已失效且无可用快照,请用户重新出码或改用手机号核销'); return snap; } async submitPendingRedeem( storeAccountId: bigint, storeId: bigint, body: { token: string; photoResourceId: string; failCount?: number; remark?: string }, ) { const account = await this.loadOpenStoreAccount(storeAccountId, storeId); const token = body.token.trim(); if (!token) throw new BadRequestException('请提供核销码'); const existingPending = await this.prisma.redeemPendingRecord.findFirst({ where: { redeemToken: token, storeId: account.storeId, status: 'PENDING' }, }); if (existingPending) { return serializeBigInt({ pendingId: existingPending.id, pendingNo: existingPending.pendingNo, redeemToken: existingPending.redeemToken, failCount: existingPending.failCount, }); } const alreadyDone = await this.redis.getJson(`redeem:result:${token}`); if (alreadyDone) { throw new BadRequestException('该核销码已核销成功,无需提交待处理单'); } const snapshot = await this.resolvePendingSnapshot(token); if (snapshot.storeId && snapshot.storeId !== account.storeId.toString()) { throw new BadRequestException('该核销码仅限指定门店使用'); } const photoId = BigInt(body.photoResourceId); const photo = await this.prisma.commonResource.findFirst({ where: { id: photoId, status: 'ACTIVE' }, }); if (!photo) throw new BadRequestException('核销码照片不存在'); const allocations = snapshot.allocations ?? (snapshot.couponId ? [{ couponId: snapshot.couponId, amount: snapshot.amount }] : []); if (!allocations.length) throw new BadRequestException('核销码数据异常'); const rawFail = await this.redis.get(this.netFailKey(storeAccountId, token)); const failCount = Math.max( Number(body.failCount) || 0, rawFail ? Number(rawFail) || 0 : 0, REDEEM_WEAKNET_FAIL_THRESHOLD, ); const pending = await this.prisma.redeemPendingRecord.create({ data: { pendingNo: generateRedeemPendingNo(), redeemToken: token, storeId: account.storeId, storeAccountId: account.id, userId: BigInt(snapshot.userId), amount: snapshot.amount, redeemType: snapshot.redeemType, allocationsJson: allocations as Prisma.InputJsonValue, photoResourceId: photoId, failCount, remark: body.remark?.trim() || null, }, }); this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, { storeId: account.storeId, eventName: 'store_redeem_pending_submit', refType: 'REDEEM_PENDING', refId: pending.id, extraJson: { pendingNo: pending.pendingNo, redeemToken: token, photoResourceId: photoId.toString(), failCount, amount: Number(pending.amount), }, }); this.payRedeemAnomaly.notifyRedeemOps( '新建补核销待办', `待办 ${pending.pendingNo}\n门店 ${account.storeId}\n金额 ${Number(pending.amount)} 元\n失败次数 ${failCount}`, `redeem_pending|${pending.pendingNo}`, ); return serializeBigInt({ pendingId: pending.id, pendingNo: pending.pendingNo, redeemToken: pending.redeemToken, failCount: pending.failCount, }); } async listPendingRedeems(query: { page?: number; pageSize?: number; status?: 'PENDING' | 'COMPLETED' | 'REJECTED'; storeId?: string; pendingNo?: string; redeemToken?: string; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.RedeemPendingRecordWhereInput = {}; if (query.status) where.status = query.status; if (query.storeId) where.storeId = BigInt(query.storeId); if (query.pendingNo) where.pendingNo = { contains: query.pendingNo }; if (query.redeemToken) where.redeemToken = { contains: query.redeemToken }; const [items, total] = await Promise.all([ this.prisma.redeemPendingRecord.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { store: { select: { id: true, name: true, cityName: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } }, photoResource: { select: { id: true, url: true } }, redeemRecord: { select: { id: true, redeemNo: true } }, }, }), this.prisma.redeemPendingRecord.count({ where }), ]); return serializeBigInt({ items: items.map((row) => ({ ...row, photoUrl: row.photoResource?.url ?? null, photoResource: undefined, })), total, page, pageSize, }); } async getPendingRedeem(id: bigint) { const row = await this.prisma.redeemPendingRecord.findUnique({ where: { id }, include: { store: { select: { id: true, name: true, cityName: true, phone: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } }, photoResource: { select: { id: true, url: true } }, redeemRecord: { select: { id: true, redeemNo: true, amount: true, settleAmount: true } }, storeAccount: { select: { id: true, name: true, phone: true } }, }, }); if (!row) throw new NotFoundException('待处理核销单不存在'); return serializeBigInt({ ...row, photoUrl: row.photoResource?.url ?? null, }); } async completePendingRedeem(pendingId: bigint, hqAccountId: bigint) { const pending = await this.prisma.redeemPendingRecord.findUnique({ where: { id: pendingId }, }); if (!pending) throw new NotFoundException('待处理核销单不存在'); if (pending.status === 'COMPLETED' && pending.redeemRecordId) { const record = await this.prisma.redeemRecord.findUnique({ where: { id: pending.redeemRecordId }, }); return serializeBigInt({ pending, record }); } if (pending.status !== 'PENDING') { throw new BadRequestException('待处理单状态不可补核销'); } const account = await this.loadOpenStoreAccount(pending.storeAccountId, pending.storeId); const allocationsRaw = pending.allocationsJson as Array<{ couponId: string; amount: number }>; const normalizedAllocations = allocationsRaw.map((item) => ({ couponId: String(item.couponId), amount: Number(item.amount), })); await this.validateAllocations(normalizedAllocations); const amount = Number(pending.amount); const record = await this.executeRedeem( account, pending.userId, amount, normalizedAllocations, { channel: 'token', tokenSuffix: pending.redeemToken.slice(-8) }, ); const now = new Date(); const updated = await this.prisma.redeemPendingRecord.update({ where: { id: pending.id }, data: { status: 'COMPLETED', redeemRecordId: record.id, processedAt: now, processedByHqId: hqAccountId, }, include: { store: { select: { id: true, name: true } }, user: { select: { id: true, userNo: true, phone: true } }, redeemRecord: true, }, }); await this.redis.setJson( `redeem:result:${pending.redeemToken}`, { recordId: record.id.toString(), redeemNo: record.redeemNo, userId: pending.userId.toString(), amount, storeId: account.storeId.toString(), storeName: account.store.name, createdAt: record.createdAt.toISOString(), } satisfies RedeemResultPayload, REDEEM_RESULT_TTL_SECONDS, ); await this.redis.del(`redeem:token:${pending.redeemToken}`); await this.redis.del(`redeem:pending-snapshot:${pending.redeemToken}`); await this.redis.del(this.netFailKey(pending.storeAccountId, pending.redeemToken)); this.analyticsService.trackStoreOneSafe(pending.storeAccountId, ClientApp.SHOP_H5, { storeId: pending.storeId, eventName: 'store_redeem_pending_complete', refType: 'REDEEM_PENDING', refId: pending.id, extraJson: { pendingNo: pending.pendingNo, redeemToken: pending.redeemToken, redeemNo: record.redeemNo, hqAccountId: hqAccountId.toString(), }, }); return serializeBigInt({ pending: updated, record }); } async rejectPendingRedeem(pendingId: bigint, hqAccountId: bigint, reason: string) { const pending = await this.prisma.redeemPendingRecord.findUnique({ where: { id: pendingId }, }); if (!pending) throw new NotFoundException('待处理核销单不存在'); if (pending.status !== 'PENDING') { throw new BadRequestException('仅待处理状态可驳回'); } const rejectReason = reason?.trim(); if (!rejectReason) throw new BadRequestException('请填写驳回原因'); const updated = await this.prisma.redeemPendingRecord.update({ where: { id: pendingId }, data: { status: 'REJECTED', rejectReason, processedAt: new Date(), processedByHqId: hqAccountId, }, }); this.analyticsService.trackStoreOneSafe(pending.storeAccountId, ClientApp.SHOP_H5, { storeId: pending.storeId, eventName: 'store_redeem_pending_reject', refType: 'REDEEM_PENDING', refId: pending.id, extraJson: { pendingNo: pending.pendingNo, redeemToken: pending.redeemToken, reason: rejectReason, hqAccountId: hqAccountId.toString(), }, }); return serializeBigInt(updated); } async listShopRecords(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) { await this.prisma.storeAccountStore.findUniqueOrThrow({ where: { storeAccountId_storeId: { storeAccountId, storeId } }, }); const [list, total] = await Promise.all([ this.prisma.redeemRecord.findMany({ where: { storeId }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { payout: true }, }), this.prisma.redeemRecord.count({ where: { storeId } }), ]); return { list: serializeBigInt(list), total, page, pageSize }; } async getShopDashboard(storeAccountId: bigint, storeId: bigint) { const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({ where: { storeAccountId_storeId: { storeAccountId, storeId } }, include: { store: true }, }); const start = new Date(); start.setHours(0, 0, 0, 0); const records = await this.prisma.redeemRecord.findMany({ where: { storeId, createdAt: { gte: start } }, }); const todayCount = records.length; const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0); const recent = await this.prisma.redeemRecord.findMany({ where: { storeId }, orderBy: { createdAt: 'desc' }, take: 3, }); return serializeBigInt({ store: binding.store, todayCount, todayAmount, recentRecords: recent, }); } async submitRating(userId: bigint, body: { redeemRecordId: string; serviceScore: number; envScore: number }) { const record = await this.prisma.redeemRecord.findFirst({ where: { id: BigInt(body.redeemRecordId), userId }, }); if (!record) throw new NotFoundException('核销记录不存在'); const rating = await this.prisma.storeRating.create({ data: { redeemRecordId: record.id, storeId: record.storeId, serviceScore: body.serviceScore, envScore: body.envScore, }, }); return serializeBigInt(rating); } }