弱网核销功能
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user