弱网核销功能
This commit is contained in:
@@ -7,6 +7,9 @@ import {
|
||||
allocateBenefitCoupons,
|
||||
calcBenefitSummary,
|
||||
orderTabToStatuses,
|
||||
classifyRedeemClientError,
|
||||
generateRedeemPendingNo,
|
||||
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||
} from './index';
|
||||
|
||||
describe('calcBenefitAmount', () => {
|
||||
@@ -120,3 +123,26 @@ describe('calcBenefitSummary', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyRedeemClientError', () => {
|
||||
it('treats 5xx and network messages as NETWORK', () => {
|
||||
expect(classifyRedeemClientError({ status: 502 })).toBe('NETWORK');
|
||||
expect(classifyRedeemClientError({ message: 'Failed to fetch' })).toBe('NETWORK');
|
||||
expect(classifyRedeemClientError({ message: '网络超时' })).toBe('NETWORK');
|
||||
});
|
||||
|
||||
it('treats business messages as BUSINESS', () => {
|
||||
expect(classifyRedeemClientError({ status: 400, message: '核销码无效或已过期' })).toBe('BUSINESS');
|
||||
expect(classifyRedeemClientError({ message: '余额不足' })).toBe('BUSINESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRedeemPendingNo', () => {
|
||||
it('uses RP prefix', () => {
|
||||
expect(generateRedeemPendingNo().startsWith('RP')).toBe(true);
|
||||
});
|
||||
|
||||
it('exports weaknet threshold of 5', () => {
|
||||
expect(REDEEM_WEAKNET_FAIL_THRESHOLD).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,36 @@ export function generateRedeemNo(): string {
|
||||
return `RD${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
}
|
||||
|
||||
export function generateRedeemPendingNo(): string {
|
||||
return `RP${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
}
|
||||
|
||||
/** 弱网核销:网络类失败达到该次数后触发兜底 */
|
||||
export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5;
|
||||
|
||||
export type RedeemErrorClass = 'NETWORK' | 'BUSINESS';
|
||||
|
||||
/** 门店端/服务端共用:区分网络类与业务类核销错误(仅 NETWORK 计入 5 次) */
|
||||
export function classifyRedeemClientError(input: {
|
||||
status?: number | null;
|
||||
message?: string | null;
|
||||
}): RedeemErrorClass {
|
||||
const status = input.status;
|
||||
const message = (input.message ?? '').toLowerCase();
|
||||
if (status != null && status >= 500) return 'NETWORK';
|
||||
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
|
||||
return 'NETWORK';
|
||||
}
|
||||
if (
|
||||
/failed to fetch|network|timeout|timed out|econnreset|econnrefused|etimedout|abort|offline|连接|网络|超时/.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
return 'NETWORK';
|
||||
}
|
||||
return 'BUSINESS';
|
||||
}
|
||||
|
||||
export function orderTabToStatuses(tab: string): string[] | undefined {
|
||||
switch (tab) {
|
||||
case 'pending_pay':
|
||||
|
||||
@@ -180,3 +180,7 @@ export const SMS_CODE_TTL_SECONDS = 180;
|
||||
export const REDEEM_RESULT_TTL_SECONDS = 360;
|
||||
/** 手机号核销会话 TTL(查权益 → 选金额 → 确认) */
|
||||
export const REDEEM_PHONE_SESSION_TTL_SECONDS = 600;
|
||||
/** 弱网兜底:preview 快照 TTL(token 失效后仍可提交待处理单) */
|
||||
export const REDEEM_PENDING_SNAPSHOT_TTL_SECONDS = 600;
|
||||
/** 弱网核销:网络类失败阈值 */
|
||||
export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5;
|
||||
|
||||
@@ -44,3 +44,46 @@ export interface RedeemPhonePrepareDto {
|
||||
amount: number;
|
||||
expireInSeconds: number;
|
||||
}
|
||||
|
||||
export type RedeemPendingStatus = 'PENDING' | 'COMPLETED' | 'REJECTED';
|
||||
|
||||
export const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = {
|
||||
PENDING: '待处理',
|
||||
COMPLETED: '已补核销',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export type RedeemErrorClass = 'NETWORK' | 'BUSINESS';
|
||||
|
||||
export type RedeemFailureReportResult = {
|
||||
failCount: number;
|
||||
thresholdReached: boolean;
|
||||
threshold: number;
|
||||
};
|
||||
|
||||
export type RedeemPendingSubmitResult = {
|
||||
pendingId: string;
|
||||
pendingNo: string;
|
||||
redeemToken: string;
|
||||
failCount: number;
|
||||
};
|
||||
|
||||
export type RedeemPendingItem = {
|
||||
id: string;
|
||||
pendingNo: string;
|
||||
redeemToken: string;
|
||||
amount: number;
|
||||
redeemType: string;
|
||||
failCount: number;
|
||||
status: RedeemPendingStatus;
|
||||
remark?: string | null;
|
||||
rejectReason?: string | null;
|
||||
processedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
store?: { id: string; name: string; cityName?: string } | null;
|
||||
user?: { id: string; userNo?: string | null; phone?: string | null; nickname?: string | null } | null;
|
||||
photoUrl?: string | null;
|
||||
redeemRecordId?: string | null;
|
||||
redeemRecord?: { id: string; redeemNo: string } | null;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,18 @@ export type StoreLogCategory =
|
||||
export const STORE_LOG_EVENT_CATEGORIES: Record<StoreLogCategory, readonly string[]> = {
|
||||
login: ['store_sms_send', 'store_sms_login', 'store_sms_verify_fail', 'store_login_success'],
|
||||
wechat_auth: ['store_wechat_login', 'store_wechat_bind'],
|
||||
redeem: ['store_redeem_preview', 'store_redeem_confirm'],
|
||||
redeem: [
|
||||
'store_redeem_preview',
|
||||
'store_redeem_confirm',
|
||||
'store_redeem_confirm_fail',
|
||||
'store_redeem_weaknet_threshold',
|
||||
'store_redeem_pending_submit',
|
||||
'store_redeem_pending_complete',
|
||||
'store_redeem_pending_reject',
|
||||
'store_redeem_phone_lookup_sms',
|
||||
'store_redeem_phone_balance',
|
||||
'store_redeem_phone_prepare',
|
||||
],
|
||||
payout: ['store_payout_created', 'store_payout_paid'],
|
||||
store_ops: ['store_status_change'],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user