feat(round-10): 新增出海潜客多时区最佳联系窗口算法与Stripe回调幂等性防重处理 (79套件957测试全绿)
这个提交包含在:
@@ -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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户