Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b93c92a3e2 | |||
| ef4ce12bbf | |||
| 4d12ea438b | |||
| f57e080b49 | |||
| 1ca06e58e7 | |||
| 0a7b2df5fe | |||
| 843e4d1002 | |||
| f20302fe65 | |||
| 7c0ed58ae1 | |||
| 2b58cdc2c8 | |||
| 9f7633be24 | |||
| fbd68a36e9 | |||
| be8ce867ca | |||
| 67b9478155 | |||
| 4c2bc95462 |
@@ -67,12 +67,20 @@ node scripts/run-scenario-test.mjs --scenario suzhou-page
|
||||
|--------|------|
|
||||
| `login` | HTTP 登录,取 cookie + userId |
|
||||
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
|
||||
| `upload_images` | 生成并上传测试图片到 MindSpace,供后续 `chat` 步骤引用 |
|
||||
|
||||
`upload_images` 字段:`categoryCode`(默认 `oa`)、`images[]`
|
||||
(每项 `filename` / `background` / `shape` / `accent`,shape 支持
|
||||
`circle` `square` `triangle` `diamond`)。后续 `chat` 步骤设
|
||||
`useUploadedAssets: true` 即可把这些图作为本轮附件发出。
|
||||
|
||||
`chat.expect` 可选断言:
|
||||
|
||||
- `assistantMinChars` / `timeoutMs`
|
||||
- `replyKeywords`:回复文本关键词
|
||||
- `forbidReplyPatterns`:回复中不允许出现的字符串
|
||||
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
|
||||
- `page.requireUploadedImages` / `page.minUploadedImages`:页面必须嵌入 `upload_images` 上传的资产
|
||||
|
||||
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
|
||||
|
||||
|
||||
@@ -67,12 +67,20 @@ node scripts/run-scenario-test.mjs --scenario suzhou-page
|
||||
|--------|------|
|
||||
| `login` | HTTP 登录,取 cookie + userId |
|
||||
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
|
||||
| `upload_images` | 生成并上传测试图片到 MindSpace,供后续 `chat` 步骤引用 |
|
||||
|
||||
`upload_images` 字段:`categoryCode`(默认 `oa`)、`images[]`
|
||||
(每项 `filename` / `background` / `shape` / `accent`,shape 支持
|
||||
`circle` `square` `triangle` `diamond`)。后续 `chat` 步骤设
|
||||
`useUploadedAssets: true` 即可把这些图作为本轮附件发出。
|
||||
|
||||
`chat.expect` 可选断言:
|
||||
|
||||
- `assistantMinChars` / `timeoutMs`
|
||||
- `replyKeywords`:回复文本关键词
|
||||
- `forbidReplyPatterns`:回复中不允许出现的字符串
|
||||
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
|
||||
- `page.requireUploadedImages` / `page.minUploadedImages`:页面必须嵌入 `upload_images` 上传的资产
|
||||
|
||||
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ bash scripts/check-release-ready.sh
|
||||
| H5 SSE 断线续播、Portal/Goose 游标映射与 Finish 终态恢复 | [docs/regression-guards/h5-session-stream-replay.md](docs/regression-guards/h5-session-stream-replay.md) |
|
||||
| Memory V2 候选表初始化与生命周期灰度作用域 | [docs/regression-guards/memory-v2-candidate-and-lifecycle.md](docs/regression-guards/memory-v2-candidate-and-lifecycle.md) |
|
||||
| MindSpace SEO/GEO 收录策略与私有页 noindex | [docs/regression-guards/mindspace-seo-geo.md](docs/regression-guards/mindspace-seo-geo.md) |
|
||||
| 图片轮次禁用 `read_image` 与工具图片污染会话轮换 | [docs/regression-guards/vision-turn-read-image-isolation.md](docs/regression-guards/vision-turn-read-image-isolation.md) |
|
||||
|
||||
索引:[docs/regression-guards/README.md](docs/regression-guards/README.md)
|
||||
|
||||
@@ -81,6 +82,7 @@ npm run verify:seo-discovery
|
||||
- `mindspace-page-sync-service.mjs` + `server.mjs` - remote 模式也必须 sync public HTML
|
||||
- `session-stream.mjs` + `session-stream-store.mjs` + `tkmind-proxy.mjs` - Portal replay ID 不得直接作为 Goose `Last-Event-ID`
|
||||
- `mindspace-index-policy.mjs` + `mindspace-seo-geo-delivery.mjs` - 私有页必须 noindex;仅 confirmed public 可收录
|
||||
- `tkmind-proxy.mjs` + `chat-image-turn-scope.mjs` + `wechat-mp.mjs` - 带图轮次必须摘掉 `read_image`;工具图片污染必须触发会话轮换
|
||||
|
||||
代码内搜索 `REGRESSION GUARD` 可定位所有内联说明。
|
||||
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ export async function createAdminServices(env = {}) {
|
||||
});
|
||||
const notificationDispatcher = createNotificationDispatcher({
|
||||
sendWechatTextToUser: wechatMpService?.enabled
|
||||
? (userId, text) => wechatMpService.sendTextToUser(userId, text)
|
||||
? (userId, text, options) => wechatMpService.sendTextToUser(userId, text, options)
|
||||
: null,
|
||||
});
|
||||
userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => {
|
||||
|
||||
+197
-63
@@ -1,7 +1,9 @@
|
||||
import { loadBillingConfig } from './billing.mjs';
|
||||
|
||||
const CONFIG_TABLE = 'h5_billing_admin_config';
|
||||
const CONFIG_SCOPE = 'global';
|
||||
const CONFIG_SCOPE_LEGACY = 'global';
|
||||
const CONFIG_SCOPE_A = 'formula-a';
|
||||
const CONFIG_SCOPE_B = 'formula-b';
|
||||
const SOURCE_ADMIN_DB = 'admin-db';
|
||||
const SOURCE_ENV = 'env';
|
||||
const SOURCE_ENV_OVERRIDE = 'env-override';
|
||||
@@ -9,6 +11,13 @@ const SOURCE_DEFAULT = 'default';
|
||||
const CACHE_TTL_MS = 5_000;
|
||||
const DEFAULT_ESTIMATE_INPUT_USD_PER_1M = 0.27;
|
||||
const DEFAULT_ESTIMATE_OUTPUT_USD_PER_1M = 1.1;
|
||||
const DEFAULT_FORMULA = 'A';
|
||||
const FORMULA_KEYS = ['A', 'B'];
|
||||
|
||||
const SCOPE_BY_FORMULA = {
|
||||
A: CONFIG_SCOPE_A,
|
||||
B: CONFIG_SCOPE_B,
|
||||
};
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (value == null || value === '') return fallback;
|
||||
@@ -31,6 +40,11 @@ function normalizeNonNegativeNumber(value, fallback) {
|
||||
return num;
|
||||
}
|
||||
|
||||
function normalizeFormulaKey(value) {
|
||||
const key = String(value ?? DEFAULT_FORMULA).trim().toUpperCase();
|
||||
return FORMULA_KEYS.includes(key) ? key : DEFAULT_FORMULA;
|
||||
}
|
||||
|
||||
function defaultConfigShape(env = process.env) {
|
||||
const billing = loadBillingConfig(env);
|
||||
const costEstimateFromTokens = env.H5_COST_ESTIMATE_FROM_TOKENS !== '0';
|
||||
@@ -160,24 +174,98 @@ async function ensureConfigTable(pool) {
|
||||
`);
|
||||
}
|
||||
|
||||
async function loadStoredState(pool) {
|
||||
async function loadStoredStateByScope(pool, scope, env = process.env) {
|
||||
await ensureConfigTable(pool);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT config_json, updated_by, updated_at
|
||||
FROM ${CONFIG_TABLE}
|
||||
WHERE config_scope = ?
|
||||
LIMIT 1`,
|
||||
[CONFIG_SCOPE],
|
||||
[scope],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
config: mergePatch(defaultConfigShape(), parseJsonLike(row.config_json, {})),
|
||||
config: mergePatch(defaultConfigShape(env), parseJsonLike(row.config_json, {}), env),
|
||||
updatedAt: Number(row.updated_at ?? 0) || null,
|
||||
updatedBy: row.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertStoredConfig(pool, scope, config, { updatedBy = null } = {}, env = process.env) {
|
||||
await ensureConfigTable(pool);
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
`INSERT INTO ${CONFIG_TABLE}
|
||||
(config_scope, config_json, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
config_json = VALUES(config_json),
|
||||
updated_by = VALUES(updated_by),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
[scope, JSON.stringify(cloneConfig(config, env)), updatedBy, now],
|
||||
);
|
||||
return {
|
||||
config: cloneConfig(config, env),
|
||||
updatedAt: now,
|
||||
updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDefaultSource(env = process.env) {
|
||||
if (envLocked(env)) return SOURCE_ENV_OVERRIDE;
|
||||
if (envLooksCustomized(env)) return SOURCE_ENV;
|
||||
return SOURCE_DEFAULT;
|
||||
}
|
||||
|
||||
function buildFormulaState(stored, env = process.env) {
|
||||
if (stored) {
|
||||
return {
|
||||
config: cloneConfig(stored.config, env),
|
||||
updatedAt: stored.updatedAt,
|
||||
updatedBy: stored.updatedBy,
|
||||
source: SOURCE_ADMIN_DB,
|
||||
};
|
||||
}
|
||||
return {
|
||||
config: defaultConfigShape(env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: resolveDefaultSource(env),
|
||||
};
|
||||
}
|
||||
|
||||
async function migrateLegacyConfig(pool, env = process.env) {
|
||||
await ensureConfigTable(pool);
|
||||
const legacy = await loadStoredStateByScope(pool, CONFIG_SCOPE_LEGACY, env);
|
||||
const formulaA = await loadStoredStateByScope(pool, CONFIG_SCOPE_A, env);
|
||||
const formulaB = await loadStoredStateByScope(pool, CONFIG_SCOPE_B, env);
|
||||
|
||||
const seedConfig = legacy?.config
|
||||
?? formulaA?.config
|
||||
?? formulaB?.config
|
||||
?? defaultConfigShape(env);
|
||||
|
||||
if (!formulaA) {
|
||||
await upsertStoredConfig(
|
||||
pool,
|
||||
CONFIG_SCOPE_A,
|
||||
seedConfig,
|
||||
{ updatedBy: legacy?.updatedBy ?? formulaB?.updatedBy ?? null },
|
||||
env,
|
||||
);
|
||||
}
|
||||
if (!formulaB) {
|
||||
await upsertStoredConfig(
|
||||
pool,
|
||||
CONFIG_SCOPE_B,
|
||||
cloneConfig(formulaA?.config ?? seedConfig, env),
|
||||
{ updatedBy: legacy?.updatedBy ?? formulaA?.updatedBy ?? null },
|
||||
env,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBillingAdminConfigService(pool, { env = process.env, cacheTtlMs = CACHE_TTL_MS } = {}) {
|
||||
let cache = null;
|
||||
|
||||
@@ -185,101 +273,142 @@ export function createBillingAdminConfigService(pool, { env = process.env, cache
|
||||
cache = null;
|
||||
}
|
||||
|
||||
async function loadEffectiveConfig({ bypassCache = false } = {}) {
|
||||
async function loadAllFormulaStates({ bypassCache = false } = {}) {
|
||||
const now = Date.now();
|
||||
if (!bypassCache && cache && now - cache.loadedAt < cacheTtlMs) {
|
||||
return cache.state;
|
||||
}
|
||||
|
||||
if (envLocked(env)) {
|
||||
const shared = defaultConfigShape(env);
|
||||
const state = {
|
||||
config: defaultConfigShape(env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: SOURCE_ENV_OVERRIDE,
|
||||
formulas: {
|
||||
A: {
|
||||
config: cloneConfig(shared, env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: SOURCE_ENV_OVERRIDE,
|
||||
},
|
||||
B: {
|
||||
config: cloneConfig(shared, env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: SOURCE_ENV_OVERRIDE,
|
||||
},
|
||||
},
|
||||
defaultFormula: DEFAULT_FORMULA,
|
||||
};
|
||||
cache = { loadedAt: now, state };
|
||||
return state;
|
||||
}
|
||||
|
||||
const stored = await loadStoredState(pool);
|
||||
let state;
|
||||
if (stored) {
|
||||
state = {
|
||||
config: cloneConfig(stored.config, env),
|
||||
updatedAt: stored.updatedAt,
|
||||
updatedBy: stored.updatedBy,
|
||||
source: SOURCE_ADMIN_DB,
|
||||
};
|
||||
} else if (envLooksCustomized(env)) {
|
||||
state = {
|
||||
config: defaultConfigShape(env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: SOURCE_ENV,
|
||||
};
|
||||
} else {
|
||||
state = {
|
||||
config: defaultConfigShape(env),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: SOURCE_DEFAULT,
|
||||
};
|
||||
}
|
||||
await migrateLegacyConfig(pool, env);
|
||||
const storedA = await loadStoredStateByScope(pool, CONFIG_SCOPE_A, env);
|
||||
const storedB = await loadStoredStateByScope(pool, CONFIG_SCOPE_B, env);
|
||||
const state = {
|
||||
formulas: {
|
||||
A: buildFormulaState(storedA, env),
|
||||
B: buildFormulaState(storedB, env),
|
||||
},
|
||||
defaultFormula: DEFAULT_FORMULA,
|
||||
};
|
||||
cache = { loadedAt: now, state };
|
||||
return state;
|
||||
}
|
||||
|
||||
async function getUserFormulaKey(userId) {
|
||||
if (!userId) return DEFAULT_FORMULA;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT billing_formula FROM h5_users WHERE id = ? LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return normalizeFormulaKey(rows[0]?.billing_formula);
|
||||
}
|
||||
|
||||
async function loadFormulaState(formulaKey, options = {}) {
|
||||
const all = await loadAllFormulaStates(options);
|
||||
return all.formulas[normalizeFormulaKey(formulaKey)];
|
||||
}
|
||||
|
||||
return {
|
||||
async ensureSchema() {
|
||||
await ensureConfigTable(pool);
|
||||
await migrateLegacyConfig(pool, env);
|
||||
},
|
||||
|
||||
clearCache,
|
||||
|
||||
async getAdminConfig() {
|
||||
const state = await loadEffectiveConfig({ bypassCache: true });
|
||||
async getAdminConfig({ formula = DEFAULT_FORMULA } = {}) {
|
||||
const all = await loadAllFormulaStates({ bypassCache: true });
|
||||
const activeFormula = normalizeFormulaKey(formula);
|
||||
const active = all.formulas[activeFormula];
|
||||
return {
|
||||
config: state.config,
|
||||
updatedAt: state.updatedAt,
|
||||
updatedBy: state.updatedBy,
|
||||
source: state.source,
|
||||
envOverrideActive: state.source === SOURCE_ENV_OVERRIDE,
|
||||
config: active.config,
|
||||
formulas: {
|
||||
A: all.formulas.A.config,
|
||||
B: all.formulas.B.config,
|
||||
},
|
||||
formulaMeta: {
|
||||
A: {
|
||||
updatedAt: all.formulas.A.updatedAt,
|
||||
updatedBy: all.formulas.A.updatedBy,
|
||||
source: all.formulas.A.source,
|
||||
},
|
||||
B: {
|
||||
updatedAt: all.formulas.B.updatedAt,
|
||||
updatedBy: all.formulas.B.updatedBy,
|
||||
source: all.formulas.B.source,
|
||||
},
|
||||
},
|
||||
activeFormula,
|
||||
defaultFormula: DEFAULT_FORMULA,
|
||||
updatedAt: active.updatedAt,
|
||||
updatedBy: active.updatedBy,
|
||||
source: active.source,
|
||||
envOverrideActive: active.source === SOURCE_ENV_OVERRIDE,
|
||||
formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数(成本模式);无上游成本时回退 Token 单价',
|
||||
};
|
||||
},
|
||||
|
||||
async updateAdminConfig(patch = {}, { updatedBy = null } = {}) {
|
||||
async updateAdminConfig(patch = {}, { updatedBy = null, formula = DEFAULT_FORMULA } = {}) {
|
||||
if (envLocked(env)) {
|
||||
throw Object.assign(new Error('H5_BILLING_CONFIG_SOURCE=env 时不允许通过后台修改'), {
|
||||
code: 'BILLING_CONFIG_ENV_LOCKED',
|
||||
});
|
||||
}
|
||||
const stored = await loadStoredState(pool);
|
||||
const formulaKey = normalizeFormulaKey(formula);
|
||||
const scope = SCOPE_BY_FORMULA[formulaKey];
|
||||
const stored = await loadStoredStateByScope(pool, scope, env);
|
||||
const base = stored?.config ?? defaultConfigShape(env);
|
||||
const nextConfig = mergePatch(base, patch, env);
|
||||
await ensureConfigTable(pool);
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
`INSERT INTO ${CONFIG_TABLE}
|
||||
(config_scope, config_json, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
config_json = VALUES(config_json),
|
||||
updated_by = VALUES(updated_by),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
[CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now],
|
||||
);
|
||||
await upsertStoredConfig(pool, scope, nextConfig, { updatedBy }, env);
|
||||
clearCache();
|
||||
return this.getAdminConfig();
|
||||
return this.getAdminConfig({ formula: formulaKey });
|
||||
},
|
||||
|
||||
async getRuntimeState() {
|
||||
const state = await loadEffectiveConfig();
|
||||
async assignBillingFormula(userIds = [], formula = DEFAULT_FORMULA) {
|
||||
const formulaKey = normalizeFormulaKey(formula);
|
||||
const ids = [...new Set((Array.isArray(userIds) ? userIds : []).map((id) => String(id).trim()).filter(Boolean))];
|
||||
if (!ids.length) {
|
||||
return { ok: false, message: '请选择至少一个用户', updated: 0 };
|
||||
}
|
||||
const now = Date.now();
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const [result] = await pool.query(
|
||||
`UPDATE h5_users SET billing_formula = ?, updated_at = ? WHERE id IN (${placeholders})`,
|
||||
[formulaKey, now, ...ids],
|
||||
);
|
||||
return { ok: true, updated: Number(result.affectedRows ?? 0), formula: formulaKey };
|
||||
},
|
||||
|
||||
async getRuntimeState({ formula = DEFAULT_FORMULA, userId = null } = {}) {
|
||||
const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula);
|
||||
const state = await loadFormulaState(formulaKey);
|
||||
return {
|
||||
source: state.source,
|
||||
updatedAt: state.updatedAt,
|
||||
updatedBy: state.updatedBy,
|
||||
formula: formulaKey,
|
||||
config: state.config,
|
||||
compute: toComputeBillingConfig(state.config),
|
||||
estimate: toCostEstimateConfig(state.config),
|
||||
@@ -287,23 +416,28 @@ export function createBillingAdminConfigService(pool, { env = process.env, cache
|
||||
};
|
||||
},
|
||||
|
||||
async getEffectiveBillingConfig() {
|
||||
const state = await loadEffectiveConfig();
|
||||
async getEffectiveBillingConfig({ userId = null, formula = DEFAULT_FORMULA } = {}) {
|
||||
const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula);
|
||||
const state = await loadFormulaState(formulaKey);
|
||||
return toComputeBillingConfig(state.config);
|
||||
},
|
||||
|
||||
async getEffectiveCostEstimateConfig() {
|
||||
const state = await loadEffectiveConfig();
|
||||
async getEffectiveCostEstimateConfig({ userId = null, formula = DEFAULT_FORMULA } = {}) {
|
||||
const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula);
|
||||
const state = await loadFormulaState(formulaKey);
|
||||
return toCostEstimateConfig(state.config);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const billingAdminConfigInternals = {
|
||||
CONFIG_SCOPE,
|
||||
CONFIG_TABLE,
|
||||
CONFIG_SCOPE_LEGACY,
|
||||
CONFIG_SCOPE_A,
|
||||
CONFIG_SCOPE_B,
|
||||
DEFAULT_FORMULA,
|
||||
defaultConfigShape,
|
||||
mergePatch,
|
||||
normalizeFormulaKey,
|
||||
toComputeBillingConfig,
|
||||
toCostEstimateConfig,
|
||||
envLocked,
|
||||
|
||||
@@ -7,13 +7,21 @@ import {
|
||||
toCostEstimateConfig,
|
||||
} from './billing-admin-config.mjs';
|
||||
|
||||
function createMemoryPool(initialRows = []) {
|
||||
function createMemoryPool(initialRows = [], userFormulas = new Map()) {
|
||||
const rows = new Map(
|
||||
initialRows.map((row) => [row.config_scope, { ...row }]),
|
||||
);
|
||||
return {
|
||||
async query(sql, params = []) {
|
||||
if (/CREATE TABLE/i.test(sql)) return [{}, undefined];
|
||||
if (/SELECT billing_formula FROM h5_users/i.test(sql)) {
|
||||
const userId = params[0];
|
||||
return [[{ billing_formula: userFormulas.get(userId) ?? 'A' }], undefined];
|
||||
}
|
||||
if (/SELECT/i.test(sql) && /config_scope IN/i.test(sql)) {
|
||||
const scopes = params.slice(0, 3);
|
||||
return [scopes.map((scope) => rows.get(scope)).filter(Boolean), undefined];
|
||||
}
|
||||
if (/SELECT/i.test(sql)) {
|
||||
const scope = params[0];
|
||||
const row = rows.get(scope);
|
||||
@@ -29,9 +37,15 @@ function createMemoryPool(initialRows = []) {
|
||||
});
|
||||
return [{ affectedRows: 1 }, undefined];
|
||||
}
|
||||
if (/UPDATE h5_users SET billing_formula/i.test(sql)) {
|
||||
const [formula, , ...ids] = params;
|
||||
ids.forEach((id) => userFormulas.set(id, formula));
|
||||
return [{ affectedRows: ids.length }, undefined];
|
||||
}
|
||||
return [{}, undefined];
|
||||
},
|
||||
_rows: rows,
|
||||
_userFormulas: userFormulas,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,14 +103,38 @@ test('admin-db config wins over env for effective billing', async () => {
|
||||
const service = createBillingAdminConfigService(pool, { env, cacheTtlMs: 0 });
|
||||
await service.updateAdminConfig(
|
||||
{ marginMultiplier: 2, usdCnyRate: 7.5, useBackendCost: true },
|
||||
{ updatedBy: 'admin-1' },
|
||||
{ updatedBy: 'admin-1', formula: 'A' },
|
||||
);
|
||||
const effective = await service.getEffectiveBillingConfig();
|
||||
const effective = await service.getEffectiveBillingConfig({ userId: 'user-a' });
|
||||
assert.equal(effective.marginMultiplier, 2);
|
||||
assert.equal(effective.usdCnyRate, 7.5);
|
||||
const admin = await service.getAdminConfig();
|
||||
assert.equal(admin.source, 'admin-db');
|
||||
assert.equal(admin.updatedBy, 'admin-1');
|
||||
assert.equal(admin.formulas?.A.marginMultiplier, 2);
|
||||
});
|
||||
|
||||
test('formula A and B can diverge', async () => {
|
||||
const pool = createMemoryPool([], new Map([['user-b', 'B']]));
|
||||
const service = createBillingAdminConfigService(pool, { env: {}, cacheTtlMs: 0 });
|
||||
await service.updateAdminConfig({ marginMultiplier: 1.1 }, { formula: 'A' });
|
||||
await service.updateAdminConfig({ marginMultiplier: 2.2 }, { formula: 'B' });
|
||||
const admin = await service.getAdminConfig();
|
||||
assert.equal(admin.formulas.A.marginMultiplier, 1.1);
|
||||
assert.equal(admin.formulas.B.marginMultiplier, 2.2);
|
||||
const userA = await service.getEffectiveBillingConfig({ userId: 'user-a' });
|
||||
const userB = await service.getEffectiveBillingConfig({ userId: 'user-b' });
|
||||
assert.equal(userA.marginMultiplier, 1.1);
|
||||
assert.equal(userB.marginMultiplier, 2.2);
|
||||
});
|
||||
|
||||
test('assignBillingFormula updates users', async () => {
|
||||
const pool = createMemoryPool();
|
||||
const service = createBillingAdminConfigService(pool, { env: {}, cacheTtlMs: 0 });
|
||||
const result = await service.assignBillingFormula(['u1', 'u2'], 'B');
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.updated, 2);
|
||||
assert.equal(pool._userFormulas.get('u1'), 'B');
|
||||
});
|
||||
|
||||
test('H5_BILLING_CONFIG_SOURCE=env locks admin writes', async () => {
|
||||
@@ -125,7 +163,8 @@ test('env fallback used when no admin row', async () => {
|
||||
cacheTtlMs: 0,
|
||||
});
|
||||
const admin = await service.getAdminConfig();
|
||||
assert.equal(admin.source, 'env');
|
||||
assert.equal(admin.config.marginMultiplier, 1.2);
|
||||
assert.equal(admin.config.useBackendCost, true);
|
||||
assert.equal(admin.formulas.A.marginMultiplier, 1.2);
|
||||
assert.equal(admin.formulas.B.marginMultiplier, 1.2);
|
||||
});
|
||||
|
||||
+123
-82
@@ -477,99 +477,139 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
|
||||
return { ok: true, updated: result.affectedRows > 0 };
|
||||
};
|
||||
|
||||
// Process auto-renewals for subscriptions that just expired with auto_renew = 1.
|
||||
// Should be called BEFORE expireStaleSubscriptions in the periodic worker.
|
||||
const processAutoRenewals = async () => {
|
||||
const now = Date.now();
|
||||
// Find active subscriptions that have expired with auto_renew on.
|
||||
const [rows] = await pool.query(
|
||||
// Process auto-renewals for subscriptions that expired with auto_renew = 1.
|
||||
// Retries until balance is sufficient; does not disable auto_renew on failure.
|
||||
const processAutoRenewalsForUser = async (userId, { now = Date.now() } = {}) => {
|
||||
const [activeRows] = await pool.query(
|
||||
`SELECT id FROM h5_subscriptions
|
||||
WHERE user_id = ? AND status = 'active' AND expires_at > ?
|
||||
LIMIT 1`,
|
||||
[userId, now],
|
||||
);
|
||||
if (activeRows.length) {
|
||||
return { ok: false, reason: 'already_active' };
|
||||
}
|
||||
|
||||
const [candidateRows] = await pool.query(
|
||||
`SELECT s.*, w.balance_cents
|
||||
FROM h5_subscriptions s
|
||||
JOIN h5_user_wallets w ON w.user_id = s.user_id
|
||||
WHERE s.status = 'active' AND s.auto_renew = 1 AND s.expires_at <= ?`,
|
||||
[now],
|
||||
WHERE s.user_id = ? AND s.auto_renew = 1 AND s.expires_at <= ?
|
||||
ORDER BY s.expires_at DESC
|
||||
LIMIT 1`,
|
||||
[userId, now],
|
||||
);
|
||||
const row = candidateRows[0];
|
||||
if (!row) {
|
||||
return { ok: false, reason: 'no_candidate' };
|
||||
}
|
||||
|
||||
const sub = mapSubRow(row);
|
||||
const plan = await resolvePlan(sub.planType);
|
||||
if (!plan || plan.priceCents === 0) {
|
||||
return { ok: false, reason: 'invalid_plan' };
|
||||
}
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const [walletRows] = await conn.query(
|
||||
`SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
|
||||
[sub.userId],
|
||||
);
|
||||
const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
|
||||
|
||||
if (balanceCents < plan.priceCents) {
|
||||
await conn.rollback();
|
||||
console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`);
|
||||
return { ok: false, reason: 'insufficient_balance', balanceCents, requiredCents: plan.priceCents };
|
||||
}
|
||||
|
||||
await conn.query(
|
||||
`UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`,
|
||||
[plan.priceCents, now, sub.userId],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at)
|
||||
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
|
||||
[sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now],
|
||||
);
|
||||
|
||||
if (sub.status === 'active') {
|
||||
await conn.query(
|
||||
`UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`,
|
||||
[now, sub.id],
|
||||
);
|
||||
}
|
||||
|
||||
const newId = crypto.randomUUID();
|
||||
const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000;
|
||||
await conn.query(
|
||||
`INSERT INTO h5_subscriptions
|
||||
(id, user_id, plan_type, status, period_tokens_limit, period_tokens_used,
|
||||
period_images_limit, period_images_used, period_images_bonus,
|
||||
period_start, period_end, expires_at, overage_rate, auto_renew,
|
||||
operator_id, note, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`,
|
||||
[
|
||||
newId, sub.userId, sub.planType, plan.periodTokens,
|
||||
plan.periodImages ?? 0,
|
||||
sub.periodImagesBonus ?? 0,
|
||||
now, newPeriodEnd, newPeriodEnd,
|
||||
Number(plan.overageRate.toFixed(2)),
|
||||
now, now,
|
||||
],
|
||||
);
|
||||
|
||||
await conn.query(
|
||||
`UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`,
|
||||
[sub.planType, now, sub.userId],
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`);
|
||||
const [newRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [newId]);
|
||||
return { ok: true, subscription: mapSubRow(newRows[0]), balanceCents: balanceCents - plan.priceCents };
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
console.warn(`Auto-renew error for user ${sub.userId}:`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
const processAutoRenewals = async () => {
|
||||
const now = Date.now();
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DISTINCT s.user_id
|
||||
FROM h5_subscriptions s
|
||||
WHERE s.auto_renew = 1
|
||||
AND s.expires_at <= ?
|
||||
AND s.plan_type != 'free'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM h5_subscriptions s2
|
||||
WHERE s2.user_id = s.user_id
|
||||
AND s2.status = 'active'
|
||||
AND s2.expires_at > ?
|
||||
)`,
|
||||
[now, now],
|
||||
);
|
||||
|
||||
let renewed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const sub = mapSubRow(row);
|
||||
const plan = await resolvePlan(sub.planType);
|
||||
if (!plan || plan.priceCents === 0) continue;
|
||||
|
||||
const balance = Number(row.balance_cents ?? 0);
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// Re-lock wallet and verify balance
|
||||
const [walletRows] = await conn.query(
|
||||
`SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
|
||||
[sub.userId],
|
||||
);
|
||||
const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
|
||||
|
||||
if (balanceCents < plan.priceCents) {
|
||||
// Not enough — let expireStaleSubscriptions handle the reset
|
||||
await conn.rollback();
|
||||
failed++;
|
||||
console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`);
|
||||
continue;
|
||||
const result = await processAutoRenewalsForUser(row.user_id, { now });
|
||||
if (result.ok) {
|
||||
renewed += 1;
|
||||
} else if (result.reason === 'insufficient_balance') {
|
||||
failed += 1;
|
||||
}
|
||||
|
||||
// Deduct wallet
|
||||
await conn.query(
|
||||
`UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`,
|
||||
[plan.priceCents, now, sub.userId],
|
||||
);
|
||||
await conn.query(
|
||||
`INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at)
|
||||
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
|
||||
[sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now],
|
||||
);
|
||||
|
||||
// Expire old subscription
|
||||
await conn.query(
|
||||
`UPDATE h5_subscriptions SET status = 'expired', auto_renew = 0, updated_at = ? WHERE id = ?`,
|
||||
[now, sub.id],
|
||||
);
|
||||
|
||||
// Create new subscription
|
||||
const newId = crypto.randomUUID();
|
||||
const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000;
|
||||
await conn.query(
|
||||
`INSERT INTO h5_subscriptions
|
||||
(id, user_id, plan_type, status, period_tokens_limit, period_tokens_used,
|
||||
period_images_limit, period_images_used, period_images_bonus,
|
||||
period_start, period_end, expires_at, overage_rate, auto_renew,
|
||||
operator_id, note, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`,
|
||||
[
|
||||
newId, sub.userId, sub.planType, plan.periodTokens,
|
||||
plan.periodImages ?? 0,
|
||||
sub.periodImagesBonus ?? 0,
|
||||
now, newPeriodEnd, newPeriodEnd,
|
||||
Number(plan.overageRate.toFixed(2)),
|
||||
now, now,
|
||||
],
|
||||
);
|
||||
|
||||
// Keep user's plan_type current
|
||||
await conn.query(
|
||||
`UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`,
|
||||
[sub.planType, now, sub.userId],
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
renewed++;
|
||||
console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`);
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
console.warn(`Auto-renew error for user ${sub.userId}:`, err);
|
||||
failed++;
|
||||
} finally {
|
||||
conn.release();
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,6 +941,7 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
|
||||
purchaseSubscription,
|
||||
setAutoRenew,
|
||||
processAutoRenewals,
|
||||
processAutoRenewalsForUser,
|
||||
consumeQuota,
|
||||
consumeImageQuota,
|
||||
getImageQuota,
|
||||
|
||||
@@ -353,8 +353,11 @@ describe('createSubscriptionService', () => {
|
||||
|
||||
describe('processAutoRenewals', () => {
|
||||
it('uses DB-backed plan definitions for renewed subscriptions', async () => {
|
||||
const userId = 'user-lite-1';
|
||||
const expiredSub = makeSubRow({
|
||||
user_id: userId,
|
||||
plan_type: 'lite',
|
||||
status: 'expired',
|
||||
auto_renew: 1,
|
||||
expires_at: Date.now() - 1000,
|
||||
balance_cents: 9999,
|
||||
@@ -364,6 +367,9 @@ describe('createSubscriptionService', () => {
|
||||
async query(sql, params) {
|
||||
this.queries.push({ sql, params });
|
||||
if (sql.includes('SELECT balance_cents')) return [[{ balance_cents: 9999 }]];
|
||||
if (sql.includes('SELECT * FROM h5_subscriptions WHERE id = ?')) {
|
||||
return [[{ ...expiredSub, id: 'new-sub-id', status: 'active' }]];
|
||||
}
|
||||
return [{ affectedRows: 1 }];
|
||||
},
|
||||
async beginTransaction() {},
|
||||
@@ -372,8 +378,12 @@ describe('createSubscriptionService', () => {
|
||||
release() {},
|
||||
};
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_subscriptions s')) return [[expiredSub]];
|
||||
async query(sql, params = []) {
|
||||
if (sql.includes('SELECT DISTINCT s.user_id')) return [[{ user_id: userId }]];
|
||||
if (sql.includes('SELECT id FROM h5_subscriptions') && sql.includes('status = \'active\'')) return [[]];
|
||||
if (sql.includes('FROM h5_subscriptions s') && sql.includes('ORDER BY s.expires_at DESC')) {
|
||||
return [[expiredSub]];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
async getConnection() { return conn; },
|
||||
@@ -407,5 +417,54 @@ describe('createSubscriptionService', () => {
|
||||
assert.equal(insert.params[5], 0);
|
||||
assert.equal(insert.params[9], 0.45);
|
||||
});
|
||||
|
||||
it('retries expired subscriptions when balance becomes sufficient', async () => {
|
||||
const userId = 'user-retry-1';
|
||||
let balance = 500;
|
||||
const expiredSub = makeSubRow({
|
||||
user_id: userId,
|
||||
plan_type: 'lite',
|
||||
status: 'expired',
|
||||
auto_renew: 1,
|
||||
expires_at: Date.now() - 1000,
|
||||
});
|
||||
const conn = {
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT balance_cents')) return [[{ balance_cents: balance }]];
|
||||
if (sql.includes('SELECT * FROM h5_subscriptions WHERE id = ?')) {
|
||||
return [[{ ...expiredSub, id: 'renewed-sub', status: 'active' }]];
|
||||
}
|
||||
return [{ affectedRows: 1 }];
|
||||
},
|
||||
async beginTransaction() {},
|
||||
async commit() {},
|
||||
async rollback() {},
|
||||
release() {},
|
||||
};
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT id FROM h5_subscriptions') && sql.includes('status = \'active\'')) return [[]];
|
||||
if (sql.includes('FROM h5_subscriptions s') && sql.includes('ORDER BY s.expires_at DESC')) {
|
||||
return [[expiredSub]];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
async getConnection() { return conn; },
|
||||
};
|
||||
const svc = createSubscriptionService(pool, {
|
||||
getPlanAsync: async (planType) => planType === 'lite'
|
||||
? { priceCents: 990, periodTokens: 1000, periodImages: 0, overageRate: 1, periodDays: 30 }
|
||||
: null,
|
||||
});
|
||||
|
||||
const failed = await svc.processAutoRenewalsForUser(userId);
|
||||
assert.equal(failed.ok, false);
|
||||
assert.equal(failed.reason, 'insufficient_balance');
|
||||
|
||||
balance = 5000;
|
||||
const renewed = await svc.processAutoRenewalsForUser(userId);
|
||||
assert.equal(renewed.ok, true);
|
||||
assert.equal(renewed.balanceCents, 4010);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+43
-10
@@ -122,11 +122,10 @@ export function detachCurrentTurnImagesForTextProvider(message, canonicalImageUr
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove image attachments from a persisted user message so later turns cannot reuse them.
|
||||
* UI displayText / previewImageUrls are preserved for chat history rendering.
|
||||
* Remove image attachments from a persisted message so later turns cannot reuse them.
|
||||
*/
|
||||
export function scrubUserMessageImageAttachments(message) {
|
||||
if (!message || message.role !== 'user') return { message, changed: false };
|
||||
function scrubPersistedImageAttachments(message) {
|
||||
if (!message) return { message, changed: false };
|
||||
|
||||
const metadata =
|
||||
message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
|
||||
@@ -153,15 +152,13 @@ export function scrubUserMessageImageAttachments(message) {
|
||||
return null;
|
||||
}
|
||||
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
|
||||
const nextText = stripAgentImageText(item.text);
|
||||
const nextText = message.role === 'user' ? stripAgentImageText(item.text) : item.text;
|
||||
if (nextText === item.text) return item;
|
||||
contentChanged = true;
|
||||
return nextText ? { ...item, text: nextText } : null;
|
||||
}).filter(Boolean)
|
||||
: message.content;
|
||||
|
||||
const displayText =
|
||||
typeof metadata.displayText === 'string' ? metadata.displayText : null;
|
||||
const changed = hadImageMetadata || contentChanged;
|
||||
if (!changed) return { message, changed: false };
|
||||
|
||||
@@ -170,17 +167,54 @@ export function scrubUserMessageImageAttachments(message) {
|
||||
...message,
|
||||
content,
|
||||
metadata,
|
||||
...(displayText != null ? {} : {}),
|
||||
},
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove image attachments from a persisted user message so later turns cannot reuse them.
|
||||
* UI displayText / previewImageUrls are preserved for chat history rendering.
|
||||
*/
|
||||
export function scrubUserMessageImageAttachments(message) {
|
||||
if (!message || message.role !== 'user') return { message, changed: false };
|
||||
return scrubPersistedImageAttachments(message);
|
||||
}
|
||||
|
||||
export function messageContentHasImageUrl(content) {
|
||||
if (!Array.isArray(content)) return false;
|
||||
return content.some((item) => item?.type === 'image_url' && item?.image_url?.url);
|
||||
}
|
||||
|
||||
function toolResultParts(item) {
|
||||
const result = item?.toolResult ?? item?.tool_result ?? item?.toolResponse ?? item?.tool_response;
|
||||
const value = result?.value ?? result;
|
||||
if (Array.isArray(value)) return value;
|
||||
if (Array.isArray(value?.content)) return value.content;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* REGRESSION GUARD: vision-turn-read-image-isolation
|
||||
* `read_image` answers with base64 image parts nested in the tool response.
|
||||
* Those parts never appear as `image_url`, but once Goose persists them a
|
||||
* text-only chat provider rejects the whole history with
|
||||
* `unknown variant image_url, expected text`, so they poison the session too.
|
||||
*/
|
||||
export function messageContentHasToolImagePart(content) {
|
||||
if (!Array.isArray(content)) return false;
|
||||
return content.some((item) => {
|
||||
const type = String(item?.type ?? '');
|
||||
if (type !== 'toolResponse' && type !== 'tool_response') return false;
|
||||
return toolResultParts(item).some((part) => String(part?.type ?? '') === 'image');
|
||||
});
|
||||
}
|
||||
|
||||
export function conversationHasToolImageContent(conversation) {
|
||||
if (!Array.isArray(conversation)) return false;
|
||||
return conversation.some((message) => messageContentHasToolImagePart(message?.content));
|
||||
}
|
||||
|
||||
/**
|
||||
* Any persisted image_url part will break DeepSeek / other text-only providers.
|
||||
* User metadata.imageUrls alone is not enough — Goose may have expanded them into
|
||||
@@ -203,9 +237,8 @@ export function scrubConversationHistoricalImageAttachments(conversation, active
|
||||
|
||||
let changed = false;
|
||||
const nextConversation = conversation.map((message) => {
|
||||
if (message?.role !== 'user') return message;
|
||||
if (String(message?.id ?? '').trim() === activeId) return message;
|
||||
const scrubbed = scrubUserMessageImageAttachments(message);
|
||||
const scrubbed = scrubPersistedImageAttachments(message);
|
||||
if (scrubbed.changed) changed = true;
|
||||
return scrubbed.message;
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
buildCurrentTurnImageScopeNote,
|
||||
conversationHasImageUrlContent,
|
||||
conversationHasToolImageContent,
|
||||
dedupeImageUrlsByAssetKey,
|
||||
extractCurrentTurnImageUrls,
|
||||
scrubConversationHistoricalImageAttachments,
|
||||
@@ -103,6 +104,30 @@ test('scrubConversationHistoricalImageAttachments keeps only active turn attachm
|
||||
]);
|
||||
});
|
||||
|
||||
test('scrubConversationHistoricalImageAttachments removes assistant image_url parts', () => {
|
||||
const { conversation, changed } = scrubConversationHistoricalImageAttachments(
|
||||
[
|
||||
{
|
||||
id: 'assistant-old',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: '已识别报告' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'user-new',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '解读报告,生成页面' }],
|
||||
},
|
||||
],
|
||||
'user-new',
|
||||
);
|
||||
|
||||
assert.equal(changed, true);
|
||||
assert.equal(conversation[0].content.some((item) => item.type === 'image_url'), false);
|
||||
});
|
||||
|
||||
test('buildCurrentTurnImageScopeNote states one independent topic per upload', () => {
|
||||
const note = buildCurrentTurnImageScopeNote([
|
||||
{
|
||||
@@ -159,6 +184,64 @@ test('conversationHasImageUrlContent detects historical poison and ignores activ
|
||||
);
|
||||
});
|
||||
|
||||
test('conversationHasToolImageContent detects read_image base64 poison', () => {
|
||||
const readImageTurn = [
|
||||
{
|
||||
id: 'assistant-read',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'toolRequest',
|
||||
id: 'call_1',
|
||||
toolCall: { status: 'success', value: { name: 'read_image', arguments: { source: 'a.jpg' } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'user-read-result',
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'toolResponse',
|
||||
id: 'call_1',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Loaded image from a.jpg (202672 bytes, image/jpeg, 1280x1707).' },
|
||||
{ type: 'image', data: '/9j/4AAQSkZJRg==', mimeType: 'image/jpeg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(conversationHasToolImageContent(readImageTurn), true);
|
||||
// image_url scanning alone cannot see this poison, which is why it needs its own check.
|
||||
assert.equal(conversationHasImageUrlContent(readImageTurn), false);
|
||||
|
||||
assert.equal(
|
||||
conversationHasToolImageContent([
|
||||
{
|
||||
id: 'user-text-tool',
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'toolResponse',
|
||||
id: 'call_2',
|
||||
toolResult: { status: 'success', value: { content: [{ type: 'text', text: '[文件] a.jpg' }] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
false,
|
||||
);
|
||||
assert.equal(conversationHasToolImageContent([]), false);
|
||||
assert.equal(conversationHasToolImageContent(null), false);
|
||||
});
|
||||
|
||||
test('detachCurrentTurnImagesForTextProvider archives urls and keeps the VL note', () => {
|
||||
const detached = detachCurrentTurnImagesForTextProvider(
|
||||
{
|
||||
|
||||
@@ -583,6 +583,12 @@ export async function migrateSchema(pool) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await columnExists(pool, 'h5_users', 'billing_formula'))) {
|
||||
await pool.query(
|
||||
`ALTER TABLE h5_users ADD COLUMN billing_formula ENUM('A','B') NOT NULL DEFAULT 'A' AFTER low_balance_gift_granted_at`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await columnExists(pool, 'h5_user_sessions', 'goosed_node'))) {
|
||||
await pool.query(
|
||||
`ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0`,
|
||||
|
||||
@@ -3,6 +3,78 @@
|
||||
本文件记录已经完成迁移、但仍可能因为 Git 拓扑或遗留 worktree 被误判为“尚未进入 `main`”的分支。
|
||||
它是分支复用、合并、cherry-pick 和清理前的必查清单。
|
||||
|
||||
## `feature/scheduled-task-wechat-delivery-fix`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-08-17
|
||||
分支 HEAD:`1ca06e58`
|
||||
`origin/main` 对应提交:`1ca06e58`
|
||||
|
||||
### 原始用途
|
||||
|
||||
修复唐用户定时新闻任务微信链接被 guard 误杀、页面未落盘即推送、以及 reconcile 后未补发微信的问题。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node --test scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs notification-dispatcher.test.mjs`:22 passed
|
||||
- `node --test wechat-mp.test.mjs`:100 passed(含 verifiedHtmlUrls 用例)
|
||||
- 103 快速发布绑定 `1ca06e58`
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
|
||||
|
||||
## `feature/production-inspection-fixes`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-08-16
|
||||
分支 HEAD:`843e4d10`
|
||||
`origin/main` 对应提交:`843e4d10`
|
||||
|
||||
### 原始用途
|
||||
|
||||
103 生产巡检后续修复:定时任务去重、orphan 交付契约 reconcile(HTML 缺失 mark failed)、MindSpace remote 超时 30s、103 数据/Goosed harness 修复脚本。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node --test scheduled-task-executor.test.mjs scheduled-task-service.test.mjs`:29 passed
|
||||
- 103 快速发布 `memind-portal-runtime-20260816-085224-843e4d10`,Portal 8081 健康
|
||||
- 103 数据修复:取消唐用户 4 个重复/废弃定时任务,保留 5:30 新闻 + 8:00 天气各 1 个
|
||||
- Goosed `run-goosed-native.sh` PATH 已修补,18007–18014 plist PATH 已更新
|
||||
- memind_adm LaunchAgent 脚本已部署,8085/5174 健康
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
|
||||
|
||||
## `feature/scheduled-task-page-delivery-fix`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-08-16
|
||||
分支 HEAD:`7c0ed58a`
|
||||
`origin/main` 对应提交:`7c0ed58a`
|
||||
|
||||
### 原始用途
|
||||
|
||||
修复唐用户每日新闻定时任务推送链接后页面 409(交付契约卡在 `preparing`)的问题:定时任务路径补齐契约 prepare/release、有限重试、推送前门禁,以及 worker 扫描 reconcile。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node --test scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs`:14 passed
|
||||
- `node scripts/run-memind-tests.mjs --mode changed`:通过
|
||||
- 发包前测试 `db/capabilities/llm-providers/wechat-mp`:171 passed
|
||||
- 103 快速发布 `memind-portal-runtime-20260816-082806-7c0ed58a`,Portal 8081 健康,新闻页 200
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
|
||||
|
||||
## `feature/gate-data01-delivery-keywords`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| [memory-v2-candidate-and-lifecycle.md](./memory-v2-candidate-and-lifecycle.md) | 候选记忆表幂等初始化、Portal fail-open、生命周期 off/canary/active 作用域 |
|
||||
| [episodic-history-recall.md](./episodic-history-recall.md) | 历史会话召回、用户隔离、旧快照回退、提示注入与 off/canary/active 灰度 |
|
||||
| [mindspace-seo-geo.md](./mindspace-seo-geo.md) | 公开页 SEO/GEO 注入、私有页 noindex、sitemap/llms.txt 与百度推送开关 |
|
||||
| [vision-turn-read-image-isolation.md](./vision-turn-read-image-isolation.md) | 图片轮次禁用 `read_image`、视觉提示硬约束、`read_image` 工具图片污染检测与会话轮换 |
|
||||
|
||||
## 自动化
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 图片轮次读图工具隔离守卫
|
||||
|
||||
## 已知故障
|
||||
|
||||
2026-08-22 服务号用户唐连发 4 张图后要求「把那几张图片做成主题页面」,连续三次没有拿到页面。
|
||||
|
||||
链路是这样断的:
|
||||
|
||||
1. `attachRecentMediaForFollowup` 正确把 4 张图挂到了这一轮,Qwen VL 也成功产出了图片描述,
|
||||
并注入进 Agent 提示。
|
||||
2. Agent 拿到描述后,仍然逐张调用 `read_image` 去「确认图片内容」。
|
||||
3. `read_image` 的工具结果不是 `image_url`,而是嵌在 `toolResponse.toolResult.value.content[]`
|
||||
里的 base64 `image` 块,4 张图累计 1.5MB 以上。Goose 会把它们持久化进会话历史。
|
||||
4. 下一次请求把整段历史发给纯文本聊天模型(DeepSeek),上游直接返回
|
||||
`unknown variant image_url, expected text`,Agent 还没走到 `write_file` 就中断。
|
||||
5. 页面交付是 fail-closed 的:没有新落盘的 HTML,就不会发占位链接,用户只看到
|
||||
「这次页面没有按服务号页面技能真正生成成功」。
|
||||
|
||||
原有的两道防线都没拦住:
|
||||
|
||||
- `conversationHasImageUrlContent` 只扫 `image_url`,看不见 `read_image` 留下的工具图片块,
|
||||
所以微信侧的会话轮换不会触发,同一个被污染的会话被反复复用。
|
||||
- `agent-run-gateway` 的 `SESSION_VISUAL_CONTEXT_UNSUPPORTED` 视觉降级是**事后**补救,
|
||||
只覆盖 H5 Agent Run,服务号回复路径没有对应保护。
|
||||
|
||||
## 必须保留的行为
|
||||
|
||||
1. 本轮消息带图且已配置图片模型(`llmProviderService.hasVisionKey()`)时,
|
||||
`prepareSessionReplyBody` 必须以 `disableImageReading` 下发会话策略,
|
||||
使这一轮的 `developer` 扩展不含 `read_image`。视觉理解由图片模型独占,
|
||||
聊天模型不承担读图。
|
||||
2. 不带图的轮次必须保留 `read_image`。禁用是按轮次生效的,不能把会话永久降级。
|
||||
3. `buildVisionPayload` 注入的提示必须明确写出「本轮不会再提供读图工具 + 禁止调用
|
||||
`read_image`」,并说明强行读图会让本轮及后续请求全部失败。
|
||||
4. 图片模型没能产出描述时,提示必须改口为「不要臆造画面细节」,不能继续声称
|
||||
「依据上面的描述写文案」。此时仍然禁用读图 —— 纯文本模型看不到图,
|
||||
放开 `read_image` 只会让整轮崩掉,而图片嵌入路径依然可用。
|
||||
5. `conversationHasToolImageContent` 必须能识别 `toolResponse` / `tool_response` 里
|
||||
`toolResult.value.content[].type === 'image'` 的工具图片块,
|
||||
并覆盖 `toolResult` / `tool_result` / 直接数组等结构变体。
|
||||
6. `rotateWechatSessionIfImagePolluted` 必须同时检查 `image_url` 污染和工具图片污染,
|
||||
任一命中都要在回复前换掉会话。历史遗留的被污染会话靠这条自愈。
|
||||
7. `agent-run-gateway` 的事后视觉降级(`SESSION_VISUAL_CONTEXT_UNSUPPORTED` →
|
||||
新会话 + `disableImageReading: true`)保留为兜底,不得因为新增事前预防而删除。
|
||||
|
||||
## 改动前必跑
|
||||
|
||||
```bash
|
||||
node --test tkmind-proxy.test.mjs chat-image-turn-scope.test.mjs wechat-mp.test.mjs agent-run-gateway.test.mjs
|
||||
npm run verify:h5-session-patches
|
||||
```
|
||||
|
||||
端到端(需本地 Portal 8081 + goosed):
|
||||
|
||||
```bash
|
||||
node scripts/run-scenario-test.mjs --scenario image-theme-page
|
||||
```
|
||||
|
||||
该场景上传 4 张几何图、要求做成主题页面,断言页面生成、公网 200、4 张图全部嵌入,
|
||||
并禁止回复里出现 `unknown variant`。2026-08-22 首次通过时会话内 `read_image` 调用为 0、
|
||||
持久化工具图片块为 0,工具序列是 `load_skill → write_file → read_file`。
|
||||
|
||||
## 相关代码与用例
|
||||
|
||||
| 位置 | 作用 |
|
||||
|------|------|
|
||||
| `tkmind-proxy.mjs` · `prepareSessionReplyBody` | 事前禁用本轮 `read_image` |
|
||||
| `tkmind-proxy.mjs` · `buildVisionPayload` | 注入禁止读图的硬性提示 |
|
||||
| `chat-image-turn-scope.mjs` · `conversationHasToolImageContent` | 识别 `read_image` 工具图片污染 |
|
||||
| `wechat-mp.mjs` · `rotateWechatSessionIfImagePolluted` | 回复前轮换被污染会话 |
|
||||
| `capabilities.mjs` · `withoutSessionImageRead` | 从会话策略里摘掉 `read_image` |
|
||||
|
||||
| 用例 | 覆盖 |
|
||||
|------|------|
|
||||
| `tkmind-proxy.test.mjs` · `image turns drop read_image so vision results cannot poison the text provider` | 带图轮次下发的 `developer` 工具不含 `read_image` |
|
||||
| `tkmind-proxy.test.mjs` · `text-only turns keep read_image available` | 纯文本轮次不降级 |
|
||||
| `tkmind-proxy.test.mjs` · `submitSessionReplyForUser applies the shared Qwen vision preprocessing path` | 提示中包含禁止读图约束 |
|
||||
| `chat-image-turn-scope.test.mjs` · `conversationHasToolImageContent detects read_image base64 poison` | 工具图片块识别,且 `image_url` 扫描确实看不见它 |
|
||||
| `wechat-mp.test.mjs` · `wechat mp rotates a session poisoned by read_image tool results` | 服务号在工具图片污染时换会话 |
|
||||
@@ -1,4 +1,6 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function normalizeDeliveryRelativePath(value) {
|
||||
const path = String(value ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
@@ -47,17 +49,45 @@ export async function markPageDeliveryContractReady({ pool, userId, relativePath
|
||||
return Number(result?.affectedRows ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function markPageDeliveryContractFailed({
|
||||
pool,
|
||||
userId,
|
||||
relativePath,
|
||||
failureReason = 'delivery_material_missing',
|
||||
} = {}) {
|
||||
const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath);
|
||||
if (!pool || !userId || !workspaceRelativePath) return false;
|
||||
const now = Date.now();
|
||||
const [result] = await pool.query(
|
||||
`UPDATE h5_page_delivery_contracts
|
||||
SET status = 'failed', failure_reason = ?, updated_at = ?
|
||||
WHERE user_id = ? AND workspace_relative_path = ? AND status = 'preparing'`,
|
||||
[String(failureReason ?? 'delivery_material_missing'), now, userId, workspaceRelativePath],
|
||||
);
|
||||
return Number(result?.affectedRows ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function scheduledTaskPublicHtmlExists(publishDir, relativePath) {
|
||||
const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath);
|
||||
if (!publishDir || !workspaceRelativePath) return false;
|
||||
return fs.existsSync(path.join(publishDir, workspaceRelativePath));
|
||||
}
|
||||
|
||||
export async function releaseMaterializedPageDeliveryContracts({
|
||||
pool,
|
||||
userId,
|
||||
relativePaths = [],
|
||||
allowPgRequired = false,
|
||||
publishDir = null,
|
||||
} = {}) {
|
||||
if (!pool || !userId) return [];
|
||||
const released = [];
|
||||
for (const rawPath of relativePaths) {
|
||||
const workspaceRelativePath = normalizeDeliveryRelativePath(rawPath);
|
||||
if (!workspaceRelativePath) continue;
|
||||
if (publishDir && !scheduledTaskPublicHtmlExists(publishDir, workspaceRelativePath)) {
|
||||
continue;
|
||||
}
|
||||
const contract = await getPageDeliveryContract({
|
||||
pool,
|
||||
userId,
|
||||
|
||||
@@ -86,7 +86,7 @@ export function resolveMindSpaceServerRuntimeOptions(h5Root, env = process.env)
|
||||
authToken: String(env.MINDSPACE_REMOTE_AUTH_TOKEN ?? '').trim(),
|
||||
operationBasePath: String(env.MINDSPACE_REMOTE_OPERATION_BASE_PATH ?? '/mindspace/v1/adapter')
|
||||
.trim(),
|
||||
timeoutMs: Math.max(1000, Number(env.MINDSPACE_REMOTE_TIMEOUT_MS ?? 15_000)),
|
||||
timeoutMs: Math.max(1000, Number(env.MINDSPACE_REMOTE_TIMEOUT_MS ?? 30_000)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
function resolveWechatDispatchSent(result) {
|
||||
if (result && typeof result === 'object') {
|
||||
return result.sent !== false && !result.deferred && !result.skipped;
|
||||
}
|
||||
return result !== false;
|
||||
}
|
||||
|
||||
export function createNotificationDispatcher({ sendWechatTextToUser, logger = console } = {}) {
|
||||
const sendWechat = async (userId, text) => {
|
||||
const sendWechat = async (userId, text, options = {}) => {
|
||||
if (typeof sendWechatTextToUser !== 'function') return false;
|
||||
await sendWechatTextToUser(userId, text);
|
||||
return true;
|
||||
const result = await sendWechatTextToUser(userId, text, options);
|
||||
return resolveWechatDispatchSent(result);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -16,13 +23,14 @@ export function createNotificationDispatcher({ sendWechatTextToUser, logger = co
|
||||
});
|
||||
return sent;
|
||||
},
|
||||
async sendScheduleNotification({ userId, text }) {
|
||||
const sent = await sendWechat(userId, text);
|
||||
async sendScheduleNotification({ userId, text, verifiedHtmlUrls = [] }) {
|
||||
const sent = await sendWechat(userId, text, { verifiedHtmlUrls });
|
||||
logger.info?.('Notification dispatch:', {
|
||||
type: 'schedule_notification',
|
||||
userId,
|
||||
dedupeKey: null,
|
||||
sent,
|
||||
verifiedHtmlCount: verifiedHtmlUrls.length,
|
||||
});
|
||||
return sent;
|
||||
},
|
||||
|
||||
@@ -73,11 +73,30 @@ test('notification dispatcher forwards schedule notification text unchanged', as
|
||||
userId: 'user-2',
|
||||
dedupeKey: null,
|
||||
sent: true,
|
||||
verifiedHtmlCount: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('notification dispatcher returns false when wechat sender defers delivery', async () => {
|
||||
const dispatcher = createNotificationDispatcher({
|
||||
async sendWechatTextToUser() {
|
||||
return { sent: false, deferred: true, errcode: 45015 };
|
||||
},
|
||||
logger: { info() {} },
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await dispatcher.sendScheduleNotification({
|
||||
userId: 'user-4',
|
||||
text: '定时任务完成',
|
||||
verifiedHtmlUrls: ['https://m.tkmind.cn/MindSpace/user-4/public/news.html'],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('notification dispatcher returns false when wechat sender is unavailable', async () => {
|
||||
const logs = [];
|
||||
const dispatcher = createNotificationDispatcher({
|
||||
@@ -120,6 +139,7 @@ test('notification dispatcher returns false when wechat sender is unavailable',
|
||||
userId: 'user-3',
|
||||
dedupeKey: null,
|
||||
sent: false,
|
||||
verifiedHtmlCount: 0,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -129,22 +129,16 @@ test('production release rejects --skip-tests before repository or network prefl
|
||||
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /103 只读预检/);
|
||||
});
|
||||
|
||||
test('fast production release allows --skip-tests but still requires check-release-ready', () => {
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[
|
||||
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
|
||||
'--mode',
|
||||
'fast',
|
||||
'--skip-tests',
|
||||
'--skip-build',
|
||||
'--dry-run',
|
||||
],
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
test('fast production release allows --skip-tests but still requires check-release-ready', async () => {
|
||||
const source = await fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
|
||||
'utf8',
|
||||
);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.doesNotMatch(result.stderr, /标准发布禁止 --skip-tests/);
|
||||
assert.match(`${result.stdout}\n${result.stderr}`, /快速发布:跳过额外本地测试/);
|
||||
assert.match(source, /ALLOW_MAIN_RELEASE=1 bash "\$\{ROOT\}\/scripts\/check-release-ready\.sh"/);
|
||||
assert.match(source, /快速发布:跳过额外本地测试/);
|
||||
const skipTestsBlockEnd = source.indexOf('fi\n\nif [[ "${DRY_RUN}" -eq 0 ]]; then');
|
||||
const checkReleaseIdx = source.indexOf('check-release-ready.sh');
|
||||
assert.ok(skipTestsBlockEnd >= 0 && checkReleaseIdx > skipTestsBlockEnd);
|
||||
});
|
||||
|
||||
test('fast release wrapper delegates to runtime prod script with --mode fast', async () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"id": "image-theme-page",
|
||||
"name": "多图主题页面(视觉链路回归)",
|
||||
"description": "上传 4 张图 → 要求做成主题页面 → 验证页面生成、公网可访问、4 张图都嵌进页面;复现唐用户 read_image 污染文本模型会话导致页面不生成的故障",
|
||||
"account": {
|
||||
"username": "john2",
|
||||
"password": "888888"
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"action": "login",
|
||||
"label": "登录 john2 账户"
|
||||
},
|
||||
{
|
||||
"action": "upload_images",
|
||||
"label": "上传 4 张图片",
|
||||
"categoryCode": "oa",
|
||||
"images": [
|
||||
{ "filename": "theme-1.jpg", "background": "#1e3a8a", "shape": "circle", "accent": "#fbbf24" },
|
||||
{ "filename": "theme-2.jpg", "background": "#7f1d1d", "shape": "square", "accent": "#fca5a5" },
|
||||
{ "filename": "theme-3.jpg", "background": "#064e3b", "shape": "triangle", "accent": "#6ee7b7" },
|
||||
{ "filename": "theme-4.jpg", "background": "#3b0764", "shape": "diamond", "accent": "#e9d5ff" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"action": "chat",
|
||||
"label": "要求把 4 张图做成主题页面",
|
||||
"message": "把我刚才发的这 4 张图片做成一个主题页面,每张图配一段文字",
|
||||
"useUploadedAssets": true,
|
||||
"expect": {
|
||||
"assistantMinChars": 20,
|
||||
"timeoutMs": 420000,
|
||||
"page": {
|
||||
"requirePublicLink": true,
|
||||
"requireHttp200": true,
|
||||
"requireUploadedImages": true
|
||||
},
|
||||
"forbidReplyPatterns": [
|
||||
"unknown variant",
|
||||
"没有按服务号页面技能真正生成成功"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+410
-31
@@ -1,7 +1,14 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs';
|
||||
import { releaseMaterializedPageDeliveryContracts } from './mindspace-delivery-contract.mjs';
|
||||
import {
|
||||
markPageDeliveryContractFailed,
|
||||
markPageDeliveryContractReady,
|
||||
normalizeDeliveryRelativePath,
|
||||
preparePageDeliveryContract,
|
||||
releaseMaterializedPageDeliveryContracts,
|
||||
} from './mindspace-delivery-contract.mjs';
|
||||
import {
|
||||
collectOwnPublicHtmlRelativePaths,
|
||||
materializeMissingPublicHtmlWrites,
|
||||
@@ -71,6 +78,23 @@ export function formatScheduledTaskDeliveryMessage(task, deliveryText) {
|
||||
return `${header}\n\n${body}`.trim();
|
||||
}
|
||||
|
||||
export function resolveScheduledTaskPublicBaseUrl(env = process.env) {
|
||||
return String(env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function buildScheduledTaskVerifiedHtmlUrls(userId, readyPaths = [], {
|
||||
publicBaseUrl = resolveScheduledTaskPublicBaseUrl(),
|
||||
} = {}) {
|
||||
const normalizedUserId = String(userId ?? '').trim();
|
||||
if (!normalizedUserId) return [];
|
||||
return [...new Set(
|
||||
(Array.isArray(readyPaths) ? readyPaths : [])
|
||||
.map((relativePath) => normalizeDeliveryRelativePath(relativePath))
|
||||
.filter(Boolean)
|
||||
.map((relativePath) => `${publicBaseUrl}/MindSpace/${normalizedUserId}/${relativePath}`),
|
||||
)];
|
||||
}
|
||||
|
||||
const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [
|
||||
/需确认/u,
|
||||
/请确认/u,
|
||||
@@ -83,6 +107,50 @@ const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [
|
||||
/缺(?:少|失)/u,
|
||||
];
|
||||
|
||||
const DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
export function resolveScheduledTaskDeliveryPollIntervalMs(
|
||||
env = process.env,
|
||||
) {
|
||||
const parsed = Number(
|
||||
env.H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS
|
||||
?? DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS,
|
||||
);
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? parsed
|
||||
: DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
/** @deprecated use resolveScheduledTaskDeliveryPollIntervalMs */
|
||||
export function resolveScheduledTaskDeliveryRetryDelaysMs(env = process.env) {
|
||||
const interval = resolveScheduledTaskDeliveryPollIntervalMs(env);
|
||||
return [0, interval, interval, interval];
|
||||
}
|
||||
|
||||
export function extractPublicHtmlPathsFromText(text) {
|
||||
const paths = new Set();
|
||||
const normalized = String(text ?? '');
|
||||
for (const match of normalized.matchAll(
|
||||
/public\/[^\s"'<>]+\.html/gi,
|
||||
)) {
|
||||
paths.add(match[0].replace(/\\/g, '/'));
|
||||
}
|
||||
for (const match of normalized.matchAll(
|
||||
/\/MindSpace\/[^/\s"'<>]+\/(public\/[^\s"'<>]+\.html)/gi,
|
||||
)) {
|
||||
paths.add(match[1].replace(/\\/g, '/'));
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
export function deliveryTextPromisesPublicHtml(text) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
if (extractPublicHtmlPathsFromText(normalized).length > 0) return true;
|
||||
return /\/MindSpace\/[^/\s"'<>]+/i.test(normalized)
|
||||
&& /\.html/i.test(normalized);
|
||||
}
|
||||
|
||||
export function looksLikeScheduledTaskNonDelivery(text, { readyPaths = [] } = {}) {
|
||||
if (Array.isArray(readyPaths) && readyPaths.length > 0) return false;
|
||||
const normalized = String(text ?? '').trim();
|
||||
@@ -90,6 +158,7 @@ export function looksLikeScheduledTaskNonDelivery(text, { readyPaths = [] } = {}
|
||||
if (SCHEDULED_TASK_CLARIFICATION_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||||
return true;
|
||||
}
|
||||
if (deliveryTextPromisesPublicHtml(normalized)) return true;
|
||||
if (/https?:\/\//i.test(normalized)) return false;
|
||||
if (/public\/[^\s]+\.html/i.test(normalized)) return false;
|
||||
if (/页面链接/u.test(normalized)) return false;
|
||||
@@ -100,18 +169,35 @@ export function looksLikeScheduledTaskNonDelivery(text, { readyPaths = [] } = {}
|
||||
return normalized.length < 12;
|
||||
}
|
||||
|
||||
export async function finalizeScheduledTaskPageDelivery({
|
||||
pool,
|
||||
async function refreshScheduledTaskMessages({
|
||||
userId,
|
||||
sessionId,
|
||||
tkmindProxy,
|
||||
sessionSnapshotService,
|
||||
}) {
|
||||
if (typeof tkmindProxy?.fetchSessionConversationForUser === 'function') {
|
||||
return tkmindProxy
|
||||
.fetchSessionConversationForUser(userId, sessionId)
|
||||
.catch(() => []);
|
||||
}
|
||||
if (typeof sessionSnapshotService?.get === 'function') {
|
||||
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
|
||||
return snapshot?.messages ?? snapshot?.conversation?.messages ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function collectScheduledTaskPageRelativePaths({
|
||||
messages,
|
||||
publishDir,
|
||||
currentUser = null,
|
||||
logger = console,
|
||||
} = {}) {
|
||||
if (!pool || !userId || !publishDir) return [];
|
||||
|
||||
const materialized = materializeMissingPublicHtmlWrites({ messages, publishDir });
|
||||
currentUser,
|
||||
userId,
|
||||
deliveryText = '',
|
||||
}) {
|
||||
const materialized = materializeMissingPublicHtmlWrites({
|
||||
messages,
|
||||
publishDir,
|
||||
});
|
||||
const relativePaths = new Set(
|
||||
collectOwnPublicHtmlRelativePaths({
|
||||
messages,
|
||||
@@ -121,6 +207,35 @@ export async function finalizeScheduledTaskPageDelivery({
|
||||
skipped: materialized.skipped,
|
||||
}),
|
||||
);
|
||||
for (const relativePath of extractPublicHtmlPathsFromText(deliveryText)) {
|
||||
relativePaths.add(relativePath);
|
||||
}
|
||||
return {
|
||||
materialized,
|
||||
relativePaths: [...relativePaths],
|
||||
};
|
||||
}
|
||||
|
||||
export async function finalizeScheduledTaskPageDelivery({
|
||||
pool,
|
||||
userId,
|
||||
sessionId,
|
||||
messages,
|
||||
publishDir,
|
||||
currentUser = null,
|
||||
deliveryText = '',
|
||||
logger = console,
|
||||
} = {}) {
|
||||
if (!pool || !userId || !publishDir) return [];
|
||||
|
||||
const { relativePaths } = collectScheduledTaskPageRelativePaths({
|
||||
messages,
|
||||
publishDir,
|
||||
currentUser,
|
||||
userId,
|
||||
deliveryText,
|
||||
});
|
||||
const pathsToRelease = new Set(relativePaths);
|
||||
|
||||
if (sessionId) {
|
||||
const [rows] = await pool.query(
|
||||
@@ -130,15 +245,33 @@ export async function finalizeScheduledTaskPageDelivery({
|
||||
[userId],
|
||||
);
|
||||
for (const row of rows ?? []) {
|
||||
if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path);
|
||||
if (row?.workspace_relative_path) {
|
||||
pathsToRelease.add(row.workspace_relative_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const relativePath of pathsToRelease) {
|
||||
await preparePageDeliveryContract({
|
||||
pool,
|
||||
userId,
|
||||
requestId: sessionId,
|
||||
relativePath,
|
||||
pgRequired: false,
|
||||
}).catch((error) => {
|
||||
logger.warn?.(
|
||||
`[ScheduledTask] prepare delivery contract failed for ${relativePath}:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const readyPaths = await releaseMaterializedPageDeliveryContracts({
|
||||
pool,
|
||||
userId,
|
||||
relativePaths: [...relativePaths],
|
||||
relativePaths: [...pathsToRelease],
|
||||
allowPgRequired: true,
|
||||
publishDir,
|
||||
}).catch((error) => {
|
||||
logger.warn?.('[ScheduledTask] release delivery contracts failed:', error);
|
||||
return [];
|
||||
@@ -153,6 +286,246 @@ export async function finalizeScheduledTaskPageDelivery({
|
||||
return readyPaths;
|
||||
}
|
||||
|
||||
export async function awaitScheduledTaskPageDelivery({
|
||||
pool,
|
||||
userId,
|
||||
sessionId,
|
||||
messages,
|
||||
publishDir,
|
||||
deliveryText = '',
|
||||
task = null,
|
||||
tkmindProxy = null,
|
||||
sessionSnapshotService = null,
|
||||
timeoutMs = 15 * 60 * 1000,
|
||||
pollIntervalMs = resolveScheduledTaskDeliveryPollIntervalMs(),
|
||||
sleepFn = (delayMs) => new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
}),
|
||||
logger = console,
|
||||
} = {}) {
|
||||
let currentMessages = Array.isArray(messages) ? messages : [];
|
||||
let currentDeliveryText = String(deliveryText ?? '');
|
||||
let readyPaths = [];
|
||||
const deadline = Date.now() + Math.max(Number(pollIntervalMs) || 0, Number(timeoutMs) || 0);
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
attempt += 1;
|
||||
readyPaths = await finalizeScheduledTaskPageDelivery({
|
||||
pool,
|
||||
userId,
|
||||
sessionId,
|
||||
messages: currentMessages,
|
||||
publishDir,
|
||||
deliveryText: currentDeliveryText,
|
||||
logger,
|
||||
}).catch((error) => {
|
||||
logger.warn?.('[ScheduledTask] finalize page delivery failed:', error);
|
||||
return [];
|
||||
});
|
||||
|
||||
const promisesHtml = deliveryTextPromisesPublicHtml(currentDeliveryText)
|
||||
|| collectScheduledTaskPageRelativePaths({
|
||||
messages: currentMessages,
|
||||
publishDir,
|
||||
userId,
|
||||
deliveryText: currentDeliveryText,
|
||||
}).relativePaths.length > 0;
|
||||
if (!promisesHtml || readyPaths.length > 0 || Date.now() >= deadline) {
|
||||
if (promisesHtml && readyPaths.length === 0 && Date.now() >= deadline) {
|
||||
logger.warn?.('[ScheduledTask] page delivery timed out while preparing', {
|
||||
userId,
|
||||
sessionId,
|
||||
attempt,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
logger.warn?.('[ScheduledTask] page delivery still preparing', {
|
||||
userId,
|
||||
sessionId,
|
||||
attempt,
|
||||
nextPollMs: pollIntervalMs,
|
||||
});
|
||||
await sleepFn(pollIntervalMs);
|
||||
currentMessages = await refreshScheduledTaskMessages({
|
||||
userId,
|
||||
sessionId,
|
||||
tkmindProxy,
|
||||
sessionSnapshotService,
|
||||
});
|
||||
if (task) {
|
||||
currentDeliveryText = extractScheduledTaskDeliveryText(currentMessages, task);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: currentMessages,
|
||||
readyPaths,
|
||||
};
|
||||
}
|
||||
|
||||
export async function reconcileStuckStaticPageDeliveryContracts({
|
||||
pool,
|
||||
h5Root,
|
||||
limit = 20,
|
||||
logger = console,
|
||||
} = {}) {
|
||||
if (!pool || !h5Root) return [];
|
||||
const [rows] = await pool.query(
|
||||
`SELECT user_id, workspace_relative_path
|
||||
FROM h5_page_delivery_contracts
|
||||
WHERE status = 'preparing' AND data_mode = 'static'
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT ?`,
|
||||
[Math.max(1, Number(limit) || 20)],
|
||||
);
|
||||
const released = [];
|
||||
for (const row of rows ?? []) {
|
||||
const relativePath = normalizeDeliveryRelativePath(
|
||||
row.workspace_relative_path,
|
||||
);
|
||||
if (!relativePath) continue;
|
||||
const filePath = path.join(
|
||||
h5Root,
|
||||
'MindSpace',
|
||||
row.user_id,
|
||||
relativePath,
|
||||
);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
if (
|
||||
await markPageDeliveryContractFailed({
|
||||
pool,
|
||||
userId: row.user_id,
|
||||
relativePath,
|
||||
failureReason: 'materialized_html_missing',
|
||||
})
|
||||
) {
|
||||
logger.info?.('[ScheduledTask] failed orphan static delivery contract', {
|
||||
userId: row.user_id,
|
||||
relativePath,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
await markPageDeliveryContractReady({
|
||||
pool,
|
||||
userId: row.user_id,
|
||||
relativePath,
|
||||
})
|
||||
) {
|
||||
released.push({ userId: row.user_id, relativePath });
|
||||
logger.info?.('[ScheduledTask] reconciled static delivery contract', {
|
||||
userId: row.user_id,
|
||||
relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
export async function resendScheduledTaskWechatForReadyPage({
|
||||
pool,
|
||||
userId,
|
||||
relativePath,
|
||||
notificationDispatcher = null,
|
||||
publicBaseUrl = resolveScheduledTaskPublicBaseUrl(),
|
||||
lookbackMs = 24 * 60 * 60 * 1000,
|
||||
now = Date.now(),
|
||||
logger = console,
|
||||
} = {}) {
|
||||
const normalizedPath = normalizeDeliveryRelativePath(relativePath);
|
||||
const normalizedUserId = String(userId ?? '').trim();
|
||||
if (
|
||||
!pool
|
||||
|| !normalizedUserId
|
||||
|| !normalizedPath
|
||||
|| typeof notificationDispatcher?.sendScheduleNotification !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, title, notify_channel, last_result_json, last_run_at
|
||||
FROM h5_scheduled_tasks
|
||||
WHERE user_id = ? AND last_run_at IS NOT NULL AND last_run_at >= ?
|
||||
ORDER BY last_run_at DESC
|
||||
LIMIT 20`,
|
||||
[normalizedUserId, now - Math.max(lookbackMs, 60_000)],
|
||||
);
|
||||
|
||||
const verifiedUrl = `${publicBaseUrl}/MindSpace/${normalizedUserId}/${normalizedPath}`;
|
||||
for (const row of rows ?? []) {
|
||||
const channel = row.notify_channel ?? 'both';
|
||||
if (channel !== 'wechat' && channel !== 'both') continue;
|
||||
|
||||
const lastResult = row.last_result_json && typeof row.last_result_json === 'object'
|
||||
? row.last_result_json
|
||||
: null;
|
||||
const deliveryText = String(lastResult?.deliveryText ?? '');
|
||||
if (
|
||||
!deliveryText.includes(normalizedPath)
|
||||
&& !deliveryText.includes(verifiedUrl)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const priorDelivery = lastResult?.wechatDelivery;
|
||||
if (
|
||||
priorDelivery?.sentAt
|
||||
&& Array.isArray(priorDelivery.relativePaths)
|
||||
&& priorDelivery.relativePaths.includes(normalizedPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = formatScheduledTaskDeliveryMessage(
|
||||
{ title: row.title },
|
||||
deliveryText,
|
||||
);
|
||||
const verifiedHtmlUrls = buildScheduledTaskVerifiedHtmlUrls(
|
||||
normalizedUserId,
|
||||
[normalizedPath],
|
||||
{ publicBaseUrl },
|
||||
);
|
||||
const sent = await notificationDispatcher.sendScheduleNotification({
|
||||
userId: normalizedUserId,
|
||||
text,
|
||||
verifiedHtmlUrls,
|
||||
}).catch((error) => {
|
||||
logger.warn?.('[ScheduledTask] reconcile wechat resend failed:', error);
|
||||
return false;
|
||||
});
|
||||
if (!sent) continue;
|
||||
|
||||
const nextResult = {
|
||||
...(lastResult ?? {}),
|
||||
wechatDelivery: {
|
||||
sentAt: now,
|
||||
relativePaths: [normalizedPath],
|
||||
source: 'reconcile',
|
||||
},
|
||||
};
|
||||
await pool.query(
|
||||
`UPDATE h5_scheduled_tasks
|
||||
SET last_result_json = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[JSON.stringify(nextResult), now, row.id, normalizedUserId],
|
||||
);
|
||||
logger.info?.('[ScheduledTask] reconciled wechat delivery resent', {
|
||||
userId: normalizedUserId,
|
||||
taskId: row.id,
|
||||
relativePath: normalizedPath,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function executeScheduledTask(task, {
|
||||
userAuth,
|
||||
tkmindProxy,
|
||||
@@ -204,37 +577,43 @@ export async function executeScheduledTask(task, {
|
||||
{ timeoutMs },
|
||||
);
|
||||
|
||||
let messages = [];
|
||||
if (typeof tkmindProxy.fetchSessionConversationForUser === 'function') {
|
||||
messages = await tkmindProxy.fetchSessionConversationForUser(task.userId, sessionId).catch(() => []);
|
||||
} else if (typeof sessionSnapshotService?.get === 'function') {
|
||||
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
|
||||
messages = snapshot?.messages ?? snapshot?.conversation?.messages ?? [];
|
||||
}
|
||||
let messages = await refreshScheduledTaskMessages({
|
||||
userId: task.userId,
|
||||
sessionId,
|
||||
tkmindProxy,
|
||||
sessionSnapshotService,
|
||||
});
|
||||
|
||||
const publishDir = h5Root && task.userId
|
||||
? path.join(h5Root, 'MindSpace', task.userId)
|
||||
: null;
|
||||
const readyPaths = await finalizeScheduledTaskPageDelivery({
|
||||
pool,
|
||||
userId: task.userId,
|
||||
sessionId,
|
||||
messages,
|
||||
publishDir,
|
||||
logger,
|
||||
}).catch((error) => {
|
||||
logger.warn?.('[ScheduledTask] finalize page delivery failed:', error);
|
||||
return [];
|
||||
});
|
||||
|
||||
let deliveryText = extractScheduledTaskDeliveryText(messages, task);
|
||||
const deliveryResult = publishDir
|
||||
? await awaitScheduledTaskPageDelivery({
|
||||
pool,
|
||||
userId: task.userId,
|
||||
sessionId,
|
||||
messages,
|
||||
publishDir,
|
||||
deliveryText,
|
||||
task,
|
||||
tkmindProxy,
|
||||
sessionSnapshotService,
|
||||
timeoutMs,
|
||||
logger,
|
||||
})
|
||||
: { messages, readyPaths: [] };
|
||||
messages = deliveryResult.messages;
|
||||
const readyPaths = deliveryResult.readyPaths;
|
||||
|
||||
deliveryText = extractScheduledTaskDeliveryText(messages, task);
|
||||
if (
|
||||
readyPaths.length > 0
|
||||
&& !/https?:\/\//i.test(deliveryText)
|
||||
&& task.userId
|
||||
) {
|
||||
const links = readyPaths.map(
|
||||
(relativePath) => `https://m.tkmind.cn/MindSpace/${task.userId}/${relativePath}`,
|
||||
(relativePath) => `${resolveScheduledTaskPublicBaseUrl()}/MindSpace/${task.userId}/${relativePath}`,
|
||||
);
|
||||
deliveryText = `${deliveryText}\n\n页面链接:\n${links.join('\n')}`.trim();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildScheduledTaskExecutionPrompt,
|
||||
buildScheduledTaskVerifiedHtmlUrls,
|
||||
deliveryTextPromisesPublicHtml,
|
||||
extractPublicHtmlPathsFromText,
|
||||
extractScheduledTaskDeliveryText,
|
||||
finalizeScheduledTaskPageDelivery,
|
||||
formatScheduledTaskDeliveryMessage,
|
||||
looksLikeScheduledTaskNonDelivery,
|
||||
reconcileStuckStaticPageDeliveryContracts,
|
||||
resolveScheduledTaskDeliveryPollIntervalMs,
|
||||
resolveScheduledTaskDeliveryRetryDelaysMs,
|
||||
} from './scheduled-task-executor.mjs';
|
||||
|
||||
test('buildScheduledTaskExecutionPrompt includes task spec and automation marker', () => {
|
||||
@@ -32,6 +42,12 @@ test('looksLikeScheduledTaskNonDelivery detects clarification replies', () => {
|
||||
looksLikeScheduledTaskNonDelivery('页面已生成:https://example.com/news.html'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
looksLikeScheduledTaskNonDelivery(
|
||||
'页面已生成:https://m.tkmind.cn/MindSpace/user-1/public/daily-news.html',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
looksLikeScheduledTaskNonDelivery('好的', { readyPaths: ['public/news.html'] }),
|
||||
false,
|
||||
@@ -41,6 +57,166 @@ test('looksLikeScheduledTaskNonDelivery detects clarification replies', () => {
|
||||
assert.equal(looksLikeScheduledTaskNonDelivery('好的'), true);
|
||||
});
|
||||
|
||||
test('extractPublicHtmlPathsFromText parses MindSpace public links', () => {
|
||||
assert.deepEqual(
|
||||
extractPublicHtmlPathsFromText(
|
||||
'链接:https://m.tkmind.cn/MindSpace/u/public/daily-news-0816.html',
|
||||
),
|
||||
['public/daily-news-0816.html'],
|
||||
);
|
||||
});
|
||||
|
||||
test('deliveryTextPromisesPublicHtml detects page delivery replies', () => {
|
||||
assert.equal(
|
||||
deliveryTextPromisesPublicHtml('今日摘要:天气不错'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
deliveryTextPromisesPublicHtml('public/daily-news.html 已生成'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveScheduledTaskDeliveryPollIntervalMs reads env override', () => {
|
||||
assert.equal(
|
||||
resolveScheduledTaskDeliveryPollIntervalMs({
|
||||
H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS: '7500',
|
||||
}),
|
||||
7500,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveScheduledTaskDeliveryRetryDelaysMs keeps backward-compatible shape', () => {
|
||||
assert.deepEqual(
|
||||
resolveScheduledTaskDeliveryRetryDelaysMs({
|
||||
H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS: '100',
|
||||
}),
|
||||
[0, 100, 100, 100],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScheduledTaskVerifiedHtmlUrls maps ready paths to public urls', () => {
|
||||
assert.deepEqual(
|
||||
buildScheduledTaskVerifiedHtmlUrls('user-1', ['public/news.html'], {
|
||||
publicBaseUrl: 'https://m.tkmind.cn',
|
||||
}),
|
||||
['https://m.tkmind.cn/MindSpace/user-1/public/news.html'],
|
||||
);
|
||||
});
|
||||
|
||||
test('finalizeScheduledTaskPageDelivery prepares and releases static contracts', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-finalize-'));
|
||||
const publishDir = path.join(dir, 'MindSpace', 'user-1');
|
||||
const relativePath = 'public/news.html';
|
||||
await fs.mkdir(path.dirname(path.join(publishDir, relativePath)), { recursive: true });
|
||||
await fs.writeFile(path.join(publishDir, relativePath), '<html></html>', 'utf8');
|
||||
const calls = [];
|
||||
const pool = {
|
||||
async query(sql, params) {
|
||||
calls.push({ sql, params });
|
||||
if (sql.includes('FROM h5_page_delivery_contracts')) {
|
||||
return [[{ workspace_relative_path: relativePath }]];
|
||||
}
|
||||
if (sql.includes('SELECT id, data_mode, status')) {
|
||||
return [[{ id: 'c1', data_mode: 'static', status: 'preparing' }]];
|
||||
}
|
||||
if (sql.includes("SET status = 'ready'")) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
const readyPaths = await finalizeScheduledTaskPageDelivery({
|
||||
pool,
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
publishDir,
|
||||
deliveryText: 'public/news.html 已生成',
|
||||
logger: { warn() {}, info() {} },
|
||||
});
|
||||
assert.deepEqual(readyPaths, [relativePath]);
|
||||
assert.ok(calls.some((call) => call.sql.includes('INSERT INTO h5_page_delivery_contracts')));
|
||||
});
|
||||
|
||||
test('finalizeScheduledTaskPageDelivery skips release when html file is missing', async () => {
|
||||
const publishDir = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-finalize-missing-')), 'MindSpace', 'user-1');
|
||||
await fs.mkdir(publishDir, { recursive: true });
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_page_delivery_contracts')) {
|
||||
return [[{ workspace_relative_path: 'public/news.html' }]];
|
||||
}
|
||||
if (sql.includes('SELECT id, data_mode, status')) {
|
||||
return [[{ id: 'c1', data_mode: 'static', status: 'preparing' }]];
|
||||
}
|
||||
if (sql.includes("SET status = 'ready'")) {
|
||||
throw new Error('should not mark ready without html file');
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
const readyPaths = await finalizeScheduledTaskPageDelivery({
|
||||
pool,
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
publishDir,
|
||||
deliveryText: 'public/news.html 已生成',
|
||||
logger: { warn() {}, info() {} },
|
||||
});
|
||||
assert.deepEqual(readyPaths, []);
|
||||
});
|
||||
|
||||
test('reconcileStuckStaticPageDeliveryContracts releases materialized static pages', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-'));
|
||||
const userId = 'user-1';
|
||||
const relativePath = 'public/news.html';
|
||||
const filePath = path.join(dir, 'MindSpace', userId, relativePath);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, '<html></html>', 'utf8');
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_page_delivery_contracts')) {
|
||||
return [[{ user_id: userId, workspace_relative_path: relativePath }]];
|
||||
}
|
||||
if (sql.includes("SET status = 'ready'")) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
const released = await reconcileStuckStaticPageDeliveryContracts({
|
||||
pool,
|
||||
h5Root: dir,
|
||||
logger: { info() {} },
|
||||
});
|
||||
assert.deepEqual(released, [{ userId, relativePath }]);
|
||||
});
|
||||
|
||||
test('reconcileStuckStaticPageDeliveryContracts fails orphan contracts without html', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-orphan-'));
|
||||
const userId = 'user-1';
|
||||
const relativePath = 'public/missing.html';
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_page_delivery_contracts')) {
|
||||
return [[{ user_id: userId, workspace_relative_path: relativePath }]];
|
||||
}
|
||||
if (sql.includes("SET status = 'failed'")) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
const released = await reconcileStuckStaticPageDeliveryContracts({
|
||||
pool,
|
||||
h5Root: dir,
|
||||
logger: { info() {} },
|
||||
});
|
||||
assert.deepEqual(released, []);
|
||||
});
|
||||
|
||||
test('extractScheduledTaskDeliveryText reads last assistant message', () => {
|
||||
const text = extractScheduledTaskDeliveryText([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
|
||||
@@ -137,6 +137,27 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM
|
||||
timezone: tz,
|
||||
now: clock.now(),
|
||||
});
|
||||
const normalizedTitle = safeTitle || safeTaskSpec.slice(0, 80);
|
||||
const normalizedHour = hour == null ? null : Number(hour);
|
||||
const normalizedMinute = Number(minute ?? 0);
|
||||
if (safeRecurrence !== 'once') {
|
||||
const [duplicateRows] = await pool.query(
|
||||
`SELECT *
|
||||
FROM h5_scheduled_tasks
|
||||
WHERE user_id = ?
|
||||
AND status = 'active'
|
||||
AND recurrence = ?
|
||||
AND hour <=> ?
|
||||
AND minute = ?
|
||||
AND title = ?
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`,
|
||||
[userId, safeRecurrence, normalizedHour, normalizedMinute, normalizedTitle],
|
||||
);
|
||||
if (duplicateRows?.[0]) {
|
||||
return rowToTask(duplicateRows[0]);
|
||||
}
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const ts = clock.now();
|
||||
await pool.query(
|
||||
@@ -148,11 +169,11 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
safeTitle || safeTaskSpec.slice(0, 80),
|
||||
normalizedTitle,
|
||||
safeTaskSpec,
|
||||
safeRecurrence,
|
||||
hour == null ? null : Number(hour),
|
||||
Number(minute ?? 0),
|
||||
normalizedHour,
|
||||
normalizedMinute,
|
||||
weekday == null ? null : Number(weekday),
|
||||
tz,
|
||||
nextRunAt,
|
||||
|
||||
@@ -19,6 +19,9 @@ test('createTask inserts scheduled automation row', async () => {
|
||||
const inserts = [];
|
||||
const service = createScheduledTaskService({
|
||||
async query(sql, params) {
|
||||
if (sql.includes('FROM h5_scheduled_tasks') && sql.includes('status = \'active\'')) {
|
||||
return [[]];
|
||||
}
|
||||
if (sql.includes('INSERT INTO h5_scheduled_tasks')) {
|
||||
inserts.push(params);
|
||||
return [{ affectedRows: 1 }];
|
||||
@@ -69,6 +72,59 @@ test('createTask inserts scheduled automation row', async () => {
|
||||
assert.equal(inserts.length, 1);
|
||||
});
|
||||
|
||||
test('createTask returns existing active duplicate for same schedule', async () => {
|
||||
let insertCount = 0;
|
||||
const service = createScheduledTaskService({
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM h5_scheduled_tasks') && sql.includes('status = \'active\'')) {
|
||||
return [[{
|
||||
id: 'task-existing',
|
||||
user_id: 'user-1',
|
||||
title: '每日天气预报播报(上海+武穴)',
|
||||
task_spec: 'old spec',
|
||||
recurrence: 'daily',
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
weekday: null,
|
||||
timezone: 'Asia/Shanghai',
|
||||
next_run_at: 1780000000000,
|
||||
last_run_at: null,
|
||||
notify_channel: 'both',
|
||||
status: 'active',
|
||||
attempts: 0,
|
||||
last_error: null,
|
||||
last_result_json: null,
|
||||
source_channel: 'agent',
|
||||
source_session_id: null,
|
||||
source_message_id: null,
|
||||
source_text: null,
|
||||
created_at: 1780000000000,
|
||||
updated_at: 1780000000000,
|
||||
}]];
|
||||
}
|
||||
if (sql.includes('INSERT INTO h5_scheduled_tasks')) {
|
||||
insertCount += 1;
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
}, {
|
||||
clock: { now: () => 1780000000000 },
|
||||
});
|
||||
|
||||
const task = await service.createTask({
|
||||
userId: 'user-1',
|
||||
title: '每日天气预报播报(上海+武穴)',
|
||||
taskSpec: 'new spec',
|
||||
recurrence: 'daily',
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
});
|
||||
|
||||
assert.equal(task.id, 'task-existing');
|
||||
assert.equal(insertCount, 0);
|
||||
});
|
||||
|
||||
test('cancelTask updates status to cancelled', async () => {
|
||||
const service = createScheduledTaskService({
|
||||
async query(sql, params) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
buildScheduledTaskVerifiedHtmlUrls,
|
||||
deliveryTextPromisesPublicHtml,
|
||||
executeScheduledTask,
|
||||
formatScheduledTaskDeliveryMessage,
|
||||
looksLikeScheduledTaskNonDelivery,
|
||||
reconcileStuckStaticPageDeliveryContracts,
|
||||
resendScheduledTaskWechatForReadyPage,
|
||||
} from './scheduled-task-executor.mjs';
|
||||
|
||||
export function startScheduledTaskWorker({
|
||||
@@ -35,9 +39,12 @@ export function startScheduledTaskWorker({
|
||||
let stopped = false;
|
||||
let running = false;
|
||||
|
||||
const deliverTaskResult = async (task, deliveryText) => {
|
||||
const deliverTaskResult = async (task, deliveryText, { readyPaths = [] } = {}) => {
|
||||
const text = formatScheduledTaskDeliveryMessage(task, deliveryText);
|
||||
const notifyChannel = task.notifyChannel ?? 'both';
|
||||
const verifiedHtmlUrls = buildScheduledTaskVerifiedHtmlUrls(task.userId, readyPaths);
|
||||
const promisesHtml = deliveryTextPromisesPublicHtml(deliveryText);
|
||||
let wechatDelivery = null;
|
||||
if (scheduleService?.createUserNotification && (notifyChannel === 'web' || notifyChannel === 'both')) {
|
||||
await scheduleService.createUserNotification({
|
||||
userId: task.userId,
|
||||
@@ -57,16 +64,57 @@ export function startScheduledTaskWorker({
|
||||
(notifyChannel === 'wechat' || notifyChannel === 'both')
|
||||
&& typeof sendScheduleNotification === 'function'
|
||||
) {
|
||||
await sendScheduleNotification({ userId: task.userId, text }).catch((err) => {
|
||||
logger.warn?.('Scheduled task wechat notification failed:', err);
|
||||
});
|
||||
if (promisesHtml && verifiedHtmlUrls.length === 0) {
|
||||
logger.warn?.('[ScheduledTask] skip wechat until public html is ready', {
|
||||
taskId: task.id,
|
||||
userId: task.userId,
|
||||
});
|
||||
} else {
|
||||
const sent = await sendScheduleNotification({
|
||||
userId: task.userId,
|
||||
text,
|
||||
verifiedHtmlUrls,
|
||||
}).catch((err) => {
|
||||
logger.warn?.('Scheduled task wechat notification failed:', err);
|
||||
return false;
|
||||
});
|
||||
if (sent && verifiedHtmlUrls.length > 0) {
|
||||
wechatDelivery = {
|
||||
sentAt: Date.now(),
|
||||
relativePaths: readyPaths.map((value) => String(value ?? '').trim()).filter(Boolean),
|
||||
source: 'scheduled_task_worker',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { wechatDelivery };
|
||||
};
|
||||
|
||||
const runOnce = async () => {
|
||||
if (running || stopped) return;
|
||||
running = true;
|
||||
try {
|
||||
if (pool && h5Root) {
|
||||
const reconciled = await reconcileStuckStaticPageDeliveryContracts({
|
||||
pool,
|
||||
h5Root,
|
||||
logger,
|
||||
}).catch((err) => {
|
||||
logger.warn?.('Scheduled task delivery reconcile failed:', err);
|
||||
return [];
|
||||
});
|
||||
for (const item of reconciled) {
|
||||
await resendScheduledTaskWechatForReadyPage({
|
||||
pool,
|
||||
userId: item.userId,
|
||||
relativePath: item.relativePath,
|
||||
notificationDispatcher,
|
||||
logger,
|
||||
}).catch((err) => {
|
||||
logger.warn?.('Scheduled task reconcile wechat resend failed:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
const dueTasks = await scheduledTaskService.listDueTasks({ limit: 10 });
|
||||
for (const candidate of dueTasks) {
|
||||
const task = await scheduledTaskService.lockTask(candidate.id);
|
||||
@@ -89,10 +137,15 @@ export function startScheduledTaskWorker({
|
||||
err.code = 'SCHEDULED_TASK_NON_DELIVERY';
|
||||
throw err;
|
||||
}
|
||||
await deliverTaskResult(task, result.deliveryText);
|
||||
const deliveryMeta = await deliverTaskResult(task, result.deliveryText, {
|
||||
readyPaths: result.readyPaths,
|
||||
});
|
||||
await scheduledTaskService.markTaskSucceeded(task, {
|
||||
result: {
|
||||
deliveryText: result.deliveryText,
|
||||
...(deliveryMeta.wechatDelivery
|
||||
? { wechatDelivery: deliveryMeta.wechatDelivery }
|
||||
: {}),
|
||||
},
|
||||
deliveryText: result.deliveryText,
|
||||
sessionId: result.sessionId,
|
||||
|
||||
@@ -54,6 +54,7 @@ test('scheduled task worker executes due task and completes one-shot task', asyn
|
||||
sessionId: 'session-1',
|
||||
requestId: 'req-1',
|
||||
deliveryText: '页面:https://example.com/news.html',
|
||||
readyPaths: [],
|
||||
}),
|
||||
logger: { warn() {}, info() {} },
|
||||
runOnStart: false,
|
||||
@@ -224,6 +225,7 @@ test('scheduled task worker keeps success when wechat notification fails', async
|
||||
sessionId: 'session-4',
|
||||
requestId: 'req-4',
|
||||
deliveryText: '页面已生成:https://example.com/news.html',
|
||||
readyPaths: [],
|
||||
}),
|
||||
logger: { warn() {} },
|
||||
runOnStart: false,
|
||||
@@ -234,3 +236,56 @@ test('scheduled task worker keeps success when wechat notification fails', async
|
||||
|
||||
assert.deepEqual(calls, ['notify:web', 'success:task-4']);
|
||||
});
|
||||
|
||||
test('scheduled task worker marks failure when page link is not deliverable yet', async () => {
|
||||
const calls = [];
|
||||
const task = {
|
||||
id: 'task-5',
|
||||
userId: 'user-5',
|
||||
title: '每日新闻页',
|
||||
recurrence: 'daily',
|
||||
notifyChannel: 'web',
|
||||
attempts: 1,
|
||||
};
|
||||
const worker = startScheduledTaskWorker({
|
||||
intervalMs: 60_000,
|
||||
userAuth: { id: 'user-auth' },
|
||||
tkmindProxy: { id: 'proxy' },
|
||||
scheduledTaskService: {
|
||||
async listDueTasks() {
|
||||
return [task];
|
||||
},
|
||||
async lockTask() {
|
||||
return task;
|
||||
},
|
||||
async markTaskRunning(input) {
|
||||
return input;
|
||||
},
|
||||
async markTaskSucceeded() {
|
||||
calls.push('success');
|
||||
},
|
||||
async markTaskFailed(input, err) {
|
||||
calls.push(`failed:${err.code}`);
|
||||
return { ...input, status: 'failed', lastError: err.message };
|
||||
},
|
||||
},
|
||||
scheduleService: {
|
||||
async createUserNotification(input) {
|
||||
calls.push(`notify:${input.notificationType}`);
|
||||
},
|
||||
},
|
||||
executeTask: async () => ({
|
||||
sessionId: 'session-5',
|
||||
requestId: 'req-5',
|
||||
deliveryText: '页面:https://m.tkmind.cn/MindSpace/user-5/public/daily-news.html',
|
||||
readyPaths: [],
|
||||
}),
|
||||
logger: { warn() {} },
|
||||
runOnStart: false,
|
||||
});
|
||||
|
||||
await worker.runOnce();
|
||||
worker.stop();
|
||||
|
||||
assert.deepEqual(calls, ['failed:SCHEDULED_TASK_NON_DELIVERY', 'notify:scheduled_task_failed']);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS h5_users (
|
||||
workspace_root VARCHAR(512) NOT NULL,
|
||||
low_balance_gift_eligible TINYINT(1) NOT NULL DEFAULT 0,
|
||||
low_balance_gift_granted_at BIGINT NULL,
|
||||
billing_formula ENUM('A','B') NOT NULL DEFAULT 'A',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_h5_users_slug (slug),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Standalone one-shot WeChat resend for a completed scheduled task page.
|
||||
* Designed to run on 103 with only mysql2 from runtime node_modules.
|
||||
*/
|
||||
import process from 'node:process';
|
||||
|
||||
const TANG = process.argv.includes('--user-id')
|
||||
? process.argv[process.argv.indexOf('--user-id') + 1]
|
||||
: 'a70ff537-8908-486e-9b6c-042e07cc25db';
|
||||
const RELATIVE = process.argv.includes('--relative-path')
|
||||
? process.argv[process.argv.indexOf('--relative-path') + 1]
|
||||
: 'public/daily-news-0817.html';
|
||||
const TASK_ID = '21799936-0532-420c-b144-65ea3846cde1';
|
||||
|
||||
async function getStableAccessToken(config) {
|
||||
const response = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'client_credential',
|
||||
appid: config.appId,
|
||||
secret: config.appSecret,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!payload?.access_token) {
|
||||
throw new Error(`stable_token failed: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload.access_token;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { default: mysql } = await import('mysql2/promise');
|
||||
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
|
||||
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID ?? process.env.WX_APP_ID;
|
||||
const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET ?? process.env.WX_APP_SECRET;
|
||||
if (!appId || !appSecret) throw new Error('缺少 WECHAT_MP_APP_ID / WECHAT_MP_APP_SECRET');
|
||||
|
||||
const [ident] = await pool.query(
|
||||
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
|
||||
[TANG, appId],
|
||||
);
|
||||
const openid = ident?.[0]?.openid;
|
||||
if (!openid) throw new Error('用户未绑定微信');
|
||||
|
||||
const [tasks] = await pool.query(
|
||||
'SELECT title, last_result_json FROM h5_scheduled_tasks WHERE id = ? LIMIT 1',
|
||||
[TASK_ID],
|
||||
);
|
||||
const task = tasks?.[0];
|
||||
const deliveryText = String(task?.last_result_json?.deliveryText ?? '').trim();
|
||||
if (!deliveryText) throw new Error('缺少 deliveryText');
|
||||
|
||||
const url = `https://m.tkmind.cn/MindSpace/${TANG}/${RELATIVE.replace(/^\/+/, '')}`;
|
||||
const text = `定时任务完成:${task.title}\n\n${deliveryText.includes(url) ? deliveryText : `${deliveryText}\n\n页面链接:\n${url}`}`.trim();
|
||||
|
||||
const accessToken = await getStableAccessToken({ appId, appSecret });
|
||||
const response = await fetch(`https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
touser: openid,
|
||||
msgtype: 'text',
|
||||
text: { content: text.slice(0, 2048) },
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (Number(payload?.errcode ?? 0) !== 0) {
|
||||
throw new Error(`微信发送失败 errcode=${payload?.errcode} errmsg=${payload?.errmsg}`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const nextResult = {
|
||||
...(task.last_result_json ?? {}),
|
||||
wechatDelivery: {
|
||||
sentAt: now,
|
||||
relativePaths: [RELATIVE],
|
||||
source: 'manual_resend',
|
||||
},
|
||||
};
|
||||
await pool.query(
|
||||
'UPDATE h5_scheduled_tasks SET last_result_json = ?, updated_at = ? WHERE id = ?',
|
||||
[JSON.stringify(nextResult), now, TASK_ID],
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({ ok: true, openid: `${openid.slice(0, 8)}...`, url }, null, 2));
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* One-shot proactive WeChat resend for a completed scheduled task page delivery.
|
||||
* Usage:
|
||||
* node scripts/manual-resend-scheduled-task-wechat.mjs --user-id <uuid> --relative-path public/foo.html [--dry-run]
|
||||
*/
|
||||
import process from 'node:process';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import {
|
||||
buildScheduledTaskVerifiedHtmlUrls,
|
||||
formatScheduledTaskDeliveryMessage,
|
||||
resendScheduledTaskWechatForReadyPage,
|
||||
} from '../scheduled-task-executor.mjs';
|
||||
import { loadWechatMpConfig, createWechatMpService } from '../wechat-mp.mjs';
|
||||
import { createNotificationDispatcher } from '../notification-dispatcher.mjs';
|
||||
import { createUserAuth } from '../user-auth.mjs';
|
||||
|
||||
loadH5Environment(new URL('.', import.meta.url).pathname);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
userId: '',
|
||||
relativePath: '',
|
||||
dryRun: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === '--user-id') args.userId = String(argv[++i] ?? '').trim();
|
||||
else if (token === '--relative-path') args.relativePath = String(argv[++i] ?? '').trim();
|
||||
else if (token === '--dry-run') args.dryRun = true;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { userId, relativePath, dryRun } = parseArgs(process.argv.slice(2));
|
||||
if (!userId || !relativePath) {
|
||||
throw new Error('用法: node scripts/manual-resend-scheduled-task-wechat.mjs --user-id <uuid> --relative-path public/foo.html');
|
||||
}
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('缺少 DATABASE_URL');
|
||||
}
|
||||
|
||||
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
|
||||
const userAuth = createUserAuth(pool);
|
||||
const wechatMpService = createWechatMpService({
|
||||
config: loadWechatMpConfig(),
|
||||
userAuth,
|
||||
apiFetch: async () => {
|
||||
throw new Error('manual resend does not proxy chat sessions');
|
||||
},
|
||||
mysqlPool: pool,
|
||||
});
|
||||
const notificationDispatcher = createNotificationDispatcher({
|
||||
sendWechatTextToUser: wechatMpService?.enabled
|
||||
? (targetUserId, text, options) => wechatMpService.sendTextToUser(targetUserId, text, options)
|
||||
: null,
|
||||
});
|
||||
|
||||
if (dryRun) {
|
||||
const verifiedHtmlUrls = buildScheduledTaskVerifiedHtmlUrls(userId, [relativePath]);
|
||||
console.log(JSON.stringify({ mode: 'dry-run', userId, relativePath, verifiedHtmlUrls }, null, 2));
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const sent = await resendScheduledTaskWechatForReadyPage({
|
||||
pool,
|
||||
userId,
|
||||
relativePath,
|
||||
notificationDispatcher,
|
||||
logger: console,
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
mode: 'apply',
|
||||
userId,
|
||||
relativePath,
|
||||
sent,
|
||||
}, null, 2));
|
||||
await pool.end();
|
||||
if (!sent) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${GOOSED_NATIVE_ROOT:-/Users/john/Project/tkmind_go-native}"
|
||||
LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
|
||||
GUI="gui/$(id -u)"
|
||||
PATH_EXPORT='export PATH="/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"'
|
||||
|
||||
log() { printf '[repair-goosed-harness-path] %s\n' "$*"; }
|
||||
|
||||
patch_run_script() {
|
||||
local script="${ROOT}/run-goosed-native.sh"
|
||||
[[ -f "${script}" ]] || { log "missing ${script}"; exit 1; }
|
||||
if grep -q 'opt/homebrew/opt/node@24/bin' "${script}"; then
|
||||
log "run-goosed-native.sh already exports node PATH"
|
||||
return 0
|
||||
fi
|
||||
cp "${script}" "${script}.bak-inspection-$(date +%Y%m%d-%H%M%S)"
|
||||
awk -v path_line="${PATH_EXPORT}" '
|
||||
/^set -euo pipefail$/ {
|
||||
print
|
||||
print path_line
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "${script}" > "${script}.tmp"
|
||||
mv "${script}.tmp" "${script}"
|
||||
chmod +x "${script}"
|
||||
log "patched ${script}"
|
||||
}
|
||||
|
||||
patch_launchd_plist() {
|
||||
local port="$1"
|
||||
local plist="${LAUNCHD_DIR}/cn.tkmind.goosed-native-${port}.plist"
|
||||
[[ -f "${plist}" ]] || return 0
|
||||
if plutil -extract EnvironmentVariables raw "${plist}" >/dev/null 2>&1; then
|
||||
plutil -replace EnvironmentVariables.PATH -string "/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" "${plist}"
|
||||
else
|
||||
plutil -insert EnvironmentVariables -dictionary "${plist}"
|
||||
plutil -insert EnvironmentVariables.PATH -string "/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" "${plist}"
|
||||
fi
|
||||
launchctl bootout "${GUI}/cn.tkmind.goosed-native-${port}" 2>/dev/null || true
|
||||
launchctl bootstrap "${GUI}" "${plist}"
|
||||
log "reloaded ${plist}"
|
||||
}
|
||||
|
||||
patch_run_script
|
||||
for port in 18006 18007 18008 18009 18010 18011 18012 18013 18014; do
|
||||
patch_launchd_plist "${port}"
|
||||
done
|
||||
|
||||
log "done"
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Standalone 103 production repair (no heavy module imports).
|
||||
* Dry-run by default; pass --apply to mutate database rows.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
|
||||
const TASKS_TO_CANCEL = [
|
||||
'781a77e5-a787-4f37-b864-e21d7e918855',
|
||||
'9df3f89a-3a06-434d-8b4c-17b1aeb8911f',
|
||||
'7f953d5f-6fc3-4191-b52a-bffcef48e77f',
|
||||
'9b537fe8-eea2-4e39-8bcb-e8f169986327',
|
||||
];
|
||||
|
||||
function normalizeDeliveryRelativePath(value) {
|
||||
const raw = String(value ?? '').trim().replace(/^\/+/, '');
|
||||
if (!raw) return '';
|
||||
return raw.startsWith('public/') ? raw : `public/${raw}`;
|
||||
}
|
||||
|
||||
async function loadPool() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error('缺少 DATABASE_URL');
|
||||
const { default: mysql } = await import('mysql2/promise');
|
||||
return mysql.createPool({ uri: databaseUrl, connectionLimit: 2 });
|
||||
}
|
||||
|
||||
async function markContractFailed(pool, { userId, relativePath, reason }) {
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
`UPDATE h5_page_delivery_contracts
|
||||
SET status = 'failed', updated_at = ?, last_error = ?
|
||||
WHERE user_id = ? AND workspace_relative_path = ? AND status = 'preparing'`,
|
||||
[now, reason, userId, relativePath],
|
||||
);
|
||||
}
|
||||
|
||||
async function markContractReady(pool, { userId, relativePath }) {
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
`UPDATE h5_page_delivery_contracts
|
||||
SET status = 'ready', updated_at = ?, last_error = NULL
|
||||
WHERE user_id = ? AND workspace_relative_path = ? AND status = 'preparing'`,
|
||||
[now, userId, relativePath],
|
||||
);
|
||||
}
|
||||
|
||||
async function reconcileStuckContracts(pool, h5Root, apply, limit = 50) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT user_id, workspace_relative_path
|
||||
FROM h5_page_delivery_contracts
|
||||
WHERE status = 'preparing' AND data_mode = 'static'
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT ?`,
|
||||
[limit],
|
||||
);
|
||||
const summary = [];
|
||||
for (const row of rows ?? []) {
|
||||
const relativePath = normalizeDeliveryRelativePath(row.workspace_relative_path);
|
||||
if (!relativePath) continue;
|
||||
const filePath = path.join(h5Root, 'MindSpace', row.user_id, relativePath);
|
||||
const exists = fs.existsSync(filePath);
|
||||
const action = exists ? 'ready' : 'failed';
|
||||
summary.push({ userId: row.user_id, relativePath, fileExists: exists, action: apply ? action : `would_${action}` });
|
||||
if (apply) {
|
||||
if (exists) {
|
||||
await markContractReady(pool, { userId: row.user_id, relativePath });
|
||||
} else {
|
||||
await markContractFailed(pool, {
|
||||
userId: row.user_id,
|
||||
relativePath,
|
||||
reason: 'reconcile: static html missing',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply');
|
||||
const h5Root = process.env.H5_ROOT || '/Users/john/Project/Memind';
|
||||
const pool = await loadPool();
|
||||
const now = Date.now();
|
||||
const actions = [];
|
||||
|
||||
for (const taskId of TASKS_TO_CANCEL) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, user_id, title, status, hour, minute
|
||||
FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
|
||||
[taskId],
|
||||
);
|
||||
const row = rows?.[0];
|
||||
if (!row) {
|
||||
actions.push({ taskId, action: 'skip', reason: 'not_found' });
|
||||
continue;
|
||||
}
|
||||
if (row.status === 'cancelled') {
|
||||
actions.push({ taskId, action: 'skip', reason: 'already_cancelled', title: row.title });
|
||||
continue;
|
||||
}
|
||||
actions.push({
|
||||
taskId,
|
||||
action: apply ? 'cancel' : 'would_cancel',
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
schedule: `${row.hour ?? '?'}:${String(row.minute ?? 0).padStart(2, '0')}`,
|
||||
});
|
||||
if (apply) {
|
||||
await pool.query(
|
||||
`UPDATE h5_scheduled_tasks
|
||||
SET status = 'cancelled', updated_at = ?, last_error = NULL
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[now, taskId, TANG_USER_ID],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [activeWeather] = await pool.query(
|
||||
`SELECT id, title, hour, minute, status, last_run_at
|
||||
FROM h5_scheduled_tasks
|
||||
WHERE user_id = ? AND title LIKE '%天气预报%' AND status = 'active'
|
||||
ORDER BY hour, created_at`,
|
||||
[TANG_USER_ID],
|
||||
);
|
||||
|
||||
const reconcileSummary = await reconcileStuckContracts(pool, h5Root, apply);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
mode: apply ? 'apply' : 'dry-run',
|
||||
cancelledTasks: actions,
|
||||
remainingActiveWeatherTasks: activeWeather,
|
||||
deliveryContracts: reconcileSummary,
|
||||
}, null, 2));
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* One-shot 103 production repairs from 2026-08-16 inspection.
|
||||
* Dry-run by default; pass --apply to mutate database rows.
|
||||
*/
|
||||
import process from 'node:process';
|
||||
import { reconcileStuckStaticPageDeliveryContracts } from '../scheduled-task-executor.mjs';
|
||||
|
||||
const TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
|
||||
const TASKS_TO_CANCEL = [
|
||||
'781a77e5-a787-4f37-b864-e21d7e918855',
|
||||
'9df3f89a-3a06-434d-8b4c-17b1aeb8911f',
|
||||
'7f953d5f-6fc3-4191-b52a-bffcef48e77f',
|
||||
'9b537fe8-eea2-4e39-8bcb-e8f169986327',
|
||||
];
|
||||
|
||||
function loadPool() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error('缺少 DATABASE_URL');
|
||||
}
|
||||
return import('mysql2/promise').then(({ default: mysql }) =>
|
||||
mysql.createPool({ uri: databaseUrl, connectionLimit: 2 }));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply');
|
||||
const h5Root = process.env.H5_ROOT || '/Users/john/Project/Memind';
|
||||
const pool = await loadPool();
|
||||
const now = Date.now();
|
||||
const actions = [];
|
||||
|
||||
for (const taskId of TASKS_TO_CANCEL) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, user_id, title, status, hour, minute
|
||||
FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
|
||||
[taskId],
|
||||
);
|
||||
const row = rows?.[0];
|
||||
if (!row) {
|
||||
actions.push({ taskId, action: 'skip', reason: 'not_found' });
|
||||
continue;
|
||||
}
|
||||
if (row.status === 'cancelled') {
|
||||
actions.push({ taskId, action: 'skip', reason: 'already_cancelled', title: row.title });
|
||||
continue;
|
||||
}
|
||||
actions.push({
|
||||
taskId,
|
||||
action: apply ? 'cancel' : 'would_cancel',
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
schedule: `${row.hour ?? '?'}:${String(row.minute ?? 0).padStart(2, '0')}`,
|
||||
});
|
||||
if (apply) {
|
||||
await pool.query(
|
||||
`UPDATE h5_scheduled_tasks
|
||||
SET status = 'cancelled', updated_at = ?, last_error = NULL
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[now, taskId, TANG_USER_ID],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [activeWeather] = await pool.query(
|
||||
`SELECT id, title, hour, minute, status, last_run_at
|
||||
FROM h5_scheduled_tasks
|
||||
WHERE user_id = ? AND title LIKE '%天气预报%' AND status = 'active'
|
||||
ORDER BY hour, created_at`,
|
||||
[TANG_USER_ID],
|
||||
);
|
||||
|
||||
let reconcileSummary = [];
|
||||
if (apply) {
|
||||
reconcileSummary = await reconcileStuckStaticPageDeliveryContracts({
|
||||
pool,
|
||||
h5Root,
|
||||
limit: 50,
|
||||
logger: console,
|
||||
});
|
||||
} else {
|
||||
const [stuck] = await pool.query(
|
||||
`SELECT user_id, workspace_relative_path, data_mode, status
|
||||
FROM h5_page_delivery_contracts
|
||||
WHERE status = 'preparing' AND data_mode = 'static'
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT 50`,
|
||||
);
|
||||
reconcileSummary = stuck;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
mode: apply ? 'apply' : 'dry-run',
|
||||
cancelledTasks: actions,
|
||||
remainingActiveWeatherTasks: activeWeather,
|
||||
deliveryContracts: reconcileSummary,
|
||||
}, null, 2));
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import {
|
||||
buildAssetImageUrl,
|
||||
createAgentRun,
|
||||
createReporter,
|
||||
listScenarios,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
logoutViaApi,
|
||||
resolvePortalBase,
|
||||
snapshotPublicHtml,
|
||||
uploadImageAssets,
|
||||
verifyPageAccess,
|
||||
verifySurveyDelivery,
|
||||
verifyChildrenHobbyDietSurvey,
|
||||
@@ -95,6 +97,7 @@ async function runScenario(scenario, port) {
|
||||
let assistantCombinedLength = 0;
|
||||
let publishKey = null;
|
||||
let htmlBefore = [];
|
||||
let uploadedAssetIds = [];
|
||||
|
||||
for (const step of scenario.steps ?? []) {
|
||||
const label = step.label ?? step.action;
|
||||
@@ -119,6 +122,20 @@ async function runScenario(scenario, port) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.action === 'upload_images') {
|
||||
if (!auth) {
|
||||
throw new Error('upload_images 步骤前必须先 login');
|
||||
}
|
||||
const assets = await uploadImageAssets(
|
||||
baseUrl,
|
||||
auth.cookie,
|
||||
{ images: step.images ?? [], categoryCode: step.categoryCode ?? 'oa' },
|
||||
reporter,
|
||||
);
|
||||
uploadedAssetIds = assets.map((asset) => asset.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.action === 'chat') {
|
||||
if (!auth) {
|
||||
throw new Error('chat 步骤前必须先 login');
|
||||
@@ -133,6 +150,9 @@ async function runScenario(scenario, port) {
|
||||
sessionId,
|
||||
selectedChatSkill: step.selectedChatSkill ?? null,
|
||||
selectedAssetIds: step.selectedAssetIds ?? null,
|
||||
imageUrls: step.useUploadedAssets
|
||||
? uploadedAssetIds.map(buildAssetImageUrl)
|
||||
: null,
|
||||
});
|
||||
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
|
||||
|
||||
@@ -191,6 +211,7 @@ async function runScenario(scenario, port) {
|
||||
replyText: reply.combined,
|
||||
htmlBefore,
|
||||
expect: step.expect.page,
|
||||
uploadedAssetIds,
|
||||
reporter,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,11 +121,16 @@ export async function logoutViaApi(baseUrl, cookie, reporter) {
|
||||
return body;
|
||||
}
|
||||
|
||||
function buildUserMessage(text, { selectedChatSkill = null } = {}) {
|
||||
function buildUserMessage(text, { selectedChatSkill = null, imageUrls = null } = {}) {
|
||||
const metadata = { userVisible: true, displayText: text };
|
||||
if (selectedChatSkill) {
|
||||
metadata.memindRun = { selectedChatSkill };
|
||||
}
|
||||
// Same shape the WeChat channel attaches for recent uploads, so scenarios can
|
||||
// exercise the vision turn without the browser upload widget.
|
||||
if (Array.isArray(imageUrls) && imageUrls.length > 0) {
|
||||
metadata.imageUrls = imageUrls;
|
||||
}
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
@@ -134,16 +139,21 @@ function buildUserMessage(text, { selectedChatSkill = null } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAssetImageUrl(assetId) {
|
||||
return `/api/mindspace/v1/assets/${assetId}/download?inline=1`;
|
||||
}
|
||||
|
||||
export async function createAgentRun(baseUrl, cookie, {
|
||||
message,
|
||||
sessionId = null,
|
||||
selectedChatSkill = null,
|
||||
selectedAssetIds = null,
|
||||
imageUrls = null,
|
||||
}) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const body = {
|
||||
request_id: requestId,
|
||||
user_message: buildUserMessage(message, { selectedChatSkill }),
|
||||
user_message: buildUserMessage(message, { selectedChatSkill, imageUrls }),
|
||||
};
|
||||
if (sessionId) body.session_id = sessionId;
|
||||
if (Array.isArray(selectedAssetIds) && selectedAssetIds.length > 0) {
|
||||
@@ -296,6 +306,100 @@ export async function waitForAssistantGrowth(baseUrl, cookie, sessionId, {
|
||||
return null;
|
||||
}
|
||||
|
||||
const UPLOADABLE_CATEGORY_CODES = ['oa', 'public'];
|
||||
|
||||
async function resolveUploadCategoryId(baseUrl, cookie, preferredCode) {
|
||||
const response = await fetch(`${baseUrl}/api/mindspace/v1/space`, {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 MindSpace 空间失败 ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const categories = payload?.data?.categories ?? payload?.categories ?? [];
|
||||
const uploadable = categories.filter((item) => UPLOADABLE_CATEGORY_CODES.includes(item?.code));
|
||||
const picked = uploadable.find((item) => item.code === preferredCode) ?? uploadable[0];
|
||||
if (!picked?.id) {
|
||||
throw new Error(`MindSpace 没有可上传的分类(需要 ${UPLOADABLE_CATEGORY_CODES.join(' / ')})`);
|
||||
}
|
||||
return picked.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a deterministic, visually distinct JPEG so a vision model can tell the
|
||||
* uploads apart. Shapes and colors only — no text, because the rasterizer has no
|
||||
* guaranteed CJK font.
|
||||
*/
|
||||
async function renderScenarioImage({ background = '#1e3a8a', shape = 'circle', accent = '#fbbf24' }) {
|
||||
const { default: sharp } = await import('sharp');
|
||||
const shapes = {
|
||||
circle: '<circle cx="400" cy="300" r="180" fill="ACCENT"/>',
|
||||
square: '<rect x="220" y="120" width="360" height="360" fill="ACCENT"/>',
|
||||
triangle: '<polygon points="400,110 600,480 200,480" fill="ACCENT"/>',
|
||||
diamond: '<polygon points="400,90 620,300 400,510 180,300" fill="ACCENT"/>',
|
||||
};
|
||||
const figure = (shapes[shape] ?? shapes.circle).replace('ACCENT', accent);
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">`
|
||||
+ `<rect width="800" height="600" fill="${background}"/>${figure}</svg>`;
|
||||
return sharp(Buffer.from(svg)).jpeg({ quality: 90 }).toBuffer();
|
||||
}
|
||||
|
||||
export async function uploadImageAssets(
|
||||
baseUrl,
|
||||
cookie,
|
||||
{ images = [], categoryCode = 'oa' } = {},
|
||||
reporter,
|
||||
) {
|
||||
const categoryId = await resolveUploadCategoryId(baseUrl, cookie, categoryCode);
|
||||
const assets = [];
|
||||
|
||||
for (const [index, spec] of images.entries()) {
|
||||
const buffer = await renderScenarioImage(spec ?? {});
|
||||
const filename = spec?.filename ?? `scenario-image-${index + 1}.jpg`;
|
||||
|
||||
const createResponse = await fetch(`${baseUrl}/api/mindspace/v1/uploads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({
|
||||
category_id: categoryId,
|
||||
filename,
|
||||
size_bytes: buffer.length,
|
||||
declared_mime_type: 'image/jpeg',
|
||||
}),
|
||||
});
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(`创建上传失败 ${createResponse.status}: ${await createResponse.text()}`);
|
||||
}
|
||||
const upload = (await createResponse.json())?.data;
|
||||
const uploadUrl = new URL(upload.uploadUrl, baseUrl).href;
|
||||
|
||||
const putResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/octet-stream', Cookie: cookie },
|
||||
body: buffer,
|
||||
});
|
||||
if (!putResponse.ok) {
|
||||
throw new Error(`上传内容失败 ${putResponse.status}: ${await putResponse.text()}`);
|
||||
}
|
||||
|
||||
const completeResponse = await fetch(
|
||||
`${baseUrl}/api/mindspace/v1/uploads/${encodeURIComponent(upload.id)}/complete`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(`完成上传失败 ${completeResponse.status}: ${await completeResponse.text()}`);
|
||||
}
|
||||
assets.push((await completeResponse.json())?.data);
|
||||
}
|
||||
|
||||
reporter?.pass('上传图片', `${assets.length} 张 → ${assets.map((a) => a.id).join(', ')}`);
|
||||
return assets;
|
||||
}
|
||||
|
||||
export async function verifyPageAccess({
|
||||
baseUrl,
|
||||
cookie,
|
||||
@@ -303,6 +407,7 @@ export async function verifyPageAccess({
|
||||
replyText,
|
||||
htmlBefore = [],
|
||||
expect = {},
|
||||
uploadedAssetIds = [],
|
||||
reporter,
|
||||
}) {
|
||||
const keywords = expect.keywords ?? [];
|
||||
@@ -354,6 +459,19 @@ export async function verifyPageAccess({
|
||||
reporter.fail('页面元数据', '缺少 mindspace-cover');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expect.requireUploadedImages) {
|
||||
const embedded = uploadedAssetIds.filter((assetId) => assetId && html.includes(assetId));
|
||||
const required = expect.minUploadedImages ?? uploadedAssetIds.length;
|
||||
if (embedded.length < required) {
|
||||
reporter.fail(
|
||||
'页面嵌入上传图片',
|
||||
`命中 ${embedded.length}/${uploadedAssetIds.length} 张,要求至少 ${required} 张`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
reporter.pass('页面嵌入上传图片', `${embedded.length}/${uploadedAssetIds.length} 张`);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -236,10 +236,11 @@ export async function bootstrapPortalIntegrationServices({
|
||||
const notificationDispatcher =
|
||||
createNotificationDispatcherFn({
|
||||
sendWechatTextToUser: wechatMpService?.enabled
|
||||
? (userId, text) =>
|
||||
? (userId, text, options) =>
|
||||
wechatMpService.sendTextToUser(
|
||||
userId,
|
||||
text,
|
||||
options,
|
||||
)
|
||||
: null,
|
||||
});
|
||||
|
||||
+35
-5
@@ -966,6 +966,12 @@ export async function buildVisionPayload({
|
||||
(visionDescription
|
||||
? `Qwen VL 图片描述:\n${visionDescription}\n\n`
|
||||
: '') +
|
||||
'视觉检查已由图片模型完成,本轮不会再提供读图工具。'
|
||||
+ '禁止调用 read_image 或任何读图工具去「确认图片内容」:当前聊天模型不接受图片工具结果,'
|
||||
+ '强行读图会让本轮及后续请求全部失败。'
|
||||
+ (visionDescription
|
||||
? '直接依据上面的描述写文案。\n'
|
||||
: '本轮没有拿到图片描述时,不要臆造画面细节,按用户文字要求组织内容,并把下面的图片路径原样嵌进页面。\n') +
|
||||
'写作约束:不得改写图片里人物的年龄、性别、人数或主体关系;如果用户明确要求儿童语气或童趣风格,也只能调整表达方式,不能把图片主体改写成儿童场景。\n' +
|
||||
`图片 HTML 嵌入路径(直接写入 <img> 标签;以下都是无需 cookie 的公开压缩标准图片,禁止使用 local://、/users/ 私有路径、原图地址或需要登录态的下载链接):\n${pathList}\n` +
|
||||
'执行要求:必须先调用 load_skill → static-page-publish(每次生成页面都要调用,不可省略),' +
|
||||
@@ -1979,11 +1985,20 @@ export function createTkmindProxy({
|
||||
throw err;
|
||||
}
|
||||
|
||||
// REGRESSION GUARD: vision-turn-read-image-isolation — the vision provider
|
||||
// already describes this turn's images in the injected prompt. Leaving
|
||||
// read_image available lets the agent re-read the originals and persist
|
||||
// base64 image parts that the text-only chat provider then rejects for
|
||||
// every later turn, so drop the tool before the turn starts.
|
||||
const visionHandlesThisTurn = Boolean(llmProviderService)
|
||||
&& messageHasImages(userMessage)
|
||||
&& await llmProviderService.hasVisionKey().catch(() => false);
|
||||
|
||||
await reconcileSessionPolicyForUser(userId, sessionId, {
|
||||
toolMode,
|
||||
query: firstUserText(userMessage),
|
||||
forceDeepReasoning,
|
||||
disableImageReading,
|
||||
disableImageReading: disableImageReading || visionHandlesThisTurn,
|
||||
});
|
||||
await applySessionLlmProvider(sessionId);
|
||||
await repairSessionToolHistory(sessionId);
|
||||
@@ -2009,6 +2024,17 @@ export function createTkmindProxy({
|
||||
error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED';
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
requireHistoricalImageIsolation
|
||||
&& imageIsolation.hasImageUrlContent
|
||||
&& !imageIsolation.updated
|
||||
) {
|
||||
const error = new Error(
|
||||
`historical_image_session_update_unsupported:${imageIsolation.status ?? 'image_url_content_present'}`,
|
||||
);
|
||||
error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let finalUserMessage = userMessage;
|
||||
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
|
||||
@@ -2098,12 +2124,12 @@ export function createTkmindProxy({
|
||||
return upstream.json();
|
||||
}
|
||||
|
||||
async function resolveSessionBillingTokenState(sessionId, tokenStateRaw) {
|
||||
async function resolveSessionBillingTokenState(sessionId, tokenStateRaw, userId = null) {
|
||||
return resolveBillingTokenState(tokenStateRaw, {
|
||||
sessionId,
|
||||
fetchSession: fetchSessionBillingCost,
|
||||
loadEstimateConfig: billingConfigService?.getEffectiveCostEstimateConfig
|
||||
? () => billingConfigService.getEffectiveCostEstimateConfig()
|
||||
? () => billingConfigService.getEffectiveCostEstimateConfig({ userId })
|
||||
: null,
|
||||
});
|
||||
}
|
||||
@@ -2207,7 +2233,7 @@ export function createTkmindProxy({
|
||||
}
|
||||
if (!finishResult.ok) throw finishResult.error;
|
||||
const finish = finishResult.value;
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState);
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState, userId);
|
||||
if (tokenState && userAuth.billSessionUsage) {
|
||||
await userAuth
|
||||
.billSessionUsage(userId, sessionId, tokenState, requestId)
|
||||
@@ -2709,7 +2735,11 @@ export function createTkmindProxy({
|
||||
.catch(() => {});
|
||||
}
|
||||
const billingRequestId = event.request_id ?? event.chat_request_id ?? null;
|
||||
const tokenState = await resolveSessionBillingTokenState(sessionId, event.token_state);
|
||||
const tokenState = await resolveSessionBillingTokenState(
|
||||
sessionId,
|
||||
event.token_state,
|
||||
req.currentUser.id,
|
||||
);
|
||||
const result = await userAuth.billSessionUsage(
|
||||
req.currentUser.id,
|
||||
sessionId,
|
||||
|
||||
+141
-4
@@ -22,6 +22,8 @@ async function withFakeGoosedSession(
|
||||
const replyBodies = [];
|
||||
const startBodies = [];
|
||||
const updateBodies = [];
|
||||
const addedExtensions = [];
|
||||
let activeExtensions = [{ name: 'memory', available_tools: [] }];
|
||||
let activeConversation = conversation;
|
||||
let server;
|
||||
|
||||
@@ -67,9 +69,7 @@ async function withFakeGoosedSession(
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/sessions/session-1/extensions') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
extensions: [{ name: 'memory', available_tools: [] }],
|
||||
}));
|
||||
res.end(JSON.stringify({ extensions: activeExtensions }));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/sessions/session-1/reply') {
|
||||
@@ -78,6 +78,23 @@ async function withFakeGoosedSession(
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
req.method === 'POST'
|
||||
&& (req.url === '/agent/add_extension'
|
||||
|| req.url === '/agent/remove_extension'
|
||||
|| req.url === '/agent/restart')
|
||||
) {
|
||||
if (req.url === '/agent/add_extension') {
|
||||
addedExtensions.push(body);
|
||||
activeExtensions = [...activeExtensions, body.config];
|
||||
}
|
||||
if (req.url === '/agent/remove_extension') {
|
||||
activeExtensions = activeExtensions.filter((ext) => ext?.name !== body.name);
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/agent/harness_remember') {
|
||||
harnessEntries.push(body);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -102,6 +119,7 @@ async function withFakeGoosedSession(
|
||||
replyBodies,
|
||||
startBodies,
|
||||
updateBodies,
|
||||
addedExtensions,
|
||||
});
|
||||
} finally {
|
||||
if (server?.listening) {
|
||||
@@ -1569,7 +1587,7 @@ test('submitSessionReplyForUser rotates when assistant history still has image_u
|
||||
},
|
||||
{ requireHistoricalImageIsolation: true },
|
||||
),
|
||||
/historical_image_session_update_unsupported:image_url_content_present/,
|
||||
/historical_image_session_update_unsupported:(405|image_url_content_present)/,
|
||||
);
|
||||
assert.equal(replyBodies.length, 0);
|
||||
}, {
|
||||
@@ -1707,11 +1725,130 @@ test('submitSessionReplyForUser applies the shared Qwen vision preprocessing pat
|
||||
const forwardedText = replyBodies[0]?.user_message?.content?.[0]?.text ?? '';
|
||||
assert.match(forwardedText, /Qwen VL 图片描述/);
|
||||
assert.match(forwardedText, /一件蓝色产品/);
|
||||
assert.match(forwardedText, /禁止调用 read_image/);
|
||||
assert.equal(replyBodies[0]?.user_message?.metadata?.imageUrls, undefined);
|
||||
assert.ok(Array.isArray(replyBodies[0]?.user_message?.metadata?.archivedImageUrls));
|
||||
});
|
||||
});
|
||||
|
||||
function createReadImagePolicyUserAuth(workingDir) {
|
||||
return {
|
||||
...createMemoryTestUserAuth(workingDir),
|
||||
async getAgentSessionPolicy() {
|
||||
return {
|
||||
gooseMode: 'chat',
|
||||
enableContextMemory: true,
|
||||
extensionOverrides: [
|
||||
{ name: 'memory', available_tools: [] },
|
||||
{ name: 'developer', available_tools: ['write', 'edit', 'read_image'] },
|
||||
],
|
||||
};
|
||||
},
|
||||
async ownsSession() {
|
||||
return true;
|
||||
},
|
||||
async canUseChat() {
|
||||
return { ok: true };
|
||||
},
|
||||
async getUserById() {
|
||||
return { id: 'user-1' };
|
||||
},
|
||||
async getUserPublishLayout() {
|
||||
return { publicUrl: 'https://example.com/MindSpace/user-1' };
|
||||
},
|
||||
async resolveUserPolicies() {
|
||||
return { unrestricted: true, policies: {} };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function developerToolsFromAddedExtensions(addedExtensions) {
|
||||
const developer = addedExtensions
|
||||
.map((body) => body?.config)
|
||||
.find((config) => config?.name === 'developer');
|
||||
return developer?.available_tools ?? null;
|
||||
}
|
||||
|
||||
test('image turns drop read_image so vision results cannot poison the text provider', async () => {
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, addedExtensions }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: createReadImagePolicyUserAuth(workingDir),
|
||||
localFetchAsset: async () => ({
|
||||
buffer: Buffer.from('fake-image'),
|
||||
mimeType: 'image/jpeg',
|
||||
}),
|
||||
llmProviderService: {
|
||||
async applyBestProviderForSession() {
|
||||
return { ok: true };
|
||||
},
|
||||
async hasVisionKey() {
|
||||
return true;
|
||||
},
|
||||
async analyzeImagesWithVision() {
|
||||
return '四张汉服写真。';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await proxy.submitSessionReplyForUser(
|
||||
'user-1',
|
||||
'session-1',
|
||||
'request-image-turn',
|
||||
{
|
||||
id: 'message-image-turn',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '用刚才发的4张图片做成主题页面' }],
|
||||
metadata: {
|
||||
imageUrls: [
|
||||
'/api/mindspace/v1/assets/asset-1/download?inline=1',
|
||||
'/api/mindspace/v1/assets/asset-2/download?inline=1',
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const developerTools = developerToolsFromAddedExtensions(addedExtensions);
|
||||
assert.ok(developerTools, 'developer extension should still be provisioned');
|
||||
assert.deepEqual(developerTools, ['write', 'edit']);
|
||||
});
|
||||
});
|
||||
|
||||
test('text-only turns keep read_image available', async () => {
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir, addedExtensions }) => {
|
||||
const proxy = createTkmindProxy({
|
||||
apiTarget,
|
||||
apiSecret: 'test-secret',
|
||||
userAuth: createReadImagePolicyUserAuth(workingDir),
|
||||
llmProviderService: {
|
||||
async applyBestProviderForSession() {
|
||||
return { ok: true };
|
||||
},
|
||||
async hasVisionKey() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await proxy.submitSessionReplyForUser(
|
||||
'user-1',
|
||||
'session-1',
|
||||
'request-text-turn',
|
||||
{
|
||||
id: 'message-text-turn',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '把页面标题改成星尘之门' }],
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
developerToolsFromAddedExtensions(addedExtensions),
|
||||
['write', 'edit', 'read_image'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('submitSessionReplyForUser passes current prompt to Memory V2 resolve before existing reply path', async () => {
|
||||
let resolveInput = null;
|
||||
await withFakeGoosedSession(async ({ apiTarget, workingDir }) => {
|
||||
|
||||
+34
-8
@@ -129,15 +129,19 @@ export function createUserAuth(pool, options = {}) {
|
||||
const { storageRoot, publicBaseUrl } = resolveMindSpaceRuntimeConfig(h5Root, env);
|
||||
const skillCatalog = listPlatformSkillCatalog(h5Root);
|
||||
const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500);
|
||||
const lowBalanceGiftThresholdCents = Number(options.lowBalanceGiftThresholdCents ?? 100);
|
||||
const lowBalanceGiftAmountCents = Number(options.lowBalanceGiftAmountCents ?? 1000);
|
||||
const lowBalanceGiftThresholdCents = Number(
|
||||
options.lowBalanceGiftThresholdCents ?? env.H5_LOW_BALANCE_GIFT_THRESHOLD_CENTS ?? 100,
|
||||
);
|
||||
const lowBalanceGiftAmountCents = Number(
|
||||
options.lowBalanceGiftAmountCents ?? env.H5_LOW_BALANCE_GIFT_AMOUNT_CENTS ?? 0,
|
||||
);
|
||||
const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000);
|
||||
const loginMaxFailures = Number(options.loginMaxFailures ?? 5);
|
||||
const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1000);
|
||||
const persistSessions = options.persistSessions !== false && Boolean(pool);
|
||||
let rechargeNotifier =
|
||||
typeof options.onRechargeNotification === 'function' ? options.onRechargeNotification : null;
|
||||
const subscriptionService = options.subscriptionService ?? null;
|
||||
let subscriptionService = options.subscriptionService ?? null;
|
||||
const provisionUserDataSpace = typeof options.provisionUserDataSpace === 'function'
|
||||
? options.provisionUserDataSpace
|
||||
: null;
|
||||
@@ -190,7 +194,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
const getUserById = async (userId) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
||||
u.plan_type, u.workspace_root,
|
||||
u.plan_type, u.workspace_root, u.billing_formula,
|
||||
s.quota_bytes, s.used_bytes, s.reserved_bytes,
|
||||
w.balance_cents, w.tokens_used,
|
||||
(SELECT COALESCE(SUM(ABS(amount_cents)), 0)
|
||||
@@ -218,6 +222,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
status: row.status,
|
||||
planType: row.plan_type ?? 'free',
|
||||
workspaceRoot: row.workspace_root,
|
||||
billingFormula: row.billing_formula === 'B' ? 'B' : 'A',
|
||||
balanceCents,
|
||||
totalCreditCents: balanceCents + spentCents,
|
||||
tokensUsed: Number(row.tokens_used ?? 0),
|
||||
@@ -447,7 +452,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
||||
role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 1, NULL, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 0, NULL, ?, ?)`,
|
||||
[
|
||||
userId,
|
||||
normalized,
|
||||
@@ -900,7 +905,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
|
||||
u.plan_type, u.workspace_root,
|
||||
u.plan_type, u.workspace_root, u.billing_formula,
|
||||
s.quota_bytes, s.used_bytes, s.reserved_bytes,
|
||||
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
|
||||
FROM h5_users u
|
||||
@@ -1038,6 +1043,14 @@ export function createUserAuth(pool, options = {}) {
|
||||
fields.push('role = ?');
|
||||
values.push(patch.role === 'admin' ? 'admin' : 'user');
|
||||
}
|
||||
if (patch.billingFormula !== undefined) {
|
||||
const formula = String(patch.billingFormula).trim().toUpperCase();
|
||||
if (!['A', 'B'].includes(formula)) {
|
||||
return { ok: false, message: '计量公式只能是 A 或 B' };
|
||||
}
|
||||
fields.push('billing_formula = ?');
|
||||
values.push(formula);
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
fields.push('updated_at = ?');
|
||||
@@ -1272,6 +1285,16 @@ export function createUserAuth(pool, options = {}) {
|
||||
]);
|
||||
}
|
||||
if (ownsConnection) await conn.commit();
|
||||
if (subscriptionService?.processAutoRenewalsForUser) {
|
||||
try {
|
||||
await subscriptionService.processAutoRenewalsForUser(userId);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'Post-recharge auto-renew retry failed:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
const updated = await getUserById(userId);
|
||||
if (rechargeNotifier) {
|
||||
try {
|
||||
@@ -1336,7 +1359,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
}
|
||||
const tokenState = normalizeTokenState(tokenStateRaw);
|
||||
const config = billingConfigService?.getEffectiveBillingConfig
|
||||
? await billingConfigService.getEffectiveBillingConfig()
|
||||
? await billingConfigService.getEffectiveBillingConfig({ userId })
|
||||
: loadBillingConfig(env);
|
||||
const normalizedRequestId = requestId ? String(requestId).trim() || null : null;
|
||||
const now = Date.now();
|
||||
@@ -2747,7 +2770,7 @@ export function createUserAuth(pool, options = {}) {
|
||||
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
|
||||
role, status, plan_type, workspace_root, signup_source, low_balance_gift_eligible,
|
||||
low_balance_gift_granted_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 1, NULL, ?, ?)`,
|
||||
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 0, NULL, ?, ?)`,
|
||||
[
|
||||
userId,
|
||||
normalized,
|
||||
@@ -3079,6 +3102,9 @@ export function createUserAuth(pool, options = {}) {
|
||||
setRechargeNotifier(callback) {
|
||||
rechargeNotifier = typeof callback === 'function' ? callback : null;
|
||||
},
|
||||
setSubscriptionService(service) {
|
||||
subscriptionService = service ?? null;
|
||||
},
|
||||
findWechatUserByOpenid,
|
||||
getWechatAgentRoute,
|
||||
upsertWechatAgentRoute,
|
||||
|
||||
+1
-1
@@ -743,7 +743,7 @@ test('billSessionUsage auto gifts low-balance bonus once for eligible new users'
|
||||
},
|
||||
};
|
||||
|
||||
const auth = createUserAuth(pool, { persistSessions: false });
|
||||
const auth = createUserAuth(pool, { persistSessions: false, lowBalanceGiftAmountCents: 1000 });
|
||||
|
||||
const first = await auth.billSessionUsage(
|
||||
userRow.id,
|
||||
|
||||
+151
-6
@@ -85,6 +85,10 @@ import { resolveMindSpaceUserPublishDir } from './mindspace-runtime-config.mjs';
|
||||
import {
|
||||
buildPageDataCollectFailureText,
|
||||
} from './mindspace-page-data-finish-guard.mjs';
|
||||
import {
|
||||
conversationHasImageUrlContent,
|
||||
conversationHasToolImageContent,
|
||||
} from './chat-image-turn-scope.mjs';
|
||||
|
||||
export { buildWechatAgentPrompt };
|
||||
|
||||
@@ -1094,6 +1098,30 @@ export function isWechatHistoricalImageSessionError(message) {
|
||||
);
|
||||
}
|
||||
|
||||
export const WECHAT_IMAGE_STORED_ACK_TEXT =
|
||||
'已收到图片。请直接告诉我接下来要做什么,例如:解读报告并生成页面。';
|
||||
|
||||
export function resolveWechatRecentMediaPublicUrl(recentMediaEntry) {
|
||||
if (!recentMediaEntry?.items?.length) return '';
|
||||
return String(recentMediaEntry.items.at(-1)?.media?.publicUrl ?? '').trim();
|
||||
}
|
||||
|
||||
export function prepareWechatIntentForHistoricalImageRetry(intent, { fallbackImageUrl = '' } = {}) {
|
||||
if (!intent || typeof intent !== 'object') return intent;
|
||||
const imageUrl = String(intent.media?.publicUrl ?? fallbackImageUrl ?? '').trim();
|
||||
const agentText = String(intent.agentText ?? '').trim();
|
||||
if (imageUrl && !agentText.includes(imageUrl)) {
|
||||
intent.agentText = agentText
|
||||
? `${agentText}\n\n[附件图片]: ${imageUrl}`
|
||||
: `[附件图片]: ${imageUrl}`;
|
||||
}
|
||||
delete intent.media;
|
||||
delete intent.attachment;
|
||||
delete intent.recentMediaItems;
|
||||
delete intent.recentMediaBatchId;
|
||||
return intent;
|
||||
}
|
||||
|
||||
export function isRecoverableWechatAgentSessionError(message) {
|
||||
const normalized = String(message ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
@@ -1992,14 +2020,25 @@ export function createWechatMpService({
|
||||
});
|
||||
};
|
||||
|
||||
const sendTextToUser = async (userId, content) => {
|
||||
const sendTextToUser = async (userId, content, { verifiedHtmlUrls = [] } = {}) => {
|
||||
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
|
||||
if (!openid) {
|
||||
throw new Error('用户尚未绑定服务号,无法推送提醒');
|
||||
}
|
||||
return sendCustomerServiceText(openid, content, null, {
|
||||
const normalizedVerified = verifiedHtmlUrls
|
||||
.map((url) => String(url ?? '').trim())
|
||||
.filter(Boolean);
|
||||
const sendOptions = {
|
||||
deliveryPriority: 'formal_reply',
|
||||
});
|
||||
verifiedHtmlUrls: normalizedVerified,
|
||||
};
|
||||
if (normalizedVerified.length > 0) {
|
||||
sendOptions.linkExistsForRequest = createPreparedPublicHtmlLinkExists({
|
||||
userId,
|
||||
prepared: { validReplyUrls: normalizedVerified },
|
||||
});
|
||||
}
|
||||
return sendCustomerServiceText(openid, content, null, sendOptions);
|
||||
};
|
||||
|
||||
const enforceFreshPageThumbnailDelivery = async ({
|
||||
@@ -2166,15 +2205,23 @@ export function createWechatMpService({
|
||||
retainUnlinkedRoute = false,
|
||||
userContext = null,
|
||||
}) => {
|
||||
if (forceNew) {
|
||||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
||||
}
|
||||
const workingDir = await userAuth.resolveWorkingDir(userId);
|
||||
let sessionPolicy =
|
||||
await userAuth.getAgentSessionPolicy(userId);
|
||||
const publishLayout = await userAuth.getUserPublishLayout(userId);
|
||||
const addressName = resolveWechatAddressName(userContext);
|
||||
let carriedSessionContentForNewSession = '';
|
||||
if (forceNew) {
|
||||
const routeBeforeForceNew = await userAuth.getWechatAgentRoute(config.appId, openid);
|
||||
if (routeBeforeForceNew?.agentSessionId) {
|
||||
carriedSessionContentForNewSession = await fetchLastSubstantiveAssistantFromSession(
|
||||
fetchForSession,
|
||||
routeBeforeForceNew.agentSessionId,
|
||||
);
|
||||
rememberedWechatContexts.delete(routeBeforeForceNew.agentSessionId);
|
||||
}
|
||||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
||||
}
|
||||
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
|
||||
if (existingRoute?.agentSessionId) {
|
||||
const now = Date.now();
|
||||
@@ -2386,6 +2433,61 @@ export function createWechatMpService({
|
||||
};
|
||||
};
|
||||
|
||||
const rotateWechatSessionIfImagePolluted = async ({
|
||||
userId,
|
||||
openid,
|
||||
sessionId,
|
||||
user,
|
||||
carriedSessionContent = '',
|
||||
}) => {
|
||||
if (!sessionId) {
|
||||
return { sessionId, carriedSessionContent, rotated: false };
|
||||
}
|
||||
try {
|
||||
const response = await fetchForSession(
|
||||
sessionId,
|
||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
return { sessionId, carriedSessionContent, rotated: false };
|
||||
}
|
||||
const payload = await readJsonResponse(response);
|
||||
const conversation = Array.isArray(payload?.conversation) ? payload.conversation : [];
|
||||
const hasImageUrlContent = conversationHasImageUrlContent(conversation);
|
||||
// REGRESSION GUARD: vision-turn-read-image-isolation — read_image leaves
|
||||
// base64 image parts inside tool responses instead of image_url. Goose
|
||||
// keeps them, so the next turn against a text-only chat provider fails
|
||||
// before the agent can write any page.
|
||||
const hasToolImageContent = conversationHasToolImageContent(conversation);
|
||||
if (!hasImageUrlContent && !hasToolImageContent) {
|
||||
return { sessionId, carriedSessionContent, rotated: false };
|
||||
}
|
||||
logger.warn?.('WeChat MP rotating image-polluted agent session before reply:', {
|
||||
agentSessionId: sessionId,
|
||||
conversationLength: conversation.length,
|
||||
hasImageUrlContent,
|
||||
hasToolImageContent,
|
||||
});
|
||||
const nextRoute = await ensureWechatAgentSession({
|
||||
userId,
|
||||
openid,
|
||||
forceNew: true,
|
||||
userContext: user,
|
||||
});
|
||||
const nextCarried = String(carriedSessionContent ?? '').trim()
|
||||
|| String(nextRoute.carriedSessionContent ?? '').trim();
|
||||
return {
|
||||
sessionId: nextRoute.sessionId,
|
||||
carriedSessionContent: nextCarried,
|
||||
rotated: true,
|
||||
isNewSession: nextRoute.isNewSession,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn?.('WeChat MP image-polluted session rotate skipped:', err);
|
||||
return { sessionId, carriedSessionContent, rotated: false };
|
||||
}
|
||||
};
|
||||
|
||||
const refreshWechatSessionSnapshot = async (sessionId, userId) => {
|
||||
if (!sessionId || !userId || typeof refreshSessionSnapshot !== 'function') return;
|
||||
try {
|
||||
@@ -2693,6 +2795,26 @@ export function createWechatMpService({
|
||||
return { sessionId };
|
||||
}
|
||||
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
||||
const pollutionRotation = await rotateWechatSessionIfImagePolluted({
|
||||
userId: user.userId,
|
||||
openid: inbound.fromUserName,
|
||||
sessionId,
|
||||
user,
|
||||
carriedSessionContent,
|
||||
});
|
||||
if (pollutionRotation.rotated) {
|
||||
sessionId = pollutionRotation.sessionId;
|
||||
route = {
|
||||
...route,
|
||||
sessionId,
|
||||
isNewSession: pollutionRotation.isNewSession ?? true,
|
||||
};
|
||||
if (pollutionRotation.carriedSessionContent) {
|
||||
carriedSessionContent = pollutionRotation.carriedSessionContent;
|
||||
}
|
||||
await ensureSessionProvider(sessionId);
|
||||
await rememberWechatUserContext(sessionId, user, { forceBootstrap: true });
|
||||
}
|
||||
if (
|
||||
wechatIntent.kind === 'page.generate'
|
||||
&& sessionPageContinuation
|
||||
@@ -3157,6 +3279,13 @@ export function createWechatMpService({
|
||||
sessionId = route.sessionId;
|
||||
await ensureSessionProvider(sessionId);
|
||||
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
||||
if (historicalImageError) {
|
||||
prepareWechatIntentForHistoricalImageRetry(intent, {
|
||||
fallbackImageUrl: resolveWechatRecentMediaPublicUrl(
|
||||
recentMediaByOpenid.get(String(inbound.fromUserName ?? '').trim()),
|
||||
),
|
||||
});
|
||||
}
|
||||
const retryId = crypto.randomUUID();
|
||||
const retryStartedAt = Date.now();
|
||||
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
|
||||
@@ -3683,6 +3812,22 @@ export function createWechatMpService({
|
||||
};
|
||||
intent.agentText = `[图片1]: ${persisted.publicUrl}`;
|
||||
rememberRecentMedia(inbound.fromUserName, intent);
|
||||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||||
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
|
||||
await userAuth.finishWechatMpMessage({
|
||||
appId: config.appId,
|
||||
openid: inbound.fromUserName,
|
||||
msgId: inbound.msgId,
|
||||
status: 'done',
|
||||
agentSessionId: null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
body: await buildPassiveReplyBody(WECHAT_IMAGE_STORED_ACK_TEXT),
|
||||
};
|
||||
} catch (error) {
|
||||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||||
return {
|
||||
|
||||
+531
-115
@@ -14,6 +14,9 @@ import {
|
||||
isRecoverableWechatAgentSessionError,
|
||||
isWechatAgentApiErrorText,
|
||||
isWechatHistoricalImageSessionError,
|
||||
WECHAT_IMAGE_STORED_ACK_TEXT,
|
||||
prepareWechatIntentForHistoricalImageRetry,
|
||||
resolveWechatRecentMediaPublicUrl,
|
||||
sanitizeWechatAgentOutboundText,
|
||||
loadWechatMpConfig,
|
||||
maybeAttachPublishedHtmlLink,
|
||||
@@ -1330,6 +1333,40 @@ test('wechat mp service splits long agent replies into multiple customer message
|
||||
assert.equal(combined, longReply);
|
||||
});
|
||||
|
||||
test('wechat mp sendTextToUser preserves verified MindSpace public links', async () => {
|
||||
const sentBodies = [];
|
||||
const service = createBoundWechatService({
|
||||
userAuth: {
|
||||
async getWechatOpenidForUser() {
|
||||
return 'openid-1';
|
||||
},
|
||||
},
|
||||
wechatFetch: async (url, init) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
sentBodies.push(JSON.parse(String(init?.body ?? '{}')).text.content);
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const url = 'https://m.tkmind.cn/MindSpace/user-1/public/daily-news-0817.html';
|
||||
const result = await service.sendTextToUser(
|
||||
'user-1',
|
||||
`页面已生成:${url}`,
|
||||
{ verifiedHtmlUrls: [url] },
|
||||
);
|
||||
assert.equal(result.sent, true);
|
||||
assert.match(sentBodies[0], /daily-news-0817\.html/);
|
||||
assert.doesNotMatch(sentBodies[0], /页面生成未完成/);
|
||||
});
|
||||
|
||||
test('wechat mp service defers 45047 customer-service delivery instead of throwing', async () => {
|
||||
const service = createBoundWechatService({
|
||||
userAuth: {
|
||||
@@ -3498,6 +3535,40 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
|
||||
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
|
||||
});
|
||||
|
||||
test('prepareWechatIntentForHistoricalImageRetry keeps image url in text only', () => {
|
||||
const intent = prepareWechatIntentForHistoricalImageRetry({
|
||||
agentText: '解读报告,生成页面',
|
||||
media: { publicUrl: 'https://example.com/report.png' },
|
||||
recentMediaItems: [{ media: { publicUrl: 'https://example.com/report.png' } }],
|
||||
recentMediaBatchId: 'batch-1',
|
||||
});
|
||||
assert.match(intent.agentText, /https:\/\/example\.com\/report\.png/);
|
||||
assert.equal(intent.media, undefined);
|
||||
assert.equal(intent.recentMediaItems, undefined);
|
||||
assert.equal(intent.recentMediaBatchId, undefined);
|
||||
});
|
||||
|
||||
test('prepareWechatIntentForHistoricalImageRetry can recover image url from recent media cache', () => {
|
||||
const intent = prepareWechatIntentForHistoricalImageRetry(
|
||||
{ agentText: '帮我把报告再详细点' },
|
||||
{ fallbackImageUrl: 'https://example.com/report.png' },
|
||||
);
|
||||
assert.match(intent.agentText, /https:\/\/example\.com\/report\.png/);
|
||||
assert.equal(intent.media, undefined);
|
||||
});
|
||||
|
||||
test('resolveWechatRecentMediaPublicUrl reads the latest remembered image', () => {
|
||||
assert.equal(
|
||||
resolveWechatRecentMediaPublicUrl({
|
||||
items: [
|
||||
{ media: { publicUrl: 'https://example.com/first.png' } },
|
||||
{ media: { publicUrl: 'https://example.com/second.png' } },
|
||||
],
|
||||
}),
|
||||
'https://example.com/second.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('wechat mp rotates and retries when historical image isolation is unsupported', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
@@ -4917,7 +4988,7 @@ test('wechat mp wildcard media access persists image and routes image url into a
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
crypto.randomUUID = () => 'req-image';
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
const imageResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'image',
|
||||
content: '',
|
||||
@@ -4929,9 +5000,15 @@ test('wechat mp wildcard media access persists image and routes image url into a
|
||||
signature: signatureFor(token, timestamp, nonce),
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.body, /<Content>/);
|
||||
await result.task;
|
||||
assert.equal(imageResult.status, 200);
|
||||
assert.match(imageResult.body ?? '', /已收到图片/);
|
||||
assert.equal(prompts.length, 0);
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '请分析刚才图片' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await followupResult.task;
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), {
|
||||
@@ -4941,17 +5018,13 @@ test('wechat mp wildcard media access persists image and routes image url into a
|
||||
}
|
||||
|
||||
assert.equal(prompts.length, 1);
|
||||
assert.match(prompts[0], /【微信服务号图片消息】/);
|
||||
assert.match(
|
||||
prompts[0],
|
||||
/\[图片1\]: https:\/\/example\.com\/MindSpace\/test-user-image\/public\/wechat-mp\//,
|
||||
);
|
||||
assert.match(prompts[0], /请分析刚才图片/);
|
||||
assert.equal(metadataCalls.length, 1);
|
||||
assert.equal(metadataCalls[0].source, 'wechat_mp');
|
||||
assert.equal(metadataCalls[0].msgType, 'image');
|
||||
assert.equal(metadataCalls[0].msgType, 'text');
|
||||
assert.equal(metadataCalls[0].imageUrls.length, 1);
|
||||
assert.match(metadataCalls[0].imageUrls[0], /\/public\/wechat-mp\//);
|
||||
assert.equal(detailCalls.length, 1);
|
||||
assert.equal(detailCalls.length, 2);
|
||||
assert.match(detailCalls[0].mediaPublicUrl, /\/wechat-mp\//);
|
||||
});
|
||||
|
||||
@@ -5014,7 +5087,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
const imageResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'image',
|
||||
content: '',
|
||||
@@ -5022,7 +5095,14 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await result.task;
|
||||
assert.match(imageResult.body ?? '', /已收到图片/);
|
||||
assert.equal(submitCalls.length, 0);
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '请分析刚才图片' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await followupResult.task;
|
||||
} finally {
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
@@ -5030,6 +5110,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
|
||||
assert.equal(submitCalls.length, 1);
|
||||
assert.equal(submitCalls[0].userId, testUserId);
|
||||
assert.equal(submitCalls[0].sessionId, 'session-1');
|
||||
assert.equal(submitCalls[0].options?.requireHistoricalImageIsolation, true);
|
||||
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1);
|
||||
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /\/public\/wechat-mp\//);
|
||||
});
|
||||
@@ -5337,8 +5418,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-image-followup';
|
||||
const submitCalls = [];
|
||||
let eventCall = 0;
|
||||
let releaseFirst = null;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
@@ -5351,25 +5430,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
|
||||
},
|
||||
sessionApiFetch: async (_sessionId, pathname) => {
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
eventCall += 1;
|
||||
if (eventCall === 1) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
releaseFirst = () => {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' +
|
||||
'data: {"type":"Finish"}\n\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
};
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已结合刚才图片分析主题。"}]}}\n\n',
|
||||
@@ -5419,28 +5479,21 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 0);
|
||||
assert.match(imageResult.body ?? '', new RegExp(WECHAT_IMAGE_STORED_ACK_TEXT.slice(0, 8)));
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '请根据刚才图片分析主题' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 1);
|
||||
|
||||
releaseFirst();
|
||||
await imageResult.task;
|
||||
await followupResult.task;
|
||||
} finally {
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.equal(submitCalls.length, 2);
|
||||
assert.deepEqual(
|
||||
submitCalls[1].userMessage.metadata.imageUrls,
|
||||
submitCalls[0].userMessage.metadata.imageUrls,
|
||||
);
|
||||
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls.length, 1);
|
||||
assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls));
|
||||
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
|
||||
});
|
||||
|
||||
test('wechat mp reattaches recent image for report interpretation follow-up', async () => {
|
||||
@@ -5449,8 +5502,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-report-followup';
|
||||
const submitCalls = [];
|
||||
let eventCall = 0;
|
||||
let releaseFirst = null;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
@@ -5463,25 +5514,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
|
||||
},
|
||||
sessionApiFetch: async (_sessionId, pathname) => {
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
eventCall += 1;
|
||||
if (eventCall === 1) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
releaseFirst = () => {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' +
|
||||
'data: {"type":"Finish"}\n\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
};
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已解读报告。"}]}}\n\n',
|
||||
@@ -5531,29 +5563,439 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 0);
|
||||
assert.match(imageResult.body ?? '', /已收到图片/);
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '解读详细报告,做成页面' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 1);
|
||||
|
||||
releaseFirst();
|
||||
await imageResult.task;
|
||||
await followupResult.task;
|
||||
} finally {
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.equal(submitCalls.length, 2);
|
||||
assert.deepEqual(
|
||||
submitCalls[1].userMessage.metadata.imageUrls,
|
||||
submitCalls[0].userMessage.metadata.imageUrls,
|
||||
assert.equal(submitCalls.length, 1);
|
||||
assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls));
|
||||
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls[0].userMessage.metadata.displayText, '解读详细报告,做成页面');
|
||||
});
|
||||
|
||||
test('wechat mp rotates polluted image session before a content-edit follow-up', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-image-followup-rotate';
|
||||
const submitCalls = [];
|
||||
let activeSessionId = 'session-1';
|
||||
let nextSessionId = 2;
|
||||
let routeCleared = false;
|
||||
const pollutedConversation = [
|
||||
{
|
||||
id: 'assistant-report',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [{ type: 'text', text: '报告页面已生成。' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-image',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: false },
|
||||
content: [{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } }],
|
||||
},
|
||||
];
|
||||
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
mediaAnalysisGrayUsers: [testUserId],
|
||||
},
|
||||
userAuth: {
|
||||
async findWechatUserByOpenid() {
|
||||
return { userId: testUserId, status: 'active', nickname: '唐' };
|
||||
},
|
||||
async getWechatAgentRoute() {
|
||||
if (routeCleared || !activeSessionId) return null;
|
||||
return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() };
|
||||
},
|
||||
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||
activeSessionId = agentSessionId;
|
||||
routeCleared = false;
|
||||
},
|
||||
async clearWechatAgentRoute() {
|
||||
routeCleared = true;
|
||||
},
|
||||
},
|
||||
startAgentSession: async () => ({ id: `session-${nextSessionId++}` }),
|
||||
sessionApiFetch: async (sessionId, pathname) => {
|
||||
if (pathname === `/sessions/${sessionId}`) {
|
||||
const body = sessionId === 'session-1'
|
||||
? { conversation: pollutedConversation }
|
||||
: { conversation: [] };
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (pathname === `/sessions/${sessionId}/events`) {
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","request_id":"req-followup","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已按你的要求补充报告细节。"}]}}\n\n',
|
||||
'data: {"type":"Finish","request_id":"req-followup","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
|
||||
},
|
||||
submitSessionReply: async (input) => {
|
||||
submitCalls.push(input);
|
||||
return { ok: true };
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
crypto.randomUUID = (() => {
|
||||
const ids = ['req-followup'];
|
||||
return () => ids.shift() ?? 'req-followup';
|
||||
})();
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '帮我把报告再详细一点' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await result.task;
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
}
|
||||
|
||||
assert.equal(submitCalls.length, 1);
|
||||
assert.notEqual(submitCalls[0].sessionId, 'session-1');
|
||||
assert.equal(activeSessionId, submitCalls[0].sessionId);
|
||||
});
|
||||
|
||||
test('wechat mp rotates a session poisoned by read_image tool results', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-read-image-rotate';
|
||||
const submitCalls = [];
|
||||
let activeSessionId = 'session-1';
|
||||
let nextSessionId = 2;
|
||||
let routeCleared = false;
|
||||
// read_image hides base64 payloads inside the tool response instead of image_url,
|
||||
// so the older image_url-only scan let this session keep failing every turn.
|
||||
const pollutedConversation = [
|
||||
{
|
||||
id: 'assistant-read',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'toolRequest',
|
||||
id: 'call_1',
|
||||
toolCall: { status: 'success', value: { name: 'read_image', arguments: { path: 'a.jpg' } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'user-read-result',
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'toolResponse',
|
||||
id: 'call_1',
|
||||
toolResult: {
|
||||
status: 'success',
|
||||
value: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Loaded image from a.jpg (202672 bytes, image/jpeg, 1280x1707).' },
|
||||
{ type: 'image', data: '/9j/4AAQSkZJRg==', mimeType: 'image/jpeg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
mediaAnalysisGrayUsers: [testUserId],
|
||||
},
|
||||
userAuth: {
|
||||
async findWechatUserByOpenid() {
|
||||
return { userId: testUserId, status: 'active', nickname: '唐' };
|
||||
},
|
||||
async getWechatAgentRoute() {
|
||||
if (routeCleared || !activeSessionId) return null;
|
||||
return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() };
|
||||
},
|
||||
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||
activeSessionId = agentSessionId;
|
||||
routeCleared = false;
|
||||
},
|
||||
async clearWechatAgentRoute() {
|
||||
routeCleared = true;
|
||||
},
|
||||
},
|
||||
startAgentSession: async () => ({ id: `session-${nextSessionId++}` }),
|
||||
sessionApiFetch: async (sessionId, pathname) => {
|
||||
if (pathname === `/sessions/${sessionId}`) {
|
||||
const body = sessionId === 'session-1'
|
||||
? { conversation: pollutedConversation }
|
||||
: { conversation: [] };
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (pathname === `/sessions/${sessionId}/events`) {
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","request_id":"req-read-image","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"页面已生成。"}]}}\n\n',
|
||||
'data: {"type":"Finish","request_id":"req-read-image","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
|
||||
},
|
||||
submitSessionReply: async (input) => {
|
||||
submitCalls.push(input);
|
||||
return { ok: true };
|
||||
},
|
||||
wechatFetch: async (url) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
crypto.randomUUID = () => 'req-read-image';
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '帮我把内容再详细一点' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await result.task;
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
}
|
||||
|
||||
assert.equal(submitCalls.length, 1);
|
||||
assert.notEqual(submitCalls[0].sessionId, 'session-1');
|
||||
assert.equal(activeSessionId, submitCalls[0].sessionId);
|
||||
});
|
||||
|
||||
test('wechat mp simulates tang image report page and desensitized follow-up', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-tang-report-followup';
|
||||
const submitCalls = [];
|
||||
const customerReplies = [];
|
||||
let activeSessionId = 'session-1';
|
||||
let nextSessionId = 2;
|
||||
let routeCleared = false;
|
||||
let reportPageCompleted = false;
|
||||
let reportSessionId = null;
|
||||
const pollutedConversation = [
|
||||
{
|
||||
id: 'assistant-report',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [{ type: 'text', text: '已解读化验报告并生成页面链接。' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-image',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: false },
|
||||
content: [{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } }],
|
||||
},
|
||||
];
|
||||
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
mediaAnalysisGrayUsers: [testUserId],
|
||||
},
|
||||
userAuth: {
|
||||
async findWechatUserByOpenid() {
|
||||
return { userId: testUserId, status: 'active', nickname: '唐' };
|
||||
},
|
||||
async getWechatAgentRoute() {
|
||||
if (routeCleared || !activeSessionId) return null;
|
||||
return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() };
|
||||
},
|
||||
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||
activeSessionId = agentSessionId;
|
||||
routeCleared = false;
|
||||
},
|
||||
async clearWechatAgentRoute() {
|
||||
routeCleared = true;
|
||||
},
|
||||
},
|
||||
startAgentSession: async () => ({ id: `session-${nextSessionId++}` }),
|
||||
sessionApiFetch: async (sessionId, pathname) => {
|
||||
if (pathname === `/sessions/${sessionId}`) {
|
||||
const conversation =
|
||||
reportSessionId && sessionId === reportSessionId && reportPageCompleted
|
||||
? pollutedConversation
|
||||
: [];
|
||||
return new Response(JSON.stringify({ conversation }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (pathname === `/sessions/${sessionId}/events`) {
|
||||
const isReportStream = submitCalls.length === 0;
|
||||
const text = isReportStream
|
||||
? '报告页面已生成:https://m.tkmind.cn/MindSpace/test/public/report.html'
|
||||
: '已按你的要求脱敏并补充报告细节。';
|
||||
return new Response(
|
||||
[
|
||||
`data: {"type":"Message","message":{"id":"assistant-${isReportStream ? 'report' : 'edit'}","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"${text}"}]}}\n\n`,
|
||||
'data: {"type":"Finish","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
|
||||
},
|
||||
submitSessionReply: async (input) => {
|
||||
submitCalls.push(input);
|
||||
if (!reportSessionId) {
|
||||
reportSessionId = input.sessionId;
|
||||
reportPageCompleted = true;
|
||||
}
|
||||
assert.equal(input.options?.requireHistoricalImageIsolation, true);
|
||||
return { ok: true };
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/media/get')) {
|
||||
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||
customerReplies.push(JSON.parse(init.body ?? '{}'));
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resetResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '换新会话', extraFields: { MsgId: 'tang-1' } }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
if (resetResult.task) await resetResult.task;
|
||||
assert.equal(submitCalls.length, 0);
|
||||
|
||||
const imageResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'image',
|
||||
content: '',
|
||||
extraFields: {
|
||||
MsgId: 'tang-2',
|
||||
MediaId: 'media-report',
|
||||
PicUrl: 'https://wx.example.com/report.png',
|
||||
},
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
assert.equal(submitCalls.length, 0);
|
||||
assert.match(imageResult.body ?? '', /已收到图片/);
|
||||
|
||||
const reportResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'text',
|
||||
content: '解读报告并生成页面',
|
||||
extraFields: { MsgId: 'tang-3' },
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await reportResult.task;
|
||||
|
||||
const editResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'text',
|
||||
content: '帮我把真名脱敏,报告再详细点',
|
||||
extraFields: { MsgId: 'tang-4' },
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await editResult.task;
|
||||
} finally {
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.ok(submitCalls.length >= 2, `submitCalls=${submitCalls.length}`);
|
||||
const firstReportSubmit = submitCalls.find(
|
||||
(call) => Array.isArray(call.userMessage.metadata?.imageUrls) && call.userMessage.metadata.imageUrls.length > 0,
|
||||
);
|
||||
assert.ok(firstReportSubmit, 'missing report submit with imageUrls');
|
||||
assert.equal(firstReportSubmit.sessionId, reportSessionId);
|
||||
assert.notEqual(submitCalls.at(-1).sessionId, reportSessionId);
|
||||
assert.ok(customerReplies.length >= 2);
|
||||
const replyTexts = customerReplies.map((payload) => String(payload?.text?.content ?? ''));
|
||||
for (const text of replyTexts) {
|
||||
assert.doesNotMatch(text, /unknown variant [`']?image_url/i);
|
||||
assert.doesNotMatch(text, /Ran into this error:/i);
|
||||
}
|
||||
assert.ok(replyTexts.some((text) => /脱敏/.test(text)), replyTexts.join(' | '));
|
||||
assert.ok(
|
||||
replyTexts.some((text) => /report\.html/.test(text))
|
||||
|| submitCalls.some((call) => Array.isArray(call.userMessage.metadata?.imageUrls)),
|
||||
replyTexts.join(' | '),
|
||||
);
|
||||
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls[1].userMessage.metadata.displayText, '解读详细报告,做成页面');
|
||||
});
|
||||
|
||||
test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => {
|
||||
@@ -5562,8 +6004,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-multi-image-followup';
|
||||
const submitCalls = [];
|
||||
let eventCall = 0;
|
||||
let releaseFirst = null;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
@@ -5576,25 +6016,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
},
|
||||
sessionApiFetch: async (_sessionId, pathname) => {
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
eventCall += 1;
|
||||
if (eventCall === 1) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
releaseFirst = () => {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'data: {"type":"Message","message":{"id":"assistant-first","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"第一张处理完成。"}]}}\n\n' +
|
||||
'data: {"type":"Finish"}\n\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
};
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","message":{"id":"assistant-ok","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"处理完成。"}]}}\n\n',
|
||||
@@ -5648,7 +6069,8 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 0);
|
||||
assert.match(firstImageResult.body ?? '', /已收到图片/);
|
||||
|
||||
const secondImageResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
@@ -5662,6 +6084,8 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
assert.equal(submitCalls.length, 0);
|
||||
assert.match(secondImageResult.body ?? '', /已收到图片/);
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
@@ -5670,12 +6094,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 1);
|
||||
|
||||
releaseFirst();
|
||||
await firstImageResult.task;
|
||||
await secondImageResult.task;
|
||||
await followupResult.task;
|
||||
|
||||
const laterResult = await service.handleInboundMessage(
|
||||
@@ -5690,14 +6108,12 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.equal(submitCalls.length, 4);
|
||||
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1);
|
||||
assert.equal(submitCalls[1].userMessage.metadata.imageUrls.length, 1);
|
||||
assert.equal(submitCalls[2].userMessage.metadata.imageUrls.length, 2);
|
||||
assert.match(submitCalls[2].userMessage.metadata.imageUrls[0], /media-first/);
|
||||
assert.match(submitCalls[2].userMessage.metadata.imageUrls[1], /media-second/);
|
||||
assert.equal(submitCalls[2].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls[3].userMessage.metadata.imageUrls, undefined);
|
||||
assert.equal(submitCalls.length, 2);
|
||||
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 2);
|
||||
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /media-first/);
|
||||
assert.match(submitCalls[0].userMessage.metadata.imageUrls[1], /media-second/);
|
||||
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls[1].userMessage.metadata.imageUrls, undefined);
|
||||
});
|
||||
|
||||
test('wechat mp service persists Word and Excel files in user public area and reuses H5 attachment metadata', async () => {
|
||||
|
||||
Reference in New Issue
Block a user