Compare commits

...

21 Commits

Author SHA1 Message Date
john 26846a0274 feat(wechat): add WeChat voice reco API fallback for service account
Memind CI / Test, build, and release guards (push) Successful in 3m27s
When passive Recognition is empty, convert AMR to mp3 and call WeChat
addvoicetorecofortext before the existing asr.tkmind.cn fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:01:50 +08:00
john d9db72fd90 fix(billing): use admin_adjust ledger reason for setImageQuota
Memind CI / Test, build, and release guards (push) Successful in 4m19s
Production ledger reason column is ENUM and rejects admin_set.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:24:24 +08:00
john f488d49d51 fix(billing): add setImageQuota for admin absolute remaining/total
Memind CI / Test, build, and release guards (push) Has been cancelled
Grant-only bonus adjustments cannot lower capacity below the stored plan
limit; admin set now updates period_images_limit when needed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:21:07 +08:00
john fda90d8579 merge: billing PG session cost fallback
Memind CI / Test, build, and release guards (push) Successful in 17m14s
2026-08-05 15:48:44 +08:00
john 8c1ae7550d fix(billing): read Goose accumulated_cost from PostgreSQL fallback
Goosed GET /sessions/:id returns accumulated_cost null while memind_sessions
PG has the real upstream cost. Fall back to PG before token estimate billing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:48:39 +08:00
john e2014e05e6 fix(analytics): stop identifying Public visitors as page creators
Public generated pages were calling umami.identify(owner_id), collapsing
every visitor into the creator session. Anonymous visits now skip identify;
logged-in viewers identify with their own distinct id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:05:18 +08:00
john 9e50681d96 merge: billing branch disposition registration
Memind CI / Test, build, and release guards (push) Failing after 10m24s
2026-08-05 10:04:02 +08:00
john eb8eedb07f docs: register billing session cost fix branch disposition
Record closure of feature/billing-session-cost-priority after merge to main
and 103 release 20260805-095542-44121df.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 10:04:02 +08:00
john 44121df83c merge: billing session cost priority and compensation script
Memind CI / Test, build, and release guards (push) Has been cancelled
Fix inflated token-estimate overcharge by preferring Goose accumulated_cost;
includes Aug 4+ user compensation tooling.
2026-08-05 09:53:10 +08:00
john e7f0627dc9 fix(billing): prefer Goose session cost over token estimate
Billing fell back to inflated token list-price estimates when Finish
frames carried inline accumulatedCost, causing ~70x overcharge vs DeepSeek.
Always fetch session accumulated_cost first; add Aug 4+ compensation script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 09:52:13 +08:00
john 1e7004dffe fix(wechat): apply agent timeout and Page Data review to all users
Memind CI / Test, build, and release guards (push) Failing after 10m39s
Remove per-user gray gates so WeChat MP always honors agentReplyTimeoutMs and enables Page Data Aider review when the feature flag is on.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 09:32:27 +08:00
john 089a44fb11 docs: close codex/flux-schnell-workflow-kind after main rollback release
Memind CI / Test, build, and release guards (push) Successful in 4m25s
Register the abandoned flux branch as non-reusable after 103 returned to main @ 6a48e0a.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 20:48:49 +08:00
john 6a48e0ad91 merge: native goosed remount for Portal release
Memind CI / Test, build, and release guards (push) Successful in 4m59s
2026-08-03 16:50:54 +08:00
john c2189ee30d fix(release): restart native goosed pool after Portal live swap on 103
103 production uses launchd goosed-native instead of Docker bind mounts;
fall back to kickstarting native services when docker compose is unavailable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:50:54 +08:00
john 2cc98b9392 merge: retrigger CI on host runner
Memind CI / Test, build, and release guards (push) Successful in 7m3s
2026-08-03 16:12:39 +08:00
john 2f4dd39181 ci: retrigger on host-mode runner
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:12:36 +08:00
john 4e66c43350 merge: retrigger CI after runner wrapper
Memind CI / Test, build, and release guards (push) Failing after 2s
2026-08-03 16:11:09 +08:00
john 85872e1e84 ci: retrigger after runner wrapper fix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:11:06 +08:00
john 93aa7c1cfa merge: retrigger CI after runner recovery
Memind CI / Test, build, and release guards (push) Failing after 8s
2026-08-03 16:06:09 +08:00
john 49c2671845 ci: retrigger workflow after runner and Colima recovery
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:06:07 +08:00
john 5cebd1121e merge: map Gitea workflows as non-runtime for release gate
Memind CI / Test, build, and release guards (push) Failing after 7s
2026-08-03 15:50:29 +08:00
20 changed files with 1232 additions and 41 deletions
+5
View File
@@ -338,6 +338,11 @@ H5_ACCESS_PASSWORD=change-me
# H5_ASR_TARGET=https://asr.tkmind.cn
# H5_ASR_MAX_BYTES=5242880
# H5_ASR_TIMEOUT_MS=45000
# 微信服务号语音:Recognition 为空时优先走微信 addvoicetorecofortext(需 ffmpeg 转 mp3);失败再回落 H5_ASR
# H5_WECHAT_MP_VOICE_RECO_API=1
# H5_WECHAT_MP_VOICE_RECO_LANG=zh_CN
# H5_WECHAT_MP_VOICE_RECO_API_BASE=https://api.weixin.qq.com
# H5_FFMPEG_PATH=ffmpeg
# 前端构建时注入(Vite,需 VITE_ 前缀)
# 工作目录:新建会话时使用,必填
+86
View File
@@ -704,6 +704,91 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
}
};
const setImageQuota = async (userId, { remaining = null, total = null } = {}, { operatorId = null, note = '' } = {}) => {
const hasRemaining = remaining !== null && remaining !== undefined;
const hasTotal = total !== null && total !== undefined;
if (hasRemaining === hasTotal) {
return { ok: false, message: '请指定 remaining 或 total 其中之一' };
}
const target = Math.floor(Number(hasRemaining ? remaining : total));
if (!Number.isFinite(target) || target < 0) {
return { ok: false, message: '额度必须是非负整数' };
}
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT * FROM h5_subscriptions
WHERE user_id = ? AND status = 'active' AND expires_at > ?
ORDER BY expires_at DESC LIMIT 1 FOR UPDATE`,
[userId, now],
);
const sub = mapSubRow(rows[0]);
if (!sub) {
await conn.rollback();
return { ok: false, message: '用户没有有效订阅,无法设置图片额度' };
}
if (sub.periodImagesLimit === 0) {
await conn.rollback();
return { ok: false, message: '无限额度套餐无法调整' };
}
const used = sub.periodImagesUsed;
const oldCapacity = sub.periodImagesLimit + sub.periodImagesBonus;
const oldRemaining = Math.max(0, oldCapacity - used);
const targetCapacity = hasRemaining ? used + target : target;
if (targetCapacity < used) {
await conn.rollback();
return { ok: false, message: `额度不能低于已用量(${used} 张)` };
}
let newLimit = sub.periodImagesLimit;
let newBonus = sub.periodImagesBonus;
if (targetCapacity >= sub.periodImagesLimit) {
newBonus = targetCapacity - sub.periodImagesLimit;
} else {
newLimit = targetCapacity;
newBonus = 0;
}
if (newLimit === sub.periodImagesLimit && newBonus === sub.periodImagesBonus) {
await conn.rollback();
return { ok: true, subscription: sub, quota: computeImageQuotaView(sub), unchanged: true };
}
await conn.query(
`UPDATE h5_subscriptions
SET period_images_limit = ?, period_images_bonus = ?, updated_at = ?
WHERE id = ?`,
[newLimit, newBonus, now, sub.id],
);
const updatedSub = {
...sub,
periodImagesLimit: newLimit,
periodImagesBonus: newBonus,
};
const quota = computeImageQuotaView(updatedSub);
const ledgerDelta = hasRemaining ? target - oldRemaining : targetCapacity - oldCapacity;
await appendImageQuotaLedgerTx(conn, {
userId,
delta: ledgerDelta,
balanceAfter: quota.unlimited ? null : quota.remaining,
reason: 'admin_adjust',
operatorId,
note,
});
await conn.commit();
return { ok: true, subscription: updatedSub, quota };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
};
const grantImageQuota = async (userId, delta, { operatorId = null, note = '' } = {}) => {
const safeDelta = Math.floor(Number(delta));
if (!Number.isFinite(safeDelta) || safeDelta === 0) {
@@ -820,6 +905,7 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
consumeImageQuota,
getImageQuota,
checkImageQuota,
setImageQuota,
grantImageQuota,
listImageQuotaLedger,
renewSubscription,
+46
View File
@@ -273,6 +273,52 @@ describe('createSubscriptionService', () => {
});
});
describe('setImageQuota', () => {
it('lowers remaining below plan limit by reducing period_images_limit', async () => {
const subRow = makeSubRow({
period_images_limit: 1000,
period_images_bonus: 0,
period_images_used: 32,
});
const pool = makePool(subRow);
const svc = createSubscriptionService(pool);
const result = await svc.setImageQuota('user-1', { remaining: 100 });
assert.equal(result.ok, true);
assert.equal(result.quota.remaining, 100);
assert.equal(result.quota.total, 132);
assert.ok(
pool._conn.queries.some(
({ sql, params }) =>
sql.includes('SET period_images_limit = ?, period_images_bonus = ?') &&
params?.[0] === 132 &&
params?.[1] === 0,
),
);
});
it('raises remaining above plan limit via bonus', async () => {
const subRow = makeSubRow({
period_images_limit: 50,
period_images_bonus: 0,
period_images_used: 10,
});
const pool = makePool(subRow);
const svc = createSubscriptionService(pool);
const result = await svc.setImageQuota('user-1', { remaining: 45 });
assert.equal(result.ok, true);
assert.equal(result.quota.remaining, 45);
assert.equal(result.quota.total, 55);
assert.ok(
pool._conn.queries.some(
({ sql, params }) =>
sql.includes('SET period_images_limit = ?, period_images_bonus = ?') &&
params?.[0] === 50 &&
params?.[1] === 5,
),
);
});
});
describe('cancelSubscription', () => {
it('returns cancelled=true when active sub exists', async () => {
const pool = makePool(makeSubRow());
+40 -19
View File
@@ -1,4 +1,5 @@
import { normalizeTokenState } from './billing.mjs';
import { fetchGooseSessionAccumulatedCostUsd } from './goose-session-cost.mjs';
export function loadCostEstimateConfig(env = process.env) {
const useBackendCost = env.H5_USE_BACKEND_COST === '1';
@@ -38,15 +39,16 @@ export function enrichTokenStateForBilling(
env = process.env,
) {
const state = normalizeTokenState(tokenStateRaw);
if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
return state;
}
const sessionUsd = pickSessionAccumulatedCost(sessionCost);
if (sessionUsd != null) {
return { ...state, accumulatedCost: sessionUsd };
}
if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
return state;
}
const estimatedUsd = estimateAccumulatedCostUsd(state, loadCostEstimateConfig(env));
if (estimatedUsd != null) {
return { ...state, accumulatedCost: estimatedUsd };
@@ -55,25 +57,44 @@ export function enrichTokenStateForBilling(
return state;
}
async function resolveSessionCostPayload(sessionId, fetchSession, fetchSessionCostFromPg, env) {
let sessionCost = null;
if (typeof fetchSession === 'function' && sessionId) {
try {
sessionCost = await fetchSession(sessionId);
} catch {
sessionCost = null;
}
}
if (pickSessionAccumulatedCost(sessionCost) != null) {
return sessionCost;
}
const readPgCost =
typeof fetchSessionCostFromPg === 'function'
? fetchSessionCostFromPg
: (sid) => fetchGooseSessionAccumulatedCostUsd(sid, env);
const pgUsd = env.H5_USE_BACKEND_COST === '1' ? await readPgCost(sessionId) : null;
if (pgUsd != null) {
return {
...(sessionCost && typeof sessionCost === 'object' ? sessionCost : {}),
accumulated_cost: pgUsd,
};
}
return sessionCost;
}
export async function resolveBillingTokenState(
tokenStateRaw,
{ sessionId = null, fetchSession = null } = {},
{ sessionId = null, fetchSession = null, fetchSessionCostFromPg = null } = {},
env = process.env,
) {
const state = normalizeTokenState(tokenStateRaw);
if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
return state;
}
if (typeof fetchSession === 'function' && sessionId) {
try {
const session = await fetchSession(sessionId);
const enriched = enrichTokenStateForBilling(state, { sessionCost: session }, env);
if (enriched.accumulatedCost != null) return enriched;
} catch {
// Best-effort: fall through to token estimate.
}
}
return enrichTokenStateForBilling(state, {}, env);
const sessionCost = await resolveSessionCostPayload(
sessionId,
fetchSession,
fetchSessionCostFromPg,
env,
);
return enrichTokenStateForBilling(state, { sessionCost }, env);
}
+43 -1
View File
@@ -23,7 +23,16 @@ test('estimateAccumulatedCostUsd uses DeepSeek-ish defaults', () => {
assert.equal(estimate, 0.281);
});
test('enrichTokenStateForBilling prefers upstream cost over estimate', () => {
test('enrichTokenStateForBilling prefers session cost over Finish inline cost', () => {
const enriched = enrichTokenStateForBilling(
{ accumulatedInputTokens: 1000, accumulatedOutputTokens: 100, accumulatedCost: 0.82 },
{ sessionCost: { accumulated_cost: 0.0067 } },
{ H5_USE_BACKEND_COST: '1' },
);
assert.equal(enriched.accumulatedCost, 0.0067);
});
test('enrichTokenStateForBilling keeps Finish inline cost when session cost missing', () => {
const enriched = enrichTokenStateForBilling(
{ accumulatedInputTokens: 1000, accumulatedOutputTokens: 100, accumulatedCost: 0.05 },
{},
@@ -78,6 +87,39 @@ test('resolveBillingTokenState fetches session before estimating', async () => {
assert.equal(resolved.accumulatedCost, 0.42);
});
test('resolveBillingTokenState replaces inflated Finish cost with session cost', async () => {
const resolved = await resolveBillingTokenState(
{
accumulatedInputTokens: 280030,
accumulatedOutputTokens: 5797,
accumulatedCost: 0.082,
},
{
sessionId: '20260804_19',
fetchSession: async () => ({ accumulated_cost: 0.006738208 }),
},
{ H5_USE_BACKEND_COST: '1' },
);
assert.equal(resolved.accumulatedCost, 0.006738208);
});
test('resolveBillingTokenState uses PG cost when goosed session API omits accumulated_cost', async () => {
const resolved = await resolveBillingTokenState(
{
accumulatedInputTokens: 280030,
accumulatedOutputTokens: 5797,
accumulatedCost: 0.082,
},
{
sessionId: '20260804_19',
fetchSession: async () => ({ id: '20260804_19', accumulated_cost: null }),
fetchSessionCostFromPg: async () => 0.006738208,
},
{ H5_USE_BACKEND_COST: '1' },
);
assert.equal(resolved.accumulatedCost, 0.006738208);
});
test('enriched cost drives 1.2x billing instead of flat fallback', () => {
const previous = { lastInputTokens: 224853, lastOutputTokens: 6685, lastAccumulatedCost: null };
const tokenState = enrichTokenStateForBilling(
+42
View File
@@ -410,3 +410,45 @@ Portal,避免在线修改稳定 `.env`,并确保稳定 8081 与其他用户
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或发布。
- 后续管理后台 UI 只能在 memind_adm 5174 开发。
## `codex/flux-schnell-workflow-kind`
**状态:禁止再次引用。分支未并入 `main`;103 曾短暂从该分支发布 `ea5b732` 后已回退到 `main` 整包发布。**
审计日期:2026-08-04
分支 HEAD(已删除):`ea5b732`
`origin/main` 对应提交:`6a48e0a`103 生产 release `20260804-204137-6a48e0a`
### 原始用途
为 Portal admin 配置 ComfyUI `flux_schnell` workflowKind103 曾从该功能分支单包发布。
### 最终处置
- Flux 服务已下线;本地分支已删除。
- **不要** merge、cherry-pick 或从 `ea5b732` 构建 runtime/artifact。
- 103 生产 manifest 已回到 `main` @ `6a48e0a`
## `feature/billing-session-cost-priority`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,103 已发布,该分支保留仅用于只读追溯。**
审计日期:2026-08-05
分支 HEAD`e7f0627`
`origin/main` 对应提交:`44121df`103 生产 release `20260805-095542-44121df`
### 原始用途
修复计费回退 Token 估价导致 DeepSeek 上游成本严重超扣;优先使用 Goose session `accumulated_cost`;新增 8 月 4 日起用户补偿脚本并在 103 执行补偿。
### 验证摘要
- `node --test billing-token-state.test.mjs billing.test.mjs`
- `node --test db.test.mjs capabilities.test.mjs llm-providers.test.mjs wechat-mp.test.mjs`
- 103 补偿脚本 dry-run + apply4 用户,合计 ¥94.11
### 最终处置
- 保留本地分支名用于审计追溯。
- **不要** merge、cherry-pick 或从该分支构建 runtime/artifact。
- 103 生产依据 `44121df` / release manifest `20260805-095542-44121df`
+71
View File
@@ -0,0 +1,71 @@
let sharedPgClientPromise = null;
export function resolveGooseSessionPgUrl(env = process.env) {
const explicit = String(env.GOOSE_SESSION_DB_URL ?? '').trim();
if (explicit) return explicit;
const host = env.GOOSE_SESSION_PG_HOST ?? '127.0.0.1';
const port = env.GOOSE_SESSION_PG_PORT ?? '5432';
const database = env.GOOSE_SESSION_PG_DATABASE ?? 'memind_sessions';
const user = env.GOOSE_SESSION_PG_USER ?? 'john';
const password = env.GOOSE_SESSION_PG_PASSWORD ?? '';
if (password) {
return `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}`;
}
return `postgresql://${user}@${host}:${port}/${database}`;
}
export function isGooseSessionPgConfigured(env = process.env) {
if (String(env.GOOSE_SESSION_DB_URL ?? '').trim()) return true;
return String(env.GOOSE_SESSION_PG_DISABLE ?? '') !== '1';
}
async function getSharedPgClient(env = process.env) {
if (!isGooseSessionPgConfigured(env)) return null;
if (!sharedPgClientPromise) {
sharedPgClientPromise = import('pg')
.then(async ({ default: pg }) => {
const client = new pg.Client({ connectionString: resolveGooseSessionPgUrl(env) });
await client.connect();
return client;
})
.catch((err) => {
sharedPgClientPromise = null;
throw err;
});
}
return sharedPgClientPromise;
}
export async function fetchGooseSessionAccumulatedCostUsd(sessionId, env = process.env) {
const normalizedSessionId = String(sessionId ?? '').trim();
if (!normalizedSessionId || !isGooseSessionPgConfigured(env)) return null;
try {
const client = await getSharedPgClient(env);
const result = await client.query(
`SELECT accumulated_cost FROM sessions WHERE id = $1 LIMIT 1`,
[normalizedSessionId],
);
const raw = result.rows[0]?.accumulated_cost;
if (raw == null) return null;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : null;
} catch {
return null;
}
}
export function createGooseSessionCostReader(env = process.env) {
return (sessionId) => fetchGooseSessionAccumulatedCostUsd(sessionId, env);
}
export async function closeGooseSessionPgClient() {
if (!sharedPgClientPromise) return;
try {
const client = await sharedPgClientPromise;
await client.end();
} catch {
// ignore shutdown errors in tests/scripts
} finally {
sharedPgClientPromise = null;
}
}
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
isGooseSessionPgConfigured,
resolveGooseSessionPgUrl,
} from './goose-session-cost.mjs';
test('resolveGooseSessionPgUrl prefers GOOSE_SESSION_DB_URL', () => {
assert.equal(
resolveGooseSessionPgUrl({ GOOSE_SESSION_DB_URL: 'postgresql://u:p@host:5432/db' }),
'postgresql://u:p@host:5432/db',
);
});
test('resolveGooseSessionPgUrl builds local default DSN', () => {
assert.equal(
resolveGooseSessionPgUrl({
GOOSE_SESSION_PG_HOST: '127.0.0.1',
GOOSE_SESSION_PG_PORT: '5432',
GOOSE_SESSION_PG_DATABASE: 'memind_sessions',
GOOSE_SESSION_PG_USER: 'john',
}),
'postgresql://john@127.0.0.1:5432/memind_sessions',
);
});
test('isGooseSessionPgConfigured can be disabled explicitly', () => {
assert.equal(isGooseSessionPgConfigured({ GOOSE_SESSION_PG_DISABLE: '1' }), false);
assert.equal(isGooseSessionPgConfigured({ GOOSE_SESSION_DB_URL: 'postgresql://x' }), true);
});
+17 -1
View File
@@ -68,6 +68,20 @@ export function resolveAnalyticsOwnerLabel(user = {}) {
return label.replace(/[\r\n\t]+/g, ' ').slice(0, 80) || '未命名用户';
}
export function buildViewerAnalyticsIdentity(viewer, config = {}) {
if (!viewer?.id) return null;
const distinctId = resolveAnalyticsIdentity(viewer.id, config);
if (!distinctId) return null;
return {
distinctId,
username: resolveAnalyticsOwnerLabel(viewer),
ownerSegment: resolveAnalyticsOwnerSegment(viewer),
planType: resolveAnalyticsPlan(viewer),
channel: 'public',
identityMode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous',
};
}
export function buildProductAnalyticsContext({ config, user = null } = {}) {
if (!config?.enabled || !config.websiteId) return { enabled: false };
const distinctId = user?.id ? resolveAnalyticsIdentity(user.id, config) : '';
@@ -152,6 +166,7 @@ export function injectMindSpaceAnalytics(html, {
planType = 'unknown',
generatedAt = '',
channel = 'h5',
viewerIdentity = null,
config = resolveMindSpaceAnalyticsConfig(),
} = {}) {
const source = String(html ?? '');
@@ -167,7 +182,8 @@ export function injectMindSpaceAnalytics(html, {
`data-host-url="${config.hostPath}"`,
];
if (config.domains) attrs.push(`data-domains="${config.domains.replaceAll('"', '&quot;')}"`);
const block = `<script ${attrs.join(' ')} defer src="${config.scriptPath}"></script><script ${ANALYTICS_MARKER}>(function(){var d=${jsonForInlineScript(metadata)},seen={};function safeTarget(h){if(!h)return'';try{var u=new URL(h,location.href);return u.origin===location.origin?u.pathname:'external:'+u.hostname;}catch{return'';}}function t(n,x){if(!window.umami||typeof window.umami.track!=='function')return;var p=Object.assign({},d,{page_url:location.href,page_route:location.pathname,page_title:document.title},x||{});window.umami.track(n,p);}function identify(){if(!window.umami||typeof window.umami.identify!=='function')return;window.umami.identify(d.owner_id,{username:d.username,memind_page_url:location.href,owner_segment:d.owner_segment,plan_type:d.plan_type,channel:d.channel,surface:d.surface,identity_mode:d.identity_mode});}function pageview(){if(!window.umami||typeof window.umami.track!=='function')return;window.umami.track();}function once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function ready(){identify();pageview();document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[role="button"],[data-umami-event]'):null;if(!el)return;var custom=el.getAttribute('data-umami-event');var href=el.tagName==='A'?el.getAttribute('href')||'':'';t(custom||'page_click',{element:el.tagName.toLowerCase(),element_id:el.id||'',event_label:(custom||el.getAttribute('aria-label')||'').slice(0,100),target_path:safeTarget(href)});},{passive:true});document.addEventListener('submit',function(e){var form=e.target;t('page_form_submit',{form_id:form&&form.id||'',form_action:safeTarget(form&&form.getAttribute('action')||'')});},{passive:true});var marks=[25,50,75,90];function scroll(){var h=document.documentElement.scrollHeight-window.innerHeight;if(h<=0){once('page_scroll_100');return;}var pct=Math.round(window.scrollY/h*100);marks.forEach(function(m){if(pct>=m)once('page_scroll_'+m);});}window.addEventListener('scroll',scroll,{passive:true});setTimeout(function(){once('page_engaged_10s');},10000);setTimeout(function(){once('page_engaged_30s');},30000);scroll();}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',ready,{once:true});}else{ready();}})();</script>`;
const viewerJson = viewerIdentity ? jsonForInlineScript(viewerIdentity) : 'null';
const block = `<script ${attrs.join(' ')} defer src="${config.scriptPath}"></script><script ${ANALYTICS_MARKER}>(function(){var d=${jsonForInlineScript(metadata)},v=${viewerJson},seen={};function safeTarget(h){if(!h)return'';try{var u=new URL(h,location.href);return u.origin===location.origin?u.pathname:'external:'+u.hostname;}catch{return'';}}function t(n,x){if(!window.umami||typeof window.umami.track!=='function')return;var p=Object.assign({},d,{page_url:location.href,page_route:location.pathname,page_title:document.title},x||{});window.umami.track(n,p);}function identifyViewer(){if(!v||!v.distinctId||!window.umami||typeof window.umami.identify!=='function')return;window.umami.identify(v.distinctId,{username:v.username||'',memind_page_url:location.href,owner_segment:v.ownerSegment||'',plan_type:v.planType||'',channel:v.channel||'public',surface:'generated_page',identity_mode:v.identityMode||'pseudonymous'});}function pageview(){if(!window.umami||typeof window.umami.track!=='function')return;window.umami.track();}function once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function ready(){identifyViewer();pageview();document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[role="button"],[data-umami-event]'):null;if(!el)return;var custom=el.getAttribute('data-umami-event');var href=el.tagName==='A'?el.getAttribute('href')||'':'';t(custom||'page_click',{element:el.tagName.toLowerCase(),element_id:el.id||'',event_label:(custom||el.getAttribute('aria-label')||'').slice(0,100),target_path:safeTarget(href)});},{passive:true});document.addEventListener('submit',function(e){var form=e.target;t('page_form_submit',{form_id:form&&form.id||'',form_action:safeTarget(form&&form.getAttribute('action')||'')});},{passive:true});var marks=[25,50,75,90];function scroll(){var h=document.documentElement.scrollHeight-window.innerHeight;if(h<=0){once('page_scroll_100');return;}var pct=Math.round(window.scrollY/h*100);marks.forEach(function(m){if(pct>=m)once('page_scroll_'+m);});}window.addEventListener('scroll',scroll,{passive:true});setTimeout(function(){once('page_engaged_10s');},10000);setTimeout(function(){once('page_engaged_30s');},30000);scroll();}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',ready,{once:true});}else{ready();}})();</script>`;
if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}</head>`);
return source.replace(/<body\b/i, `${block}<body`);
}
+56 -9
View File
@@ -4,6 +4,7 @@ import vm from 'node:vm';
import {
buildProductAnalyticsContext,
buildViewerAnalyticsIdentity,
injectMindSpaceAnalytics,
pseudonymizeAnalyticsId,
resolveAnalyticsIdentity,
@@ -124,9 +125,10 @@ test('injects one local same-origin tracker with page dimensions', () => {
assert.match(out, /src="\/analytics\/script\.js"/);
assert.match(out, /data-host-url="\/analytics"/);
assert.match(out, /data-auto-track="false"/);
assert.match(out, /window\.umami\.identify\(d\.owner_id,\{username:d\.username,memind_page_url:location\.href,owner_segment:d\.owner_segment,plan_type:d\.plan_type,channel:d\.channel,surface:d\.surface,identity_mode:d\.identity_mode\}\)/);
assert.doesNotMatch(out, /umami\.identify\(d\.owner_id/);
assert.match(out, /function identifyViewer\(\)/);
assert.match(out, /identifyViewer\(\);pageview\(\)/);
assert.match(out, /function pageview\(\).*window\.umami\.track\(\)/);
assert.ok(out.indexOf('identify();pageview();') > 0);
assert.doesNotMatch(out, /t\('page_view'\)/);
assert.match(out, /page_id/);
assert.match(out, /owner_segment/);
@@ -143,7 +145,7 @@ test('injects one local same-origin tracker with page dimensions', () => {
assert.equal(injectMindSpaceAnalytics(out, { ownerId: 'user-123', config: { enabled: true, websiteId: 'local-website', idSecret: 'secret' } }), out);
});
test('identifies the pseudonymous owner before sending a standard page view', () => {
test('public visitors skip creator identify and only send a standard page view', () => {
const out = injectMindSpaceAnalytics('<!doctype html><html><head></head><body></body></html>', {
ownerId: 'user-123',
ownerSegment: 'plan:pro',
@@ -177,22 +179,67 @@ test('identifies the pseudonymous owner before sending a standard page view', ()
documentElement: { scrollHeight: 1600 },
addEventListener: () => {},
},
location: { href: 'https://m.tkmind.cn/MindSpace/demo/public/page.html' },
location: { href: 'https://m.tkmind.cn/MindSpace/demo/public/page.html', pathname: '/MindSpace/demo/public/page.html' },
setTimeout: () => {},
});
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['track']]);
});
test('logged-in public visitors identify themselves without using the page creator id', () => {
const viewerId = pseudonymizeAnalyticsId('viewer-456', 'secret');
const out = injectMindSpaceAnalytics('<!doctype html><html><head></head><body></body></html>', {
ownerId: 'user-123',
ownerLabel: '张三',
viewerIdentity: buildViewerAnalyticsIdentity(
{ id: 'viewer-456', displayName: '李四', role: 'user', planType: 'free' },
{ idSecret: 'secret', identityMode: 'pseudonymous' },
),
config: {
enabled: true,
websiteId: 'local-website',
idSecret: 'secret',
scriptPath: '/analytics/script.js',
hostPath: '/analytics',
},
});
const inlineScript = out.match(/<script data-memind-analytics="1">([\s\S]*?)<\/script>/)?.[1];
assert.ok(inlineScript);
const calls = [];
vm.runInNewContext(inlineScript, {
window: {
umami: {
identify: (...args) => calls.push(['identify', ...args]),
track: (...args) => calls.push(['track', ...args]),
},
innerHeight: 800,
scrollY: 0,
addEventListener: () => {},
},
document: {
readyState: 'complete',
title: 'Demo',
documentElement: { scrollHeight: 1600 },
addEventListener: () => {},
},
location: { href: 'https://m.tkmind.cn/MindSpace/demo/public/page.html', pathname: '/MindSpace/demo/public/page.html' },
setTimeout: () => {},
});
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [
['identify', pseudonymizeAnalyticsId('user-123', 'secret'), {
username: '张三',
['identify', viewerId, {
username: '李四',
memind_page_url: 'https://m.tkmind.cn/MindSpace/demo/public/page.html',
owner_segment: 'plan:pro',
channel: 'h5',
owner_segment: 'plan:free',
plan_type: 'free',
channel: 'public',
surface: 'generated_page',
plan_type: 'unknown',
identity_mode: 'pseudonymous',
}],
['track'],
]);
assert.notEqual(viewerId, 'user-123');
});
test('does not alter non-full-html or disabled pages', () => {
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,224 @@
#!/usr/bin/env node
/**
* Compensate users overcharged when billing fell back to token estimate
* instead of Goose accumulated_cost (DeepSeek cache-aware upstream cost).
*
* Usage:
* node scripts/compensate-billing-token-estimate-overcharge.mjs
* node scripts/compensate-billing-token-estimate-overcharge.mjs --since=2026-08-04
* node scripts/compensate-billing-token-estimate-overcharge.mjs --since=2026-08-04 --apply
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import mysql from 'mysql2/promise';
import pg from 'pg';
import { resolveGooseSessionPgUrl } from '../goose-session-cost.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
const apply = process.argv.includes('--apply');
const sinceArg = process.argv.find((a) => a.startsWith('--since='));
const sinceRaw = sinceArg ? sinceArg.slice('--since='.length) : '2026-08-04';
const startMs = sinceRaw.includes('T')
? new Date(sinceRaw).getTime()
: new Date(`${sinceRaw}T00:00:00+08:00`).getTime();
const sinceLabel = sinceRaw.includes('T')
? sinceRaw.replace('T', ' ').replace('+08:00', ' CST')
: `${sinceRaw} 00:00 CST`;
const DEDupe_NOTE_PREFIX = `补偿:Token估价超扣(${sinceLabel}起)`;
function loadBillingConfig() {
const marginMultiplier = Number(process.env.H5_MARGIN_MULTIPLIER ?? 1);
return {
useBackendCost: process.env.H5_USE_BACKEND_COST === '1',
usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2),
marginMultiplier: Number.isFinite(marginMultiplier) && marginMultiplier > 0 ? marginMultiplier : 1,
minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1),
};
}
function correctTotalCents(gooseCostUsd, config) {
if (gooseCostUsd == null || gooseCostUsd <= 0) return 0;
return Math.max(
config.minBillCents,
Math.ceil(gooseCostUsd * config.usdCnyRate * 100 * config.marginMultiplier),
);
}
function resolvePgUrl() {
return resolveGooseSessionPgUrl(process.env);
}
async function main() {
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL is not configured');
process.exit(1);
}
const config = loadBillingConfig();
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 4 });
const pgClient = new pg.Client({ connectionString: resolvePgUrl() });
await pgClient.connect();
try {
const [records] = await pool.query(
`SELECT r.user_id, u.username, u.status, r.agent_session_id, r.cost_cents
FROM h5_usage_records r
JOIN h5_users u ON u.id = r.user_id
WHERE r.created_at >= ?
ORDER BY r.created_at ASC`,
[startMs],
);
const bySession = new Map();
for (const row of records) {
const sid = row.agent_session_id;
if (!bySession.has(sid)) {
bySession.set(sid, {
userId: row.user_id,
username: row.username,
status: row.status,
sinceStart: 0,
all: 0,
});
}
bySession.get(sid).sinceStart += Number(row.cost_cents);
}
const sessionIds = [...bySession.keys()];
if (sessionIds.length === 0) {
console.log('No usage records since', sinceLabel);
return;
}
const [allRows] = await pool.query(
`SELECT agent_session_id, SUM(cost_cents) AS total
FROM h5_usage_records
WHERE agent_session_id IN (?)
GROUP BY agent_session_id`,
[sessionIds],
);
for (const row of allRows) {
bySession.get(row.agent_session_id).all = Number(row.total);
}
const gooseRes = await pgClient.query(
`SELECT id, accumulated_cost FROM sessions WHERE id = ANY($1::text[])`,
[sessionIds],
);
const gooseCostBySession = new Map(
gooseRes.rows.map((row) => [row.id, Number(row.accumulated_cost)]),
);
const byUser = new Map();
for (const [sid, session] of bySession) {
const correct = correctTotalCents(gooseCostBySession.get(sid), config);
const overcharge = Math.max(0, session.all - correct);
const refund = Math.min(session.sinceStart, overcharge);
if (refund <= 0) continue;
if (!byUser.has(session.userId)) {
byUser.set(session.userId, {
userId: session.userId,
username: session.username,
status: session.status,
refundCents: 0,
sessions: [],
});
}
const user = byUser.get(session.userId);
user.refundCents += refund;
user.sessions.push({ sid, refund, chargedSinceStart: session.sinceStart, correctTotal: correct });
}
const users = [...byUser.values()].sort((a, b) => b.refundCents - a.refundCents);
const totalRefund = users.reduce((sum, user) => sum + user.refundCents, 0);
console.log('Billing config:', config);
console.log('Since:', sinceLabel, `(${startMs})`);
console.log('Affected users:', users.length);
console.log('Total refund:', `¥${(totalRefund / 100).toFixed(2)}`, `(${totalRefund} cents)`);
console.log('');
for (const user of users) {
console.log(`- ${user.username}: ¥${(user.refundCents / 100).toFixed(2)} (${user.sessions.length} sessions)`);
}
if (!apply) {
console.log('');
console.log('Dry run only. Re-run with --apply to execute.');
return;
}
const now = Date.now();
for (const user of users) {
const note = `${DEDupe_NOTE_PREFIX} ¥${(user.refundCents / 100).toFixed(2)}`;
const [existing] = await pool.query(
`SELECT id FROM h5_billing_ledger
WHERE user_id = ? AND type = 'adjust' AND note = ?
LIMIT 1`,
[user.userId, note],
);
if (existing.length) {
console.log(`SKIP ${user.username}: already compensated (${note})`);
continue;
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.query(
`INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
VALUES (?, ?, 0, ?)
ON DUPLICATE KEY UPDATE
balance_cents = balance_cents + VALUES(balance_cents),
updated_at = VALUES(updated_at)`,
[user.userId, user.refundCents, now],
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'adjust', ?, 0, ?, NULL, ?)`,
[user.userId, user.refundCents, note, now],
);
if (user.status === 'suspended') {
await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [
now,
user.userId,
]);
}
await conn.commit();
console.log(`APPLIED ${user.username}: +¥${(user.refundCents / 100).toFixed(2)}`);
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
} finally {
await pgClient.end();
await pool.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+36 -2
View File
@@ -438,8 +438,42 @@ remount_goosed_after_live_swap() {
local ready=0
if [[ ! -x "${docker_bin}" || ! -f "${compose_file}" ]]; then
echo "goosed remount failed: docker or ${compose_file} is unavailable" >&2
return 1
say "重启 native goosed 以刷新 Portal/MindSpace bind mount"
mkdir -p "${APP_DIR}"
printf '%s\n' "${marker_value}" > "${marker_host}"
local gui="gui/$(id -u)"
local port
for port in $(seq 18006 18014); do
launchctl kickstart -k "${gui}/cn.tkmind.goosed-native-${port}" >/dev/null 2>&1 || true
done
for _ in $(seq 1 60); do
local healthy=1
if [[ "$(cat "${marker_host}" 2>/dev/null || true)" != "${marker_value}" ]]; then
healthy=0
fi
if [[ ! -f "${APP_DIR}/mindspace-sandbox-mcp.mjs" ]]; then
healthy=0
fi
for port in $(seq 18006 18014); do
if [[ "$(curl -skS -m 5 "https://127.0.0.1:${port}/status" 2>/dev/null || true)" != "ok" ]]; then
healthy=0
fi
done
if [[ "${healthy}" -eq 1 ]]; then
ready=1
break
fi
sleep 2
done
rm -f "${marker_host}"
if [[ "${ready}" -ne 1 ]]; then
echo "goosed remount failed: native goosed pool did not become healthy on the new live directory" >&2
return 1
fi
return 0
fi
mkdir -p "${APP_DIR}"
@@ -6,6 +6,7 @@ import {
collectInlineScriptHashes,
} from '../mindspace-public-delivery.mjs';
import {
buildViewerAnalyticsIdentity,
injectMindSpaceAnalytics,
resolveAnalyticsOwnerLabel,
resolveAnalyticsOwnerSegment,
@@ -51,6 +52,7 @@ async function decoratePublicationHtmlAnalytics(
result,
analyticsConfig,
rybbitConfig,
viewer = null,
getAuthPool = () => null,
getUserAuth = () => null,
getMindSpacePages = () => null,
@@ -112,6 +114,7 @@ async function decoratePublicationHtmlAnalytics(
generatedAt: pageDataContext?.generatedAt ?? '',
pageId,
publicationId,
viewerIdentity: buildViewerAnalyticsIdentity(viewer, analyticsConfig),
config: analyticsConfig,
});
decorated = injectMindSpaceRybbit(decorated, {
@@ -215,6 +218,7 @@ export function createPortalPublishedPageDelivery({
result,
analyticsConfig,
rybbitConfig,
viewer: req.currentUser ?? null,
getAuthPool,
getUserAuth,
getMindSpacePages,
@@ -346,6 +350,7 @@ export function createPortalPublishedPageDelivery({
result,
analyticsConfig,
rybbitConfig,
viewer: req.currentUser ?? null,
getAuthPool,
getUserAuth,
getMindSpacePages,
@@ -1,4 +1,5 @@
import {
buildViewerAnalyticsIdentity,
injectMindSpaceAnalytics,
resolveAnalyticsOwnerLabel,
resolveAnalyticsOwnerSegment,
@@ -250,6 +251,10 @@ export function createPortalWorkspacePublicationDelivery({
pageDataContext?.publicationId ??
pageDataContext?.publication_id ??
'',
viewerIdentity: buildViewerAnalyticsIdentity(
req.currentUser ?? null,
analyticsConfig,
),
config: analyticsConfig,
});
html = injectMindSpaceRybbit(html, {
+5
View File
@@ -82,6 +82,11 @@ export function loadWechatMpConfig(env = process.env) {
maxImageBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_IMAGE_BYTES ?? 10 * 1024 * 1024)),
maxFileBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_FILE_BYTES ?? 30 * 1024 * 1024)),
acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0',
wechatVoiceRecoApiEnabled: env.H5_WECHAT_MP_VOICE_RECO_API !== '0',
wechatVoiceRecoLang: env.H5_WECHAT_MP_VOICE_RECO_LANG?.trim() || 'zh_CN',
wechatVoiceRecoApiBase:
env.H5_WECHAT_MP_VOICE_RECO_API_BASE?.trim()?.replace(/\/$/, '')
|| 'https://api.weixin.qq.com',
acceptImage: env.H5_WECHAT_MP_ACCEPT_IMAGE !== '0',
acceptFile: env.H5_WECHAT_MP_ACCEPT_FILE !== '0',
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
+33 -8
View File
@@ -12,6 +12,10 @@ import {
persistWechatImage,
uploadWechatGeneratedImage,
} from './wechat-media.mjs';
import {
buildWechatVoiceRecoVoiceId,
transcribeWechatVoiceViaRecoApi,
} from './wechat-voice-reco.mjs';
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
import { buildAckText } from './wechat/ack/ack-provider.mjs';
import {
@@ -1624,6 +1628,9 @@ export function createWechatMpService({
requireFreshPageThumbnail,
repairFreshPageThumbnail,
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
wechatVoiceRecoApiEnabled: config.wechatVoiceRecoApiEnabled !== false,
wechatVoiceRecoLang: config.wechatVoiceRecoLang || 'zh_CN',
wechatVoiceRecoApiBase: config.wechatVoiceRecoApiBase || 'https://api.weixin.qq.com',
};
const deferredStore = createWechatCustomerServiceDeferredStore({ mysqlPool, logger });
@@ -2091,12 +2098,30 @@ export function createWechatMpService({
}
};
const transcribeWechatVoiceMedia = async (mediaId, format) => {
const transcribeWechatVoiceMedia = async (mediaId, format, { msgId } = {}) => {
if (!mediaId) return '';
const accessToken = await getStableAccessToken();
const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch });
if (!downloaded.buffer?.length) return '';
if (config.wechatVoiceRecoApiEnabled) {
try {
const recoText = await transcribeWechatVoiceViaRecoApi({
accessToken,
voiceBuffer: downloaded.buffer,
format,
voiceId: buildWechatVoiceRecoVoiceId({ msgId, mediaId }),
lang: config.wechatVoiceRecoLang,
apiBase: config.wechatVoiceRecoApiBase,
wechatFetch,
convertToMp3: config.wechatVoiceRecoConvertToMp3,
});
if (recoText) return recoText;
} catch (err) {
logger.warn?.('WeChat MP voice reco API failed:', err);
}
}
const extension = String(format ?? '').trim().toLowerCase() || 'amr';
const form = new FormData();
form.append(
@@ -2584,10 +2609,7 @@ export function createWechatMpService({
artifacts,
forcePageData,
}) => {
const enabledForUser =
config.pageDataAiderReviewEnabled &&
isWechatMediaGrayUser(user, config.pageDataAiderReviewUsers);
if (!enabledForUser) return { action: 'skip', reason: 'disabled' };
if (!config.pageDataAiderReviewEnabled) return { action: 'skip', reason: 'disabled' };
if (typeof pageDataDeliveryReviewer?.reviewIfNeeded !== 'function') {
const error = new Error('微信 Page Data 强制 Aider 审核服务不可用');
error.code = 'PAGE_DATA_REVIEW_UNAVAILABLE';
@@ -2617,8 +2639,7 @@ export function createWechatMpService({
const runIntentMessage = async ({ inbound, intent, user }) => {
const wechatIntent = await resolveWechatIntent(intent, { openid: inbound.fromUserName });
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
const reliabilityEnabled = isWechatMediaGrayUser(user, config.reliabilityGrayUsers);
const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0;
const agentReplyTimeoutMs = config.agentReplyTimeoutMs;
const resetCandidate =
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
const isPageDataRequest = isWechatPageDataTask(resetCandidate);
@@ -3581,7 +3602,11 @@ export function createWechatMpService({
if (intent.msgType === 'voice' && !intent.agentText.trim() && intent.media?.mediaId) {
try {
const fallbackText = await transcribeWechatVoiceMedia(intent.media.mediaId, intent.media.format);
const fallbackText = await transcribeWechatVoiceMedia(
intent.media.mediaId,
intent.media.format,
{ msgId: intent.msgId },
);
if (fallbackText) {
intent.agentText = fallbackText;
intent.displayText = `语音:${fallbackText}`;
+140
View File
@@ -4444,6 +4444,7 @@ test('wechat mp service falls back to ASR when voice recognition is empty', asyn
unsupportedText: 'unsupported',
unboundTextPrefix: '请先绑定',
asrTarget: 'https://asr.example.com',
wechatVoiceRecoApiEnabled: false,
},
userAuth: {
async findWechatUserByOpenid() {
@@ -4548,6 +4549,145 @@ test('wechat mp service falls back to ASR when voice recognition is empty', asyn
}
});
test('wechat mp service uses WeChat voice reco API before legacy ASR fallback', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
let replyCalled = false;
let asrCalled = false;
const prompts = [];
const service = createWechatMpService({
config: {
enabled: true,
appId: 'wx123',
appSecret: 'secret',
token,
publicBaseUrl: 'https://example.com',
bindPath: '/auth/wechat/authorize?intent=login',
ackText: 'ack',
unsupportedText: 'unsupported',
unboundTextPrefix: '请先绑定',
asrTarget: 'https://asr.example.com',
wechatVoiceRecoApiEnabled: true,
wechatVoiceRecoConvertToMp3: async () => Buffer.from('fake-mp3'),
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: 'user-1', status: 'active', nickname: '唐' };
},
async getWechatAgentRoute() {
return { agentSessionId: 'session-1' };
},
async clearWechatAgentRoute() {},
async canUseChat() {
return { ok: true };
},
async resolveWorkingDir() {
return '/tmp/user-1';
},
async getAgentSessionPolicy() {
return { enableContextMemory: false, extensionOverrides: [], unrestricted: true };
},
async getUserPublishLayout() {
return { displayName: '唐', username: 'wx_ul610et8', slug: 'wx_ul610et8', constraints: null };
},
async registerAgentSession() {},
async upsertWechatAgentRoute() {},
async billSessionUsage() {},
async insertWechatMpMessageDetail() {},
},
sessionApiFetch: async (sessionId, pathname, init = {}) => {
assert.equal(sessionId, 'session-1');
if (pathname === '/sessions/session-1/events') {
return new Response(
[
'data: {"type":"Message","request_id":"req-voice-reco","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"收到。"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-voice-reco","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-1/reply') {
replyCalled = true;
const body = JSON.parse(init.body);
prompts.push(body.user_message.content[0].text);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
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: ${pathname}`);
},
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('fake-amr-audio'), {
status: 200,
headers: { 'Content-Type': 'audio/amr' },
});
}
if (String(url).includes('/addvoicetorecofortext')) {
assert.equal(init.method, 'POST');
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/queryrecoresultfortext')) {
return new Response(
JSON.stringify({ errcode: 0, errmsg: 'ok', result: '帮我看看仙居最近的天气情况' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
if (String(url).includes('https://asr.example.com/asr/oneshot')) {
asrCalled = true;
return new Response(JSON.stringify({ code: 200, data: { text: 'legacy-asr' } }), {
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-voice-reco';
try {
const result = await service.handleInboundMessage(
inboundXml({
msgType: 'voice',
content: '',
extraFields: { MediaId: 'media-1', Format: 'amr', MsgId: '7670473258902224896' },
}),
{
timestamp,
nonce,
signature: signatureFor(token, timestamp, nonce),
},
);
assert.equal(result.status, 200);
assert.doesNotMatch(result.body, /未识别到语音文字/);
await result.task;
assert.equal(replyCalled, true);
assert.equal(asrCalled, false);
assert.match(prompts[0], /帮我看看仙居最近的天气情况/);
} finally {
crypto.randomUUID = originalRandomUuid;
}
});
test('wechat mp wildcard media access persists image and routes image url into agent prompt', async () => {
const token = 'token';
const timestamp = '1710000000';
+209
View File
@@ -0,0 +1,209 @@
import crypto from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
export const DEFAULT_WECHAT_VOICE_RECO_API_BASE = 'https://api.weixin.qq.com';
export const DEFAULT_WECHAT_VOICE_RECO_LANG = 'zh_CN';
export const WECHAT_VOICE_RECO_MAX_MP3_BYTES = 1024 * 1024;
export const WECHAT_VOICE_RECO_DEFAULT_POLL_INTERVAL_MS = 300;
export const WECHAT_VOICE_RECO_DEFAULT_POLL_TIMEOUT_MS = 8000;
export function buildWechatVoiceRecoVoiceId({ msgId = '', mediaId = '' } = {}) {
const raw = String(msgId || mediaId || '').trim();
if (raw) return raw.slice(0, 64);
return crypto.randomUUID().replace(/-/g, '');
}
function resolveFfmpegPath(explicitPath = '') {
const configured = String(explicitPath ?? process.env.H5_FFMPEG_PATH ?? '').trim();
return configured || 'ffmpeg';
}
function runFfmpeg(ffmpegPath, args) {
return new Promise((resolve, reject) => {
const proc = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
proc.stderr.on('data', (chunk) => {
stderr += chunk;
});
proc.on('error', reject);
proc.on('close', (code) => {
if (code === 0) {
resolve(undefined);
return;
}
reject(new Error(stderr.trim() || `ffmpeg exit ${code}`));
});
});
}
export async function convertWechatVoiceToMp3(
buffer,
{
format = 'amr',
ffmpegPath = resolveFfmpegPath(),
} = {},
) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-wechat-voice-'));
const extension = String(format ?? '').trim().toLowerCase() || 'amr';
const inputPath = path.join(tmpDir, `input.${extension}`);
const outputPath = path.join(tmpDir, 'output.mp3');
try {
await fs.writeFile(inputPath, buffer);
await runFfmpeg(ffmpegPath, [
'-y',
'-i',
inputPath,
'-ar',
'16000',
'-ac',
'1',
'-f',
'mp3',
outputPath,
]);
const mp3Buffer = await fs.readFile(outputPath);
if (!mp3Buffer.length || mp3Buffer.length > WECHAT_VOICE_RECO_MAX_MP3_BYTES) {
return null;
}
return mp3Buffer;
} catch {
return null;
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
async function readWechatApiPayload(response) {
const text = await response.text();
if (!text) return { payload: null, text: '' };
try {
return { payload: JSON.parse(text), text };
} catch {
return { payload: null, text };
}
}
function assertWechatApiOk(payload, fallbackText, httpStatus) {
const errcode = Number(payload?.errcode ?? 0);
if (errcode !== 0) {
throw new Error(String(payload?.errmsg ?? fallbackText ?? `WeChat API error ${errcode}`));
}
if (!httpStatus || httpStatus < 200 || httpStatus >= 300) {
throw new Error(fallbackText || `WeChat HTTP ${httpStatus}`);
}
}
export function isRetryableWechatVoiceRecoQueryError(payload) {
const errcode = Number(payload?.errcode ?? 0);
return errcode === -1 || errcode === 87009;
}
async function queryWechatVoiceRecoResultOnce({
apiBase = DEFAULT_WECHAT_VOICE_RECO_API_BASE,
accessToken,
voiceId,
lang = DEFAULT_WECHAT_VOICE_RECO_LANG,
wechatFetch,
}) {
if (!accessToken) throw new Error('缺少微信 access_token');
if (!voiceId) throw new Error('缺少 voice_id');
const url = new URL('/cgi-bin/media/voice/queryrecoresultfortext', apiBase);
url.searchParams.set('access_token', accessToken);
url.searchParams.set('voice_id', voiceId);
url.searchParams.set('lang', lang);
const response = await wechatFetch(url.toString(), { method: 'POST' });
const { payload, text } = await readWechatApiPayload(response);
if (isRetryableWechatVoiceRecoQueryError(payload)) {
return '';
}
assertWechatApiOk(payload, text, response.status);
return String(payload?.result ?? '').trim();
}
export async function uploadWechatVoiceForReco({
apiBase = DEFAULT_WECHAT_VOICE_RECO_API_BASE,
accessToken,
voiceId,
mp3Buffer,
lang = DEFAULT_WECHAT_VOICE_RECO_LANG,
wechatFetch,
}) {
if (!accessToken) throw new Error('缺少微信 access_token');
if (!voiceId) throw new Error('缺少 voice_id');
if (!Buffer.isBuffer(mp3Buffer) || !mp3Buffer.length) {
throw new Error('语音内容为空');
}
const url = new URL('/cgi-bin/media/voice/addvoicetorecofortext', apiBase);
url.searchParams.set('access_token', accessToken);
url.searchParams.set('format', 'mp3');
url.searchParams.set('voice_id', voiceId);
url.searchParams.set('lang', lang);
const form = new FormData();
form.append('media', new Blob([mp3Buffer], { type: 'audio/mpeg' }), 'voice.mp3');
const response = await wechatFetch(url.toString(), { method: 'POST', body: form });
const { payload, text } = await readWechatApiPayload(response);
assertWechatApiOk(payload, text, response.status);
return payload;
}
export async function queryWechatVoiceRecoResult(options) {
return queryWechatVoiceRecoResultOnce(options);
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export async function transcribeWechatVoiceViaRecoApi({
accessToken,
voiceBuffer,
format = 'amr',
voiceId,
lang = DEFAULT_WECHAT_VOICE_RECO_LANG,
apiBase = DEFAULT_WECHAT_VOICE_RECO_API_BASE,
wechatFetch,
pollIntervalMs = WECHAT_VOICE_RECO_DEFAULT_POLL_INTERVAL_MS,
pollTimeoutMs = WECHAT_VOICE_RECO_DEFAULT_POLL_TIMEOUT_MS,
convertToMp3 = convertWechatVoiceToMp3,
now = Date.now,
}) {
const mp3Buffer = await convertToMp3(voiceBuffer, { format });
if (!mp3Buffer?.length) return '';
await uploadWechatVoiceForReco({
apiBase,
accessToken,
voiceId,
mp3Buffer,
lang,
wechatFetch,
});
const deadline = now() + Math.max(0, pollTimeoutMs);
while (now() < deadline) {
const result = await queryWechatVoiceRecoResultOnce({
apiBase,
accessToken,
voiceId,
lang,
wechatFetch,
});
if (result) return result;
await sleep(Math.max(1, pollIntervalMs));
}
return '';
}
+138
View File
@@ -0,0 +1,138 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildWechatVoiceRecoVoiceId,
queryWechatVoiceRecoResult,
transcribeWechatVoiceViaRecoApi,
uploadWechatVoiceForReco,
} from './wechat-voice-reco.mjs';
test('buildWechatVoiceRecoVoiceId prefers msgId', () => {
assert.equal(
buildWechatVoiceRecoVoiceId({ msgId: '7670473258902224896', mediaId: 'media-1' }),
'7670473258902224896',
);
});
test('transcribeWechatVoiceViaRecoApi uploads mp3 and polls reco result', async () => {
const calls = [];
let queryCount = 0;
const mp3Buffer = Buffer.from('fake-mp3');
const text = await transcribeWechatVoiceViaRecoApi({
accessToken: 'token-1',
voiceBuffer: Buffer.from('fake-amr'),
format: 'amr',
voiceId: 'voice-1',
wechatFetch: async (url, init = {}) => {
calls.push([String(url), init.method ?? 'GET']);
if (String(url).includes('/addvoicetorecofortext')) {
assert.equal(init.method, 'POST');
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/queryrecoresultfortext')) {
queryCount += 1;
const result = queryCount >= 2 ? '帮我看看最近的天气' : '';
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok', result }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected url: ${url}`);
},
convertToMp3: async () => mp3Buffer,
pollIntervalMs: 1,
pollTimeoutMs: 50,
now: () => Date.now(),
});
assert.equal(text, '帮我看看最近的天气');
assert.equal(calls.some(([url]) => url.includes('/addvoicetorecofortext')), true);
assert.ok(queryCount >= 2);
});
test('transcribeWechatVoiceViaRecoApi returns empty when mp3 conversion fails', async () => {
let fetchCalled = false;
const text = await transcribeWechatVoiceViaRecoApi({
accessToken: 'token-1',
voiceBuffer: Buffer.from('fake-amr'),
voiceId: 'voice-1',
wechatFetch: async () => {
fetchCalled = true;
return new Response('{}', { status: 200 });
},
convertToMp3: async () => null,
});
assert.equal(text, '');
assert.equal(fetchCalled, false);
});
test('uploadWechatVoiceForReco throws on WeChat business error', async () => {
await assert.rejects(
() =>
uploadWechatVoiceForReco({
accessToken: 'token-1',
voiceId: 'voice-1',
mp3Buffer: Buffer.from('fake-mp3'),
wechatFetch: async () =>
new Response(JSON.stringify({ errcode: 40010, errmsg: 'invalid voice size' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
}),
/invalid voice size/,
);
});
test('queryWechatVoiceRecoResult retries not-ready as empty result during polling', async () => {
let queryCount = 0;
const text = await transcribeWechatVoiceViaRecoApi({
accessToken: 'token-1',
voiceBuffer: Buffer.from('fake-amr'),
voiceId: 'voice-1',
wechatFetch: async (url) => {
if (String(url).includes('/addvoicetorecofortext')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/queryrecoresultfortext')) {
queryCount += 1;
if (queryCount === 1) {
return new Response(JSON.stringify({ errcode: -1, errmsg: 'system error' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok', result: '识别完成' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected url: ${url}`);
},
convertToMp3: async () => Buffer.from('fake-mp3'),
pollIntervalMs: 1,
pollTimeoutMs: 200,
});
assert.equal(text, '识别完成');
assert.ok(queryCount >= 2);
});
test('queryWechatVoiceRecoResult returns trimmed result', async () => {
const result = await queryWechatVoiceRecoResult({
accessToken: 'token-1',
voiceId: 'voice-1',
wechatFetch: async () =>
new Response(JSON.stringify({ errcode: 0, errmsg: 'ok', result: ' 你好 ' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
});
assert.equal(result, '你好');
});