Files
dukang/server/dukang-api/src/modules/redeem/redeem.service.ts
T
jacy 3328c52cb4 feat(ops): WeCom webhook alerts for pay/redeem anomalies
Add outbound group-bot alerts, rate/amount rules, stuck-order cron, HQ test button, and health db/redis checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 16:01:30 +08:00

1114 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ReturnType<RedeemService['loadOpenStoreAccount']>>,
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<PhoneRedeemSession> {
const session = await this.redis.getJson<PhoneRedeemSession>(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<Record<string, unknown>>(`redeem:token:${token}`);
if (!cached) throw new NotFoundException('核销码已过期');
return cached;
}
async getTokenStatus(token: string, userId: bigint) {
const result = await this.redis.getJson<RedeemResultPayload>(`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<TokenPayload>(`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<TokenPayload>(`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<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()) {
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<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,
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<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),
},
});
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);
}
}