弱网核销功能

This commit is contained in:
2026-07-12 12:00:55 +08:00
parent de01d36cdb
commit 06b1cb22e0
24 changed files with 1434 additions and 42 deletions
+62 -17
View File
@@ -43,6 +43,13 @@ enum ResourceBizType {
QRCODE
SIGN_PHOTO
VIDEO
REDEEM_PENDING_PHOTO
}
enum RedeemPendingStatus {
PENDING
COMPLETED
REJECTED
}
enum ResourceMediaType {
@@ -280,12 +287,13 @@ model CommonResource {
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
productCovers CommonProductItem[] @relation("ProductCover")
promoQrcodes CommonPromoCode[] @relation("PromoQrcode")
userAvatars User[] @relation("UserAvatar")
storeCovers Store[] @relation("StoreCover")
orderImages Order[] @relation("OrderProductImage")
deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto")
productCovers CommonProductItem[] @relation("ProductCover")
promoQrcodes CommonPromoCode[] @relation("PromoQrcode")
userAvatars User[] @relation("UserAvatar")
storeCovers Store[] @relation("StoreCover")
orderImages Order[] @relation("OrderProductImage")
deliveryPhotos OrderDelivery[] @relation("DeliverySignPhoto")
redeemPendingPhotos RedeemPendingRecord[] @relation("RedeemPendingPhoto")
@@index([ownerType, ownerId, bizType])
@@index([status])
@@ -606,8 +614,9 @@ model User {
promoTouch UserPromoAttribution?
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
orders Order[]
benefitCoupons BenefitCoupon[]
redeemRecords RedeemRecord[]
benefitCoupons BenefitCoupon[]
redeemRecords RedeemRecord[]
redeemPendingRecords RedeemPendingRecord[]
@@index([sourceType, sourceRefId])
@@index([referrerUserId])
@@ -700,9 +709,10 @@ model Store {
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
account StoreAccount?
redeemRecords RedeemRecord[]
ratings StoreRating[]
payouts StorePayout[]
redeemRecords RedeemRecord[]
redeemPendingRecords RedeemPendingRecord[]
ratings StoreRating[]
payouts StorePayout[]
@@index([cityId, status])
@@index([partnerAccountId])
@@ -721,7 +731,8 @@ model StoreAccount {
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
redeemPendingRecords RedeemPendingRecord[]
@@map("store_account")
}
@@ -854,16 +865,50 @@ model RedeemRecord {
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
rating StoreRating?
payout StorePayout?
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
rating StoreRating?
payout StorePayout?
pending RedeemPendingRecord?
@@index([storeId, createdAt])
@@map("user_redeem_record")
}
model RedeemPendingRecord {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
pendingNo String @unique @map("pending_no") @db.VarChar(32)
redeemToken String @map("redeem_token") @db.VarChar(64)
storeId BigInt @map("store_id") @db.UnsignedBigInt
storeAccountId BigInt @map("store_account_id") @db.UnsignedBigInt
userId BigInt @map("user_id") @db.UnsignedBigInt
amount Decimal @db.Decimal(10, 2)
redeemType String @default("DIRECT") @map("redeem_type") @db.VarChar(16)
allocationsJson Json @map("allocations_json")
photoResourceId BigInt @map("photo_resource_id") @db.UnsignedBigInt
failCount Int @default(5) @map("fail_count")
status RedeemPendingStatus @default(PENDING)
redeemRecordId BigInt? @unique @map("redeem_record_id") @db.UnsignedBigInt
processedAt DateTime? @map("processed_at") @db.DateTime(3)
processedByHqId BigInt? @map("processed_by_hq_id") @db.UnsignedBigInt
rejectReason String? @map("reject_reason") @db.VarChar(256)
remark String? @db.VarChar(256)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
storeAccount StoreAccount @relation(fields: [storeAccountId], references: [id], onDelete: Restrict)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
photoResource CommonResource @relation("RedeemPendingPhoto", fields: [photoResourceId], references: [id], onDelete: Restrict)
redeemRecord RedeemRecord? @relation(fields: [redeemRecordId], references: [id], onDelete: SetNull)
@@index([storeId, status, createdAt])
@@index([redeemToken])
@@index([status, createdAt])
@@map("user_redeem_pending")
}
model StoreRating {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemRecordId BigInt @unique @map("redeem_record_id") @db.UnsignedBigInt
@@ -50,6 +50,8 @@ export const HqOperationAction = {
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
PROMO_CODE_UPDATE: 'PROMO_CODE_UPDATE',
PROMO_CODE_UPDATE_STATUS: 'PROMO_CODE_UPDATE_STATUS',
REDEEM_PENDING_COMPLETE: 'REDEEM_PENDING_COMPLETE',
REDEEM_PENDING_REJECT: 'REDEEM_PENDING_REJECT',
} as const;
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
@@ -105,6 +107,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
[HqOperationAction.PROMO_CODE_UPDATE]: '编辑推广码',
[HqOperationAction.PROMO_CODE_UPDATE_STATUS]: '推广码启停',
[HqOperationAction.REDEEM_PENDING_COMPLETE]: '弱网待处理单-补核销',
[HqOperationAction.REDEEM_PENDING_REJECT]: '弱网待处理单-驳回',
STORE_PAYOUT: '门店打款确认',
};
@@ -30,4 +30,16 @@ export class RedisService {
async ttl(key: string): Promise<number> {
return this.redis.ttl(key);
}
async incr(key: string, ttlSeconds?: number): Promise<number> {
const count = await this.redis.incr(key);
if (ttlSeconds && count === 1) {
await this.redis.expire(key, ttlSeconds);
}
return count;
}
async get(key: string): Promise<string | null> {
return this.redis.get(key);
}
}
@@ -0,0 +1,52 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { RedeemService } from '../redeem/redeem.service';
import {
AdminRedeemPendingQueryDto,
AdminRedeemPendingRejectDto,
} from './dto/admin-redeem-pending.dto';
@Controller('admin/redeem-pending')
@UseGuards(HqAuthGuard)
export class AdminRedeemPendingController {
constructor(private readonly redeemService: RedeemService) {}
@Get()
list(@Query() query: AdminRedeemPendingQueryDto) {
return this.redeemService.listPendingRedeems(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.redeemService.getPendingRedeem(BigInt(id));
}
@Post(':id/complete')
@HqOperation({
action: HqOperationAction.REDEEM_PENDING_COMPLETE,
refType: 'REDEEM_PENDING',
refIdParam: 'id',
})
complete(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.redeemService.completePendingRedeem(BigInt(id), user.actorId);
}
@Post(':id/reject')
@HqOperation({
action: HqOperationAction.REDEEM_PENDING_REJECT,
refType: 'REDEEM_PENDING',
refIdParam: 'id',
includeBody: true,
})
reject(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() body: AdminRedeemPendingRejectDto,
) {
return this.redeemService.rejectPendingRedeem(BigInt(id), user.actorId, body.reason);
}
}
@@ -0,0 +1,26 @@
import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
import { PaginationQueryDto } from './admin-query.dto';
export class AdminRedeemPendingQueryDto extends PaginationQueryDto {
@IsOptional()
@IsIn(['PENDING', 'COMPLETED', 'REJECTED'])
status?: 'PENDING' | 'COMPLETED' | 'REJECTED';
@IsOptional()
@IsString()
storeId?: string;
@IsOptional()
@IsString()
pendingNo?: string;
@IsOptional()
@IsString()
redeemToken?: string;
}
export class AdminRedeemPendingRejectDto {
@IsString()
@MaxLength(256)
reason: string;
}
@@ -44,6 +44,7 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
import { RedeemModule } from '../redeem/redeem.module';
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
import { AdminRedeemPendingController } from './admin-redeem-pending.controller';
import { AdminWechatBindingsController } from './admin-wechat-bindings.controller';
import { AdminWechatBindingsService } from './admin-wechat-bindings.service';
import { AdminHqPermissionsController } from './admin-hq-permissions.controller';
@@ -77,6 +78,7 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
AdminRedeemDebugController,
AdminRedeemPendingController,
AdminWechatBindingsController,
AdminHqPermissionsController,
],
@@ -0,0 +1,40 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class RedeemFailureReportDto {
@IsString()
@IsNotEmpty()
token: string;
@IsIn(['NETWORK', 'BUSINESS'])
errorClass: 'NETWORK' | 'BUSINESS';
@IsOptional()
@IsString()
@MaxLength(256)
message?: string;
@IsIn(['preview', 'confirm'])
step: 'preview' | 'confirm';
}
export class RedeemPendingSubmitDto {
@IsString()
@IsNotEmpty()
token: string;
@IsString()
@IsNotEmpty()
photoResourceId: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
failCount?: number;
@IsOptional()
@IsString()
@MaxLength(256)
remark?: string;
}
@@ -8,6 +8,7 @@ import {
RedeemPhonePrepareDto,
RedeemPhoneSendLookupSmsDto,
} from './dto/phone-redeem.dto';
import { RedeemFailureReportDto, RedeemPendingSubmitDto } from './dto/weaknet-redeem.dto';
@Controller('redeem')
@UseGuards(JwtAuthGuard)
@@ -50,6 +51,22 @@ export class ShopRedeemController {
return this.redeemService.confirmRedeem(user.actorId, body);
}
@Post('failures')
reportFailure(
@CurrentUser() user: AuthUser,
@Body() body: RedeemFailureReportDto,
) {
return this.redeemService.reportNetworkFailure(user.actorId, body);
}
@Post('pending')
submitPending(
@CurrentUser() user: AuthUser,
@Body() body: RedeemPendingSubmitDto,
) {
return this.redeemService.submitPendingRedeem(user.actorId, body);
}
@Get('records')
records(
@CurrentUser() user: AuthUser,
@@ -7,16 +7,20 @@ 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';
@@ -33,6 +37,10 @@ type TokenPayload = {
allocations?: Array<{ couponId: string; amount: number }>;
};
type PendingSnapshot = TokenPayload & {
redeemType: 'DIRECT' | 'COUPON';
};
type RedeemResultPayload = {
recordId: string;
redeemNo: string;
@@ -470,6 +478,21 @@ export class RedeemService {
});
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,
@@ -478,7 +501,7 @@ export class RedeemService {
tokenSuffix: token.slice(-8),
amount: cached.amount,
userId: cached.userId,
redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
redeemType,
},
});
@@ -487,7 +510,7 @@ export class RedeemService {
amount: cached.amount,
user,
boundStoreId: cached.storeId,
redeemType: cached.allocations && cached.allocations.length > 1 ? 'DIRECT' : cached.couponId ? 'COUPON' : 'DIRECT',
redeemType,
expireInSeconds: ttl > 0 ? ttl : 0,
storeMatch: !cached.storeId || cached.storeId === account.storeId.toString(),
});
@@ -495,8 +518,20 @@ export class RedeemService {
async confirmRedeem(storeAccountId: bigint, body: { token: string }) {
const account = await this.loadOpenStoreAccount(storeAccountId);
const token = body.token?.trim();
if (!token) throw new BadRequestException('请提供核销码');
const cached = await this.redis.getJson<TokenPayload>(`redeem:token:${body.token}`);
const existingResult = await this.redis.getJson<RedeemResultPayload>(`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<TokenPayload>(`redeem:token:${token}`);
if (!cached) throw new BadRequestException('核销码无效或已过期');
if (cached.storeId && cached.storeId !== account.storeId.toString()) {
@@ -531,11 +566,11 @@ export class RedeemService {
BigInt(cached.userId),
tokenAmount,
normalizedAllocations,
{ channel: 'token', tokenSuffix: body.token.slice(-8) },
{ channel: 'token', tokenSuffix: token.slice(-8) },
);
await this.redis.setJson(
`redeem:result:${body.token}`,
`redeem:result:${token}`,
{
recordId: record.id.toString(),
redeemNo: record.redeemNo,
@@ -547,11 +582,365 @@ export class RedeemService {
} satisfies RedeemResultPayload,
REDEEM_RESULT_TTL_SECONDS,
);
await this.redis.del(`redeem:token:${body.token}`);
await this.redis.del(`redeem:token:${token}`);
await this.redis.del(`redeem:netfail:${storeAccountId}:${token}`);
return serializeBigInt(record);
}
private netFailKey(storeAccountId: bigint, token: string) {
return `redeem:netfail:${storeAccountId}:${token}`;
}
async reportNetworkFailure(
storeAccountId: bigint,
body: {
token: string;
errorClass: 'NETWORK' | 'BUSINESS';
message?: string;
step: 'preview' | 'confirm';
},
) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
});
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: account.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,
},
});
if (thresholdReached && body.errorClass === 'NETWORK') {
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
storeId: account.storeId,
eventName: 'store_redeem_weaknet_threshold',
extraJson: {
token,
tokenSuffix: token.slice(-8),
failCount,
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
},
});
}
return {
failCount,
thresholdReached,
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
};
}
private async resolvePendingSnapshot(token: string): Promise<PendingSnapshot> {
const live = await this.redis.getJson<TokenPayload>(`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<PendingSnapshot>(`redeem:pending-snapshot:${token}`);
if (!snap) throw new BadRequestException('核销码已失效且无可用快照,请用户重新出码或改用手机号核销');
return snap;
}
async submitPendingRedeem(
storeAccountId: bigint,
body: { token: string; photoResourceId: string; failCount?: number; remark?: string },
) {
const account = await this.loadOpenStoreAccount(storeAccountId);
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<RedeemResultPayload>(`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),
},
});
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.prisma.storeAccount.findUniqueOrThrow({
where: { id: pending.storeAccountId },
include: { store: true },
});
if (account.store.status !== 'OPEN') {
throw new BadRequestException('门店未营业,无法补核销');
}
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, page = 1, pageSize = 20) {
const account = await this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },