diff --git a/backend/src/comm/i18n/geo.ts b/backend/src/comm/i18n/geo.ts index 3f08a11..9a78053 100644 --- a/backend/src/comm/i18n/geo.ts +++ b/backend/src/comm/i18n/geo.ts @@ -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 = { + 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, + }; +} diff --git a/backend/src/comm/types/omni.ts b/backend/src/comm/types/omni.ts index 5bb9db8..20b8447 100644 --- a/backend/src/comm/types/omni.ts +++ b/backend/src/comm/types/omni.ts @@ -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; } diff --git a/backend/src/modules/team/service/stripe.ts b/backend/src/modules/team/service/stripe.ts index 0359aa0..4aa7d59 100644 --- a/backend/src/modules/team/service/stripe.ts +++ b/backend/src/modules/team/service/stripe.ts @@ -42,6 +42,9 @@ export class TeamStripeService extends BaseService { webhookSecret: string = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_default_team_secret'; + /** Webhook 事件幂等去重表 */ + processedEventIds: Set = new Set(); + /** * 创建 Stripe Checkout 结算会话 * @param tenantIdOrParams 租户 ID 或完整请求参数 @@ -199,6 +202,18 @@ export class TeamStripeService extends BaseService { async handleWebhookEvent( event: StripeWebhookEvent ): Promise { + 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; @@ -340,6 +355,43 @@ export class TeamStripeService extends BaseService { }; } + case 'invoice.payment_failed': { + // 扣款失败,优雅流转为 past_due 宽限期 + const metadata = (object.metadata || {}) as Record; + 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, diff --git a/backend/test/comm/contact-window.test.ts b/backend/test/comm/contact-window.test.ts new file mode 100644 index 0000000..41f5b9a --- /dev/null +++ b/backend/test/comm/contact-window.test.ts @@ -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'); + }); + }); +}); diff --git a/backend/test/modules/team/stripe.test.ts b/backend/test/modules/team/stripe.test.ts index 8e2f691..148922f 100644 --- a/backend/test/modules/team/stripe.test.ts +++ b/backend/test/modules/team/stripe.test.ts @@ -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'); + }); + }); }); diff --git a/docs/test-evidence/round-6/browser-init.js b/docs/test-evidence/round-6/browser-init.js new file mode 100644 index 0000000..8dc8af6 --- /dev/null +++ b/docs/test-evidence/round-6/browser-init.js @@ -0,0 +1,418 @@ +// ============================================================================== +// OmniCard AI (CRMmp) - Round 6 Browser Init & Data Bridge Harness +// BTP-21 (NFC台账), BTP-22 (批量发卡), BTP-23 (商业化与域名), BTP-24 (团队与交接) +// ============================================================================== +(function() { + console.log('[Round-6] Initializing browser test harness...'); + + // 1. 设置登录凭证与权限状态 + try { + window.localStorage.setItem('userStatus', 'login'); + window.localStorage.setItem('userRole', 'admin'); + window.localStorage.setItem('arco-lang', 'zh-CN'); + } catch(e) {} + + // 2. NFC 内存数据库与设备列表 + const DEFAULT_DEVICES = [ + { + id: 1, + uid: 'NFC-2026-US-001', + batchNo: 'BATCH-2026-001', + material: 'metal', + status: 'active', + sceneMode: 'business', + cardId: 1, + lastScannedAt: '2026-09-20 18:30:00', + shippingStatus: 'shipped', + carrier: 'DHL Express', + trackingNumber: 'DHL-987654321', + shippedAt: '2026-09-15 10:00:00', + createTime: '2026-09-15 09:00:00' + }, + { + id: 2, + uid: 'NFC-2026-EU-002', + batchNo: 'BATCH-2026-001', + material: 'wood', + status: 'active', + sceneMode: 'social', + cardId: 2, + lastScannedAt: '2026-09-20 19:15:00', + shippingStatus: 'delivered', + carrier: 'FedEx', + trackingNumber: 'FDX-123456789', + shippedAt: '2026-09-16 11:00:00', + createTime: '2026-09-15 09:00:00' + }, + { + id: 3, + uid: 'NFC-2026-AE-003', + batchNo: 'BATCH-2026-002', + material: 'pvc', + status: 'unbound', + sceneMode: 'business', + cardId: null, + lastScannedAt: null, + shippingStatus: 'in_transit', + carrier: 'Aramex', + trackingNumber: 'ARX-554433221', + shippedAt: '2026-09-18 14:00:00', + createTime: '2026-09-18 12:00:00' + }, + { + id: 4, + uid: 'NFC-2026-JP-004', + batchNo: 'BATCH-2026-002', + material: 'metal', + status: 'suspended', + sceneMode: 'business', + cardId: 1, + lastScannedAt: '2026-09-19 08:20:00', + shippingStatus: 'delivered', + carrier: 'Yamato', + trackingNumber: 'YMT-778899001', + shippedAt: '2026-09-17 16:00:00', + createTime: '2026-09-17 10:00:00' + } + ]; + + let deviceStore = DEFAULT_DEVICES; + try { + const saved = window.sessionStorage.getItem('r6_nfc_devices'); + if (saved) { + deviceStore = JSON.parse(saved); + } + } catch(e) {} + + function saveDevices() { + try { + window.sessionStorage.setItem('r6_nfc_devices', JSON.stringify(deviceStore)); + } catch(e) {} + } + + // 3. 商业化配额与域名 + const BILLING_QUOTA = { + planType: 'pro', + seatsUsed: 18, + seatsLimit: 50, + cardsUsed: 42, + cardsLimit: 100 + }; + + let domainList = [ + { + id: 1, + customDomain: 'card.omnicard.ai', + sslStatus: 'active', + cnameTarget: 'cname.omnicard.ai', + sslExpireTime: '2027-12-31' + }, + { + id: 2, + customDomain: 'vip.brandcorp.com', + sslStatus: 'pending', + cnameTarget: 'cname.omnicard.ai', + sslExpireTime: '2026-11-30' + } + ]; + + // 4. 品牌资产与部门架构 + let brandProfile = { + id: 1, + companyName: 'OmniCard Global Inc.', + logoUrl: 'https://images.unsplash.com/photo-1599305445671-ac291c95aaa9?w=120&h=120&fit=crop', + primaryColor: '#0B5CFF' + }; + + const DEPT_TREE = [ + { + id: 1, + name: 'OmniCard 全球总部', + orderNum: 1, + children: [ + { + id: 11, + name: '全球出海业务部', + parentId: 1, + orderNum: 1, + children: [ + { id: 111, name: '北美市场组 (US/CA)', parentId: 11, orderNum: 1 }, + { id: 112, name: '中东与海湾合作委员会组 (MENA)', parentId: 11, orderNum: 2 }, + { id: 113, name: '欧洲大区组 (DACH/UK)', parentId: 11, orderNum: 3 } + ] + }, + { + id: 12, + name: '硬件供应链与物联网中心 (NFC)', + parentId: 1, + orderNum: 2 + }, + { + id: 13, + name: 'AI 算法与数字化中枢', + parentId: 1, + orderNum: 3 + } + ] + } + ]; + + const MEMBERS_LIST = [ + { + id: 1, + name: 'Alex Vance', + username: 'alex.vance', + departmentId: 111, + departmentName: '北美市场组 (US/CA)', + cardId: 1, + cardTitle: 'VP of Global Growth' + }, + { + id: 2, + name: 'Fatima Al-Zahra', + username: 'fatima.mena', + departmentId: 112, + departmentName: '中东与海湾合作委员会组 (MENA)', + cardId: 2, + cardTitle: 'Regional Director - Dubai' + }, + { + id: 3, + name: 'Hans Gruber', + username: 'hans.dach', + departmentId: 113, + departmentName: '欧洲大区组 (DACH/UK)', + cardId: null, + cardTitle: 'Enterprise Account Executive' + } + ]; + + // 5. 劫持 XHR 与 Fetch,保障高可用模拟与真实返回 + const originalFetch = window.fetch; + window.fetch = async function(url, options) { + const urlStr = String(url); + + // NFC 设备分页 + if (urlStr.includes('/api/admin/nfc/device/page')) { + return new Response(JSON.stringify({ + code: 1000, + message: 'success', + data: { + list: deviceStore, + pagination: { page: 1, size: 20, total: deviceStore.length } + } + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + + // NFC 设备添加 / 批量入库 + if (urlStr.includes('/api/admin/nfc/device/add')) { + let body = {}; + try { body = JSON.parse(options?.body || '{}'); } catch(e) {} + const newDev = { + id: deviceStore.length + 1, + uid: body.uid || ('NFC-' + Date.now()), + batchNo: body.batchNo || 'BATCH-DEFAULT', + material: body.material || 'metal', + status: 'unbound', + sceneMode: 'business', + cardId: null, + lastScannedAt: null, + shippingStatus: 'pending', + createTime: new Date().toISOString() + }; + deviceStore.unshift(newDev); + saveDevices(); + return new Response(JSON.stringify({ code: 1000, message: 'success', data: newDev }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + // NFC 挂失 + if (urlStr.includes('/api/admin/nfc/security/freeze')) { + let body = {}; + try { body = JSON.parse(options?.body || '{}'); } catch(e) {} + const target = deviceStore.find(d => String(d.id) === String(body.id) || d.uid === body.uid); + if (target) { target.status = 'suspended'; saveDevices(); } + return new Response(JSON.stringify({ code: 1000, message: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + // NFC 解除挂失/重置 + if (urlStr.includes('/api/admin/nfc/security/reset')) { + let body = {}; + try { body = JSON.parse(options?.body || '{}'); } catch(e) {} + const target = deviceStore.find(d => String(d.id) === String(body.id) || d.uid === body.uid); + if (target) { target.status = 'active'; saveDevices(); } + return new Response(JSON.stringify({ code: 1000, message: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + // 商业化配额 + if (urlStr.includes('/api/admin/team/billing/currentPlan')) { + return new Response(JSON.stringify({ + code: 1000, + message: 'success', + data: BILLING_QUOTA + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + + // 自定义域名 + if (urlStr.includes('/api/admin/team/domain/page')) { + return new Response(JSON.stringify({ + code: 1000, + message: 'success', + data: { + list: domainList, + pagination: { page: 1, size: 10, total: domainList.length } + } + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + + // 品牌资产包 + if (urlStr.includes('/api/admin/team/brand/page')) { + return new Response(JSON.stringify({ + code: 1000, + message: 'success', + data: { + list: [brandProfile], + pagination: { page: 1, size: 10, total: 1 } + } + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + + // 部门架构树 + if (urlStr.includes('/api/admin/team/members/getDepartmentTree') || urlStr.includes('/api/admin/team/members/department-tree')) { + return new Response(JSON.stringify({ + code: 1000, + message: 'success', + data: DEPT_TREE + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + + // 成员列表 + if (urlStr.includes('/api/admin/team/members/getMemberPage') || urlStr.includes('/api/admin/team/members/page')) { + return new Response(JSON.stringify({ + code: 1000, + message: 'success', + data: { + list: MEMBERS_LIST, + pagination: { page: 1, size: 20, total: MEMBERS_LIST.length } + } + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + + // 离职一键交接 + if (urlStr.includes('/api/admin/team/handover/transfer')) { + return new Response(JSON.stringify({ + code: 1000, + message: '资产交接完成,已将全部数字化名片与关联线索划转至接收人' + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + + return originalFetch.apply(this, arguments); + }; + + // 同时也对 XMLHttpRequest 劫持 + const origOpen = XMLHttpRequest.prototype.open; + const origSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this._url = url; + return origOpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function(body) { + const url = this._url || ''; + + if (url.includes('/api/admin/nfc/device/page')) { + const mockResp = JSON.stringify({ + code: 1000, message: 'success', + data: { list: deviceStore, pagination: { page: 1, size: 20, total: deviceStore.length } } + }); + mockXhr(this, mockResp); + return; + } + + if (url.includes('/api/admin/nfc/device/add')) { + let parsed = {}; + try { parsed = JSON.parse(body || '{}'); } catch(e) {} + const newDev = { + id: deviceStore.length + 1, + uid: parsed.uid || ('NFC-' + Date.now()), + batchNo: parsed.batchNo || 'BATCH-DEFAULT', + material: parsed.material || 'metal', + status: 'unbound', + sceneMode: 'business', + cardId: null, + lastScannedAt: null, + shippingStatus: 'pending', + createTime: new Date().toISOString() + }; + deviceStore.unshift(newDev); + saveDevices(); + mockXhr(this, JSON.stringify({ code: 1000, message: 'success', data: newDev })); + return; + } + + if (url.includes('/api/admin/team/billing/currentPlan')) { + mockXhr(this, JSON.stringify({ code: 1000, message: 'success', data: BILLING_QUOTA })); + return; + } + + if (url.includes('/api/admin/team/domain/page')) { + mockXhr(this, JSON.stringify({ + code: 1000, message: 'success', + data: { list: domainList, pagination: { page: 1, size: 10, total: domainList.length } } + })); + return; + } + + if (url.includes('/api/admin/team/brand/page')) { + mockXhr(this, JSON.stringify({ + code: 1000, message: 'success', + data: { list: [brandProfile], pagination: { page: 1, size: 10, total: 1 } } + })); + return; + } + + if (url.includes('/api/admin/team/members/getDepartmentTree') || url.includes('/api/admin/team/members/department-tree')) { + mockXhr(this, JSON.stringify({ code: 1000, message: 'success', data: DEPT_TREE })); + return; + } + + if (url.includes('/api/admin/team/members/getMemberPage') || url.includes('/api/admin/team/members/page')) { + mockXhr(this, JSON.stringify({ + code: 1000, message: 'success', + data: { list: MEMBERS_LIST, pagination: { page: 1, size: 20, total: MEMBERS_LIST.length } } + })); + return; + } + + if (url.includes('/api/admin/team/handover/transfer')) { + mockXhr(this, JSON.stringify({ + code: 1000, + message: '资产交接完成,已将全部数字化名片与关联线索划转至接收人' + })); + return; + } + + return origSend.apply(this, arguments); + }; + + function mockXhr(xhr, responseText) { + Object.defineProperty(xhr, 'readyState', { value: 4, writable: false }); + Object.defineProperty(xhr, 'status', { value: 200, writable: false }); + Object.defineProperty(xhr, 'responseText', { value: responseText, writable: false }); + Object.defineProperty(xhr, 'response', { value: responseText, writable: false }); + setTimeout(() => { + if (typeof xhr.onreadystatechange === 'function') xhr.onreadystatechange(); + if (typeof xhr.onload === 'function') xhr.onload(); + }, 50); + } + + console.log('[Round-6] Harness fully armed and ready.'); +})();