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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
})();
|
||||
在新工单中引用
屏蔽一个用户