feat(round-10): 新增出海潜客多时区最佳联系窗口算法与Stripe回调幂等性防重处理 (79套件957测试全绿)

这个提交包含在:
wikiboss
2026-09-21 01:59:11 +08:00
父节点 9f8e309a4a
当前提交 f46b24a177
共有 6 个文件被更改,包括 643 次插入 和 1 次删除

查看文件

@@ -153,3 +153,65 @@ export function resolveSuggestedLocale(input: {
return DEFAULT_LOCALE;
}
export interface ContactWindowInfo {
localHour: number;
localTimeFormatted: string;
isWorkHour: boolean;
status: 'recommended' | 'acceptable' | 'do_not_disturb';
suggestionText: string;
}
const COUNTRY_UTC_OFFSETS: Record<string, number> = {
AE: 4, // 阿联酋 Dubai (UTC+4)
SA: 3, // 沙特 Riyadh (UTC+3)
DE: 1, // 德国 Berlin (UTC+1)
FR: 1, // 法国 Paris (UTC+1)
GB: 0, // 英国 London (UTC+0)
US: -5, // 美国东部 EST (UTC-5)
CN: 8, // 中国北京 (UTC+8)
SG: 8, // 新加坡 (UTC+8)
JP: 9, // 日本东京 (UTC+9)
};
/**
* 出海客户最佳商务联系时间窗口计算
*/
export function resolveBestContactWindow(
countryCodeOrName?: string,
now: Date = new Date()
): ContactWindowInfo {
const code = (countryCodeOrName || '').toUpperCase().trim();
const offsetHours = COUNTRY_UTC_OFFSETS[code] ?? 8; // 默认 UTC+8
const utcHours = now.getUTCHours();
const utcMinutes = now.getUTCMinutes();
let localHour = (utcHours + offsetHours + 24) % 24;
const mm = utcMinutes.toString().padStart(2, '0');
const hh = localHour.toString().padStart(2, '0');
const localTimeFormatted = `${hh}:${mm}`;
const isWorkHour = localHour >= 9 && localHour < 18;
let status: 'recommended' | 'acceptable' | 'do_not_disturb';
let suggestionText: string;
if ((localHour >= 9 && localHour < 12) || (localHour >= 14 && localHour < 18)) {
status = 'recommended';
suggestionText = `客商当地时间 ${localTimeFormatted},处于黄金当地工作时间,极力推荐沟通!`;
} else if ((localHour >= 12 && localHour < 14) || (localHour >= 18 && localHour < 21)) {
status = 'acceptable';
suggestionText = `客商当地时间 ${localTimeFormatted},处于午休或晚间,建议发送图文资料。`;
} else {
status = 'do_not_disturb';
suggestionText = `客商当地时间 ${localTimeFormatted},处于深夜休息时段,建议定时发送避免打扰。`;
}
return {
localHour,
localTimeFormatted,
isWorkHour,
status,
suggestionText,
};
}

查看文件

@@ -967,7 +967,13 @@ export interface StripeWebhookHandleResult {
tenantId?: number | string;
plan?: TenantPlanType;
status?: string;
action: 'upgraded' | 'renewed' | 'downgraded' | 'ignored';
action:
| 'upgraded'
| 'renewed'
| 'downgraded'
| 'ignored'
| 'idempotent_ignored'
| 'payment_failed';
ignored?: boolean;
message?: string;
}

查看文件

@@ -42,6 +42,9 @@ export class TeamStripeService extends BaseService {
webhookSecret: string =
process.env.STRIPE_WEBHOOK_SECRET || 'whsec_default_team_secret';
/** Webhook 事件幂等去重表 */
processedEventIds: Set<string> = new Set();
/**
* 创建 Stripe Checkout 结算会话
* @param tenantIdOrParams 租户 ID 或完整请求参数
@@ -199,6 +202,18 @@ export class TeamStripeService extends BaseService {
async handleWebhookEvent(
event: StripeWebhookEvent
): Promise<StripeWebhookHandleResult> {
if (event.id && this.processedEventIds.has(event.id)) {
return {
success: true,
eventType: event.type,
action: 'idempotent_ignored',
ignored: true,
};
}
if (event.id) {
this.processedEventIds.add(event.id);
}
const { type, data } = event;
const object = (data?.object || {}) as Record<string, unknown>;
@@ -340,6 +355,43 @@ export class TeamStripeService extends BaseService {
};
}
case 'invoice.payment_failed': {
// 扣款失败,优雅流转为 past_due 宽限期
const metadata = (object.metadata || {}) as Record<string, string>;
const stripeSubId = (object.subscription as string) || '';
const rawTenantId = String(metadata.tenantId || '');
let targetSub: TeamSubscriptionEntity | null = null;
if (this.teamSubscriptionEntity) {
if (rawTenantId) {
targetSub = await this.teamSubscriptionEntity.findOne({
where: { tenantId: Number(rawTenantId) || 0 },
});
}
if (!targetSub && stripeSubId) {
targetSub = await this.teamSubscriptionEntity.findOne({
where: { stripeSubscriptionId: stripeSubId },
});
}
}
const tenantId: number | string =
targetSub?.tenantId ?? (Number(rawTenantId) || rawTenantId);
if (targetSub && this.teamSubscriptionEntity) {
targetSub.status = 'past_due';
await this.teamSubscriptionEntity.save(targetSub);
}
return {
success: true,
eventType: type,
tenantId,
status: 'past_due',
action: 'payment_failed',
};
}
default: {
return {
success: true,

查看文件

@@ -0,0 +1,45 @@
import {
resolveBestContactWindow,
ContactWindowInfo,
} from '../../src/comm/i18n/geo';
describe('Point 39: 出海潜客多时区识别与最佳联系时间窗口推荐 (Best Contact Time Window)', () => {
describe('1. 国际多国家/时区时间计算', () => {
it('应准确计算中东阿联酋 (UTC+4) 当地时间与联系窗口', () => {
// 假设 UTC 时间为 06:00 (北京时间 14:00,阿联酋当地时间为 10:00)
const baseUtcDate = new Date(Date.UTC(2026, 8, 21, 6, 0, 0));
const windowInfo: ContactWindowInfo = resolveBestContactWindow(
'AE',
baseUtcDate
);
expect(windowInfo.localHour).toBe(10);
expect(windowInfo.isWorkHour).toBe(true);
expect(windowInfo.status).toBe('recommended');
expect(windowInfo.suggestionText).toContain('当地工作时间');
});
it('当海外客商处于深夜休息时段 (如欧洲当地 23:00) 时,建议勿扰', () => {
// 假设 UTC 时间为 22:00 (欧洲 CET UTC+1 当地为 23:00)
const baseUtcDate = new Date(Date.UTC(2026, 8, 21, 22, 0, 0));
const windowInfo: ContactWindowInfo = resolveBestContactWindow(
'DE',
baseUtcDate
);
expect(windowInfo.localHour).toBe(23);
expect(windowInfo.isWorkHour).toBe(false);
expect(windowInfo.status).toBe('do_not_disturb');
expect(windowInfo.suggestionText).toContain('休息时段');
});
it('对于未识别国家时区时,默认安全降级返回可接受状态', () => {
const windowInfo = resolveBestContactWindow(undefined);
expect(windowInfo).toBeDefined();
expect(typeof windowInfo.localHour).toBe('number');
expect(typeof windowInfo.suggestionText).toBe('string');
});
});
});

查看文件

@@ -436,4 +436,63 @@ describe('Point 21: Stripe 国际信用卡订阅支付与 Webhook 自动化入
expect(res.message).toContain('签名验证失败');
});
});
// =========================================================================
// 5. Webhook 幂等去重与异常事件状态机 (Idempotency & Failure Grace Period)
// =========================================================================
describe('5. Webhook 幂等去重与异常事件状态机', () => {
it('5.1 同一 event.id 重复推送时触发幂等拦截,防止重复入账', async () => {
const event: StripeWebhookEvent = {
id: 'evt_unique_1001',
type: 'checkout.session.completed',
created: 1700000000,
data: {
object: {
client_reference_id: 88,
metadata: { tenantId: '88', plan: 'pro' },
},
},
};
const firstRes = await stripeService.handleWebhookEvent(event);
expect(firstRes.action).toBe('upgraded');
// 重复推送相同事件
const secondRes = await stripeService.handleWebhookEvent(event);
expect(secondRes.success).toBe(true);
expect(secondRes.action).toBe('idempotent_ignored');
});
it('5.2 收到 invoice.payment_failed 扣款失败时流转为 past_due 宽限期', async () => {
// 先建立活跃订阅
subscriptions.push({
id: 99,
tenantId: 66,
planType: 'pro',
billingCycle: 'monthly',
status: 'active',
stripeSubscriptionId: 'sub_pastdue_test',
currentPeriodEnd: new Date().toISOString(),
} as TeamSubscriptionEntity);
const failEvent: StripeWebhookEvent = {
id: 'evt_fail_999',
type: 'invoice.payment_failed',
created: 1700000000,
data: {
object: {
subscription: 'sub_pastdue_test',
metadata: { tenantId: '66' },
},
},
};
const res = await stripeService.handleWebhookEvent(failEvent);
expect(res.status).toBe('past_due');
expect(res.action).toBe('payment_failed');
const updated = subscriptions.find(s => s.tenantId === 66);
expect(updated?.status).toBe('past_due');
});
});
});