Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49ff7c9bad | |||
| 7c18c998bd | |||
| 07a88eb194 | |||
| 8e4fc09cb5 | |||
| 391ba0b705 | |||
| c44017eccd | |||
| b300cb5e04 | |||
| 26846a0274 | |||
| d9db72fd90 | |||
| f488d49d51 | |||
| fda90d8579 | |||
| 8c1ae7550d | |||
| e2014e05e6 | |||
| 9e50681d96 | |||
| eb8eedb07f | |||
| 44121df83c | |||
| e7f0627dc9 | |||
| 1e7004dffe | |||
| 089a44fb11 | |||
| 6a48e0ad91 | |||
| c2189ee30d | |||
| 2cc98b9392 | |||
| 2f4dd39181 | |||
| 4e66c43350 | |||
| 85872e1e84 | |||
| 93aa7c1cfa | |||
| 49c2671845 | |||
| 5cebd1121e | |||
| 492bf6fbb4 | |||
| 1d1af888e9 | |||
| 4ebe7c76aa | |||
| 147734870b |
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: MindSpace 生成页埋点必须上报创建者 username,且不得用 owner_id identify 匿名访客
|
||||||
|
globs: mindspace-analytics.mjs,mindspace-rybbit.mjs,mindspace-analytics.test.mjs,mindspace-rybbit.test.mjs,server/portal-session-routes.mjs,server/portal-integration-services-bootstrap.mjs
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# MindSpace Analytics — 创建用户昵称与 Public 身份
|
||||||
|
|
||||||
|
Umami「创建用户」依赖 `event_data.username` / session `username`。路径里的 MindSpace UUID **不是**昵称。
|
||||||
|
|
||||||
|
## 必须
|
||||||
|
|
||||||
|
- 服务端 `page_generated`(`sendMindSpaceAnalyticsEvent` / `sendMindSpaceRybbitEvent`)的事件属性 **必须**包含 `username`(创建者展示名,来自 `resolveAnalyticsOwnerLabel`)
|
||||||
|
- 可同时保留 `owner_label` 作兼容,值与 `username` 相同
|
||||||
|
- Public HTML 内嵌脚本的 `metadata` **必须**继续带 `username`(给 scroll/click 等客户端事件)
|
||||||
|
|
||||||
|
## 禁止(避免破坏访客统计)
|
||||||
|
|
||||||
|
- Public **匿名**访问路径 **禁止** `umami.identify(owner_id, …)` / 把创建者 ID 当作访客 `id`
|
||||||
|
- 仅已登录 viewer 可 `identifyViewer()`;不得为了补创建用户昵称而恢复「全员 identify 创建者」
|
||||||
|
|
||||||
|
## 改完必跑
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test mindspace-analytics.test.mjs mindspace-rybbit.test.mjs
|
||||||
|
```
|
||||||
@@ -280,6 +280,9 @@ H5_ACCESS_PASSWORD=change-me
|
|||||||
# H5_COST_ESTIMATE_FROM_TOKENS=1
|
# H5_COST_ESTIMATE_FROM_TOKENS=1
|
||||||
# H5_COST_ESTIMATE_INPUT_USD_PER_1M=0.27
|
# H5_COST_ESTIMATE_INPUT_USD_PER_1M=0.27
|
||||||
# H5_COST_ESTIMATE_OUTPUT_USD_PER_1M=1.1
|
# H5_COST_ESTIMATE_OUTPUT_USD_PER_1M=1.1
|
||||||
|
# 上述计费公式也可在 memind_adm「计费中心 → 计量公式」后台覆盖(写入 h5_billing_admin_config)。
|
||||||
|
# 设为 env 时强制只读环境变量,禁止后台改写:
|
||||||
|
# H5_BILLING_CONFIG_SOURCE=env
|
||||||
|
|
||||||
# 用户自助充值(微信支付)
|
# 用户自助充值(微信支付)
|
||||||
# H5_RECHARGE_TIERS_CENTS=500,1000,3000,5000,10000,20000
|
# H5_RECHARGE_TIERS_CENTS=500,1000,3000,5000,10000,20000
|
||||||
@@ -338,6 +341,11 @@ H5_ACCESS_PASSWORD=change-me
|
|||||||
# H5_ASR_TARGET=https://asr.tkmind.cn
|
# H5_ASR_TARGET=https://asr.tkmind.cn
|
||||||
# H5_ASR_MAX_BYTES=5242880
|
# H5_ASR_MAX_BYTES=5242880
|
||||||
# H5_ASR_TIMEOUT_MS=45000
|
# 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_ 前缀)
|
# 前端构建时注入(Vite,需 VITE_ 前缀)
|
||||||
# 工作目录:新建会话时使用,必填
|
# 工作目录:新建会话时使用,必填
|
||||||
|
|||||||
+20
-11
@@ -33,21 +33,30 @@ jobs:
|
|||||||
|
|
||||||
- name: Install system test dependencies
|
- name: Install system test dependencies
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
apt-get install --yes --no-install-recommends sqlite3
|
apt-get update
|
||||||
rm -rf /var/lib/apt/lists/*
|
apt-get install --yes --no-install-recommends sqlite3
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
elif command -v brew >/dev/null 2>&1; then
|
||||||
|
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
brew install sqlite
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
command -v sqlite3
|
||||||
|
|
||||||
- name: Install locked dependencies
|
- name: Install locked dependencies
|
||||||
run: |
|
run: |
|
||||||
npm ci --include=optional
|
npm ci --include=optional
|
||||||
SHARP_ARM64_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/@img/sharp-linux-arm64'].version")"
|
if [[ "$(uname -s)" == "Linux" ]]; then
|
||||||
LIBVIPS_ARM64_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/@img/sharp-libvips-linux-arm64'].version")"
|
SHARP_ARM64_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/@img/sharp-linux-arm64'].version")"
|
||||||
if [[ ! -d node_modules/@img/sharp-linux-arm64 || ! -d node_modules/@img/sharp-libvips-linux-arm64 ]]; then
|
LIBVIPS_ARM64_VERSION="$(node -p "require('./package-lock.json').packages['node_modules/@img/sharp-libvips-linux-arm64'].version")"
|
||||||
npm install --no-save --package-lock=false \
|
if [[ ! -d node_modules/@img/sharp-linux-arm64 || ! -d node_modules/@img/sharp-libvips-linux-arm64 ]]; then
|
||||||
"@img/sharp-linux-arm64@${SHARP_ARM64_VERSION}" \
|
npm install --no-save --package-lock=false \
|
||||||
"@img/sharp-libvips-linux-arm64@${LIBVIPS_ARM64_VERSION}"
|
"@img/sharp-linux-arm64@${SHARP_ARM64_VERSION}" \
|
||||||
else
|
"@img/sharp-libvips-linux-arm64@${LIBVIPS_ARM64_VERSION}"
|
||||||
echo "Locked Sharp ARM64 optional packages are already installed"
|
else
|
||||||
|
echo "Locked Sharp ARM64 optional packages are already installed"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
node -e "import('sharp').then((sharp) => sharp.default({ create: { width: 1, height: 1, channels: 4, background: '#000' } }).png().toBuffer())"
|
node -e "import('sharp').then((sharp) => sharp.default({ create: { width: 1, height: 1, channels: 4, background: '#000' } }).png().toBuffer())"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { loadBillingConfig } from './billing.mjs';
|
||||||
|
|
||||||
|
const CONFIG_TABLE = 'h5_billing_admin_config';
|
||||||
|
const CONFIG_SCOPE = 'global';
|
||||||
|
const SOURCE_ADMIN_DB = 'admin-db';
|
||||||
|
const SOURCE_ENV = 'env';
|
||||||
|
const SOURCE_ENV_OVERRIDE = 'env-override';
|
||||||
|
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;
|
||||||
|
|
||||||
|
function normalizeBoolean(value, fallback = false) {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||||
|
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveNumber(value, fallback) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num) || num <= 0) return fallback;
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonNegativeNumber(value, fallback) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num) || num < 0) return fallback;
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfigShape(env = process.env) {
|
||||||
|
const billing = loadBillingConfig(env);
|
||||||
|
const costEstimateFromTokens = env.H5_COST_ESTIMATE_FROM_TOKENS !== '0';
|
||||||
|
const inputUsdPer1M = Number(env.H5_COST_ESTIMATE_INPUT_USD_PER_1M ?? DEFAULT_ESTIMATE_INPUT_USD_PER_1M);
|
||||||
|
const outputUsdPer1M = Number(env.H5_COST_ESTIMATE_OUTPUT_USD_PER_1M ?? DEFAULT_ESTIMATE_OUTPUT_USD_PER_1M);
|
||||||
|
return {
|
||||||
|
useBackendCost: billing.useBackendCost,
|
||||||
|
usdCnyRate: billing.usdCnyRate,
|
||||||
|
marginMultiplier: billing.marginMultiplier,
|
||||||
|
inputCentsPer1k: billing.inputCentsPer1k,
|
||||||
|
outputCentsPer1k: billing.outputCentsPer1k,
|
||||||
|
minBillCents: billing.minBillCents,
|
||||||
|
costEstimateFromTokens,
|
||||||
|
costEstimateInputUsdPer1M:
|
||||||
|
Number.isFinite(inputUsdPer1M) && inputUsdPer1M >= 0
|
||||||
|
? inputUsdPer1M
|
||||||
|
: DEFAULT_ESTIMATE_INPUT_USD_PER_1M,
|
||||||
|
costEstimateOutputUsdPer1M:
|
||||||
|
Number.isFinite(outputUsdPer1M) && outputUsdPer1M >= 0
|
||||||
|
? outputUsdPer1M
|
||||||
|
: DEFAULT_ESTIMATE_OUTPUT_USD_PER_1M,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneConfig(config = null, env = process.env) {
|
||||||
|
return structuredClone?.(config ?? defaultConfigShape(env))
|
||||||
|
?? JSON.parse(JSON.stringify(config ?? defaultConfigShape(env)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonLike(value, fallback) {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') return value;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envLocked(env = process.env) {
|
||||||
|
return String(env.H5_BILLING_CONFIG_SOURCE ?? '').trim().toLowerCase() === 'env';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePatch(currentConfig, patch = {}, env = process.env) {
|
||||||
|
const base = defaultConfigShape(env);
|
||||||
|
const next = cloneConfig({ ...base, ...currentConfig }, env);
|
||||||
|
const raw = patch?.config && typeof patch.config === 'object' ? patch.config : patch;
|
||||||
|
|
||||||
|
if ('useBackendCost' in raw) next.useBackendCost = normalizeBoolean(raw.useBackendCost, next.useBackendCost);
|
||||||
|
if ('usdCnyRate' in raw) next.usdCnyRate = normalizePositiveNumber(raw.usdCnyRate, next.usdCnyRate);
|
||||||
|
if ('marginMultiplier' in raw) {
|
||||||
|
next.marginMultiplier = normalizePositiveNumber(raw.marginMultiplier, next.marginMultiplier);
|
||||||
|
}
|
||||||
|
if ('inputCentsPer1k' in raw) {
|
||||||
|
next.inputCentsPer1k = normalizeNonNegativeNumber(raw.inputCentsPer1k, next.inputCentsPer1k);
|
||||||
|
}
|
||||||
|
if ('outputCentsPer1k' in raw) {
|
||||||
|
next.outputCentsPer1k = normalizeNonNegativeNumber(raw.outputCentsPer1k, next.outputCentsPer1k);
|
||||||
|
}
|
||||||
|
if ('minBillCents' in raw) {
|
||||||
|
next.minBillCents = normalizePositiveNumber(raw.minBillCents, next.minBillCents);
|
||||||
|
}
|
||||||
|
if ('costEstimateFromTokens' in raw) {
|
||||||
|
next.costEstimateFromTokens = normalizeBoolean(raw.costEstimateFromTokens, next.costEstimateFromTokens);
|
||||||
|
}
|
||||||
|
if ('costEstimateInputUsdPer1M' in raw) {
|
||||||
|
next.costEstimateInputUsdPer1M = normalizeNonNegativeNumber(
|
||||||
|
raw.costEstimateInputUsdPer1M,
|
||||||
|
next.costEstimateInputUsdPer1M,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ('costEstimateOutputUsdPer1M' in raw) {
|
||||||
|
next.costEstimateOutputUsdPer1M = normalizeNonNegativeNumber(
|
||||||
|
raw.costEstimateOutputUsdPer1M,
|
||||||
|
next.costEstimateOutputUsdPer1M,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toComputeBillingConfig(config) {
|
||||||
|
return {
|
||||||
|
useBackendCost: Boolean(config?.useBackendCost),
|
||||||
|
usdCnyRate: Number(config?.usdCnyRate ?? 7.2),
|
||||||
|
marginMultiplier: Number(config?.marginMultiplier ?? 1),
|
||||||
|
inputCentsPer1k: Number(config?.inputCentsPer1k ?? 2),
|
||||||
|
outputCentsPer1k: Number(config?.outputCentsPer1k ?? 6),
|
||||||
|
minBillCents: Number(config?.minBillCents ?? 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCostEstimateConfig(config) {
|
||||||
|
const useBackendCost = Boolean(config?.useBackendCost);
|
||||||
|
const enabled = useBackendCost && config?.costEstimateFromTokens !== false;
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
inputUsdPer1M: Number(config?.costEstimateInputUsdPer1M ?? 0.27),
|
||||||
|
outputUsdPer1M: Number(config?.costEstimateOutputUsdPer1M ?? 1.1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function envLooksCustomized(env = process.env) {
|
||||||
|
return [
|
||||||
|
'H5_USE_BACKEND_COST',
|
||||||
|
'H5_USD_CNY_RATE',
|
||||||
|
'H5_MARGIN_MULTIPLIER',
|
||||||
|
'H5_BILL_INPUT_CENTS_PER_1K',
|
||||||
|
'H5_BILL_OUTPUT_CENTS_PER_1K',
|
||||||
|
'H5_MIN_BILL_CENTS',
|
||||||
|
'H5_COST_ESTIMATE_FROM_TOKENS',
|
||||||
|
'H5_COST_ESTIMATE_INPUT_USD_PER_1M',
|
||||||
|
'H5_COST_ESTIMATE_OUTPUT_USD_PER_1M',
|
||||||
|
].some((key) => String(env[key] ?? '').trim() !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureConfigTable(pool) {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
|
||||||
|
config_scope VARCHAR(32) PRIMARY KEY,
|
||||||
|
config_json JSON NOT NULL,
|
||||||
|
updated_by CHAR(36) NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStoredState(pool) {
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
config: mergePatch(defaultConfigShape(), parseJsonLike(row.config_json, {})),
|
||||||
|
updatedAt: Number(row.updated_at ?? 0) || null,
|
||||||
|
updatedBy: row.updated_by ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBillingAdminConfigService(pool, { env = process.env, cacheTtlMs = CACHE_TTL_MS } = {}) {
|
||||||
|
let cache = null;
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEffectiveConfig({ bypassCache = false } = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!bypassCache && cache && now - cache.loadedAt < cacheTtlMs) {
|
||||||
|
return cache.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envLocked(env)) {
|
||||||
|
const state = {
|
||||||
|
config: defaultConfigShape(env),
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null,
|
||||||
|
source: SOURCE_ENV_OVERRIDE,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
cache = { loadedAt: now, state };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async ensureSchema() {
|
||||||
|
await ensureConfigTable(pool);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCache,
|
||||||
|
|
||||||
|
async getAdminConfig() {
|
||||||
|
const state = await loadEffectiveConfig({ bypassCache: true });
|
||||||
|
return {
|
||||||
|
config: state.config,
|
||||||
|
updatedAt: state.updatedAt,
|
||||||
|
updatedBy: state.updatedBy,
|
||||||
|
source: state.source,
|
||||||
|
envOverrideActive: state.source === SOURCE_ENV_OVERRIDE,
|
||||||
|
formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数(成本模式);无上游成本时回退 Token 单价',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAdminConfig(patch = {}, { updatedBy = null } = {}) {
|
||||||
|
if (envLocked(env)) {
|
||||||
|
throw Object.assign(new Error('H5_BILLING_CONFIG_SOURCE=env 时不允许通过后台修改'), {
|
||||||
|
code: 'BILLING_CONFIG_ENV_LOCKED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const stored = await loadStoredState(pool);
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
return this.getAdminConfig();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRuntimeState() {
|
||||||
|
const state = await loadEffectiveConfig();
|
||||||
|
return {
|
||||||
|
source: state.source,
|
||||||
|
updatedAt: state.updatedAt,
|
||||||
|
updatedBy: state.updatedBy,
|
||||||
|
config: state.config,
|
||||||
|
compute: toComputeBillingConfig(state.config),
|
||||||
|
estimate: toCostEstimateConfig(state.config),
|
||||||
|
envOverrideActive: state.source === SOURCE_ENV_OVERRIDE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEffectiveBillingConfig() {
|
||||||
|
const state = await loadEffectiveConfig();
|
||||||
|
return toComputeBillingConfig(state.config);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getEffectiveCostEstimateConfig() {
|
||||||
|
const state = await loadEffectiveConfig();
|
||||||
|
return toCostEstimateConfig(state.config);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const billingAdminConfigInternals = {
|
||||||
|
CONFIG_SCOPE,
|
||||||
|
CONFIG_TABLE,
|
||||||
|
defaultConfigShape,
|
||||||
|
mergePatch,
|
||||||
|
toComputeBillingConfig,
|
||||||
|
toCostEstimateConfig,
|
||||||
|
envLocked,
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
billingAdminConfigInternals,
|
||||||
|
createBillingAdminConfigService,
|
||||||
|
toComputeBillingConfig,
|
||||||
|
toCostEstimateConfig,
|
||||||
|
} from './billing-admin-config.mjs';
|
||||||
|
|
||||||
|
function createMemoryPool(initialRows = []) {
|
||||||
|
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/i.test(sql)) {
|
||||||
|
const scope = params[0];
|
||||||
|
const row = rows.get(scope);
|
||||||
|
return [row ? [row] : [], undefined];
|
||||||
|
}
|
||||||
|
if (/INSERT INTO/i.test(sql)) {
|
||||||
|
const [scope, configJson, updatedBy, updatedAt] = params;
|
||||||
|
rows.set(scope, {
|
||||||
|
config_scope: scope,
|
||||||
|
config_json: configJson,
|
||||||
|
updated_by: updatedBy,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
});
|
||||||
|
return [{ affectedRows: 1 }, undefined];
|
||||||
|
}
|
||||||
|
return [{}, undefined];
|
||||||
|
},
|
||||||
|
_rows: rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mergePatch validates margin and FX', () => {
|
||||||
|
const next = billingAdminConfigInternals.mergePatch(
|
||||||
|
billingAdminConfigInternals.defaultConfigShape({}),
|
||||||
|
{
|
||||||
|
marginMultiplier: 1.5,
|
||||||
|
usdCnyRate: 7.1,
|
||||||
|
useBackendCost: true,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
assert.equal(next.marginMultiplier, 1.5);
|
||||||
|
assert.equal(next.usdCnyRate, 7.1);
|
||||||
|
assert.equal(next.useBackendCost, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toComputeBillingConfig maps admin shape', () => {
|
||||||
|
const compute = toComputeBillingConfig({
|
||||||
|
useBackendCost: true,
|
||||||
|
usdCnyRate: 7.2,
|
||||||
|
marginMultiplier: 1.2,
|
||||||
|
inputCentsPer1k: 2,
|
||||||
|
outputCentsPer1k: 6,
|
||||||
|
minBillCents: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(compute, {
|
||||||
|
useBackendCost: true,
|
||||||
|
usdCnyRate: 7.2,
|
||||||
|
marginMultiplier: 1.2,
|
||||||
|
inputCentsPer1k: 2,
|
||||||
|
outputCentsPer1k: 6,
|
||||||
|
minBillCents: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toCostEstimateConfig disables when cost mode off', () => {
|
||||||
|
const estimate = toCostEstimateConfig({
|
||||||
|
useBackendCost: false,
|
||||||
|
costEstimateFromTokens: true,
|
||||||
|
costEstimateInputUsdPer1M: 0.27,
|
||||||
|
costEstimateOutputUsdPer1M: 1.1,
|
||||||
|
});
|
||||||
|
assert.equal(estimate.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin-db config wins over env for effective billing', async () => {
|
||||||
|
const pool = createMemoryPool();
|
||||||
|
const env = {
|
||||||
|
H5_USE_BACKEND_COST: '1',
|
||||||
|
H5_USD_CNY_RATE: '7.2',
|
||||||
|
H5_MARGIN_MULTIPLIER: '1.2',
|
||||||
|
};
|
||||||
|
const service = createBillingAdminConfigService(pool, { env, cacheTtlMs: 0 });
|
||||||
|
await service.updateAdminConfig(
|
||||||
|
{ marginMultiplier: 2, usdCnyRate: 7.5, useBackendCost: true },
|
||||||
|
{ updatedBy: 'admin-1' },
|
||||||
|
);
|
||||||
|
const effective = await service.getEffectiveBillingConfig();
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('H5_BILLING_CONFIG_SOURCE=env locks admin writes', async () => {
|
||||||
|
const pool = createMemoryPool();
|
||||||
|
const service = createBillingAdminConfigService(pool, {
|
||||||
|
env: { H5_BILLING_CONFIG_SOURCE: 'env', H5_MARGIN_MULTIPLIER: '1.2' },
|
||||||
|
cacheTtlMs: 0,
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => service.updateAdminConfig({ marginMultiplier: 9 }),
|
||||||
|
/H5_BILLING_CONFIG_SOURCE=env/,
|
||||||
|
);
|
||||||
|
const admin = await service.getAdminConfig();
|
||||||
|
assert.equal(admin.source, 'env-override');
|
||||||
|
assert.equal(admin.config.marginMultiplier, 1.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('env fallback used when no admin row', async () => {
|
||||||
|
const pool = createMemoryPool();
|
||||||
|
const service = createBillingAdminConfigService(pool, {
|
||||||
|
env: {
|
||||||
|
H5_USE_BACKEND_COST: '1',
|
||||||
|
H5_MARGIN_MULTIPLIER: '1.2',
|
||||||
|
H5_USD_CNY_RATE: '7.2',
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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 grantImageQuota = async (userId, delta, { operatorId = null, note = '' } = {}) => {
|
||||||
const safeDelta = Math.floor(Number(delta));
|
const safeDelta = Math.floor(Number(delta));
|
||||||
if (!Number.isFinite(safeDelta) || safeDelta === 0) {
|
if (!Number.isFinite(safeDelta) || safeDelta === 0) {
|
||||||
@@ -820,6 +905,7 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
|
|||||||
consumeImageQuota,
|
consumeImageQuota,
|
||||||
getImageQuota,
|
getImageQuota,
|
||||||
checkImageQuota,
|
checkImageQuota,
|
||||||
|
setImageQuota,
|
||||||
grantImageQuota,
|
grantImageQuota,
|
||||||
listImageQuotaLedger,
|
listImageQuotaLedger,
|
||||||
renewSubscription,
|
renewSubscription,
|
||||||
|
|||||||
@@ -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', () => {
|
describe('cancelSubscription', () => {
|
||||||
it('returns cancelled=true when active sub exists', async () => {
|
it('returns cancelled=true when active sub exists', async () => {
|
||||||
const pool = makePool(makeSubRow());
|
const pool = makePool(makeSubRow());
|
||||||
|
|||||||
+55
-21
@@ -1,4 +1,5 @@
|
|||||||
import { normalizeTokenState } from './billing.mjs';
|
import { normalizeTokenState } from './billing.mjs';
|
||||||
|
import { fetchGooseSessionAccumulatedCostUsd } from './goose-session-cost.mjs';
|
||||||
|
|
||||||
export function loadCostEstimateConfig(env = process.env) {
|
export function loadCostEstimateConfig(env = process.env) {
|
||||||
const useBackendCost = env.H5_USE_BACKEND_COST === '1';
|
const useBackendCost = env.H5_USE_BACKEND_COST === '1';
|
||||||
@@ -34,20 +35,24 @@ export function estimateAccumulatedCostUsd(tokenStateRaw, estimateConfig = loadC
|
|||||||
|
|
||||||
export function enrichTokenStateForBilling(
|
export function enrichTokenStateForBilling(
|
||||||
tokenStateRaw,
|
tokenStateRaw,
|
||||||
{ sessionCost = null } = {},
|
{ sessionCost = null, estimateConfig = null } = {},
|
||||||
env = process.env,
|
env = process.env,
|
||||||
) {
|
) {
|
||||||
const state = normalizeTokenState(tokenStateRaw);
|
const state = normalizeTokenState(tokenStateRaw);
|
||||||
if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionUsd = pickSessionAccumulatedCost(sessionCost);
|
const sessionUsd = pickSessionAccumulatedCost(sessionCost);
|
||||||
if (sessionUsd != null) {
|
if (sessionUsd != null) {
|
||||||
return { ...state, accumulatedCost: sessionUsd };
|
return { ...state, accumulatedCost: sessionUsd };
|
||||||
}
|
}
|
||||||
|
|
||||||
const estimatedUsd = estimateAccumulatedCostUsd(state, loadCostEstimateConfig(env));
|
if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimatedUsd = estimateAccumulatedCostUsd(
|
||||||
|
state,
|
||||||
|
estimateConfig ?? loadCostEstimateConfig(env),
|
||||||
|
);
|
||||||
if (estimatedUsd != null) {
|
if (estimatedUsd != null) {
|
||||||
return { ...state, accumulatedCost: estimatedUsd };
|
return { ...state, accumulatedCost: estimatedUsd };
|
||||||
}
|
}
|
||||||
@@ -55,25 +60,54 @@ export function enrichTokenStateForBilling(
|
|||||||
return state;
|
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(
|
export async function resolveBillingTokenState(
|
||||||
tokenStateRaw,
|
tokenStateRaw,
|
||||||
{ sessionId = null, fetchSession = null } = {},
|
{
|
||||||
|
sessionId = null,
|
||||||
|
fetchSession = null,
|
||||||
|
fetchSessionCostFromPg = null,
|
||||||
|
estimateConfig = null,
|
||||||
|
loadEstimateConfig = null,
|
||||||
|
} = {},
|
||||||
env = process.env,
|
env = process.env,
|
||||||
) {
|
) {
|
||||||
const state = normalizeTokenState(tokenStateRaw);
|
const state = normalizeTokenState(tokenStateRaw);
|
||||||
if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) {
|
const sessionCost = await resolveSessionCostPayload(
|
||||||
return state;
|
sessionId,
|
||||||
}
|
fetchSession,
|
||||||
|
fetchSessionCostFromPg,
|
||||||
if (typeof fetchSession === 'function' && sessionId) {
|
env,
|
||||||
try {
|
);
|
||||||
const session = await fetchSession(sessionId);
|
const resolvedEstimateConfig =
|
||||||
const enriched = enrichTokenStateForBilling(state, { sessionCost: session }, env);
|
estimateConfig
|
||||||
if (enriched.accumulatedCost != null) return enriched;
|
?? (typeof loadEstimateConfig === 'function' ? await loadEstimateConfig() : null)
|
||||||
} catch {
|
?? loadCostEstimateConfig(env);
|
||||||
// Best-effort: fall through to token estimate.
|
return enrichTokenStateForBilling(state, { sessionCost, estimateConfig: resolvedEstimateConfig }, env);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return enrichTokenStateForBilling(state, {}, env);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,16 @@ test('estimateAccumulatedCostUsd uses DeepSeek-ish defaults', () => {
|
|||||||
assert.equal(estimate, 0.281);
|
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(
|
const enriched = enrichTokenStateForBilling(
|
||||||
{ accumulatedInputTokens: 1000, accumulatedOutputTokens: 100, accumulatedCost: 0.05 },
|
{ accumulatedInputTokens: 1000, accumulatedOutputTokens: 100, accumulatedCost: 0.05 },
|
||||||
{},
|
{},
|
||||||
@@ -78,6 +87,39 @@ test('resolveBillingTokenState fetches session before estimating', async () => {
|
|||||||
assert.equal(resolved.accumulatedCost, 0.42);
|
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', () => {
|
test('enriched cost drives 1.2x billing instead of flat fallback', () => {
|
||||||
const previous = { lastInputTokens: 224853, lastOutputTokens: 6685, lastAccumulatedCost: null };
|
const previous = { lastInputTokens: 224853, lastOutputTokens: 6685, lastAccumulatedCost: null };
|
||||||
const tokenState = enrichTokenStateForBilling(
|
const tokenState = enrichTokenStateForBilling(
|
||||||
|
|||||||
+8
-7
@@ -1,16 +1,17 @@
|
|||||||
export function loadBillingConfig() {
|
export function loadBillingConfig(env = process.env) {
|
||||||
// 默认按人民币分(CNY cents)计费;仅当 H5_USE_BACKEND_COST=1 时才用上游 USD 成本换算。
|
// 默认按人民币分(CNY cents)计费;仅当 H5_USE_BACKEND_COST=1 时才用上游 USD 成本换算。
|
||||||
const useBackendCost = process.env.H5_USE_BACKEND_COST === '1';
|
const useBackendCost = env.H5_USE_BACKEND_COST === '1';
|
||||||
// 成本模式下的毛利倍数:最终扣费 = 上游真实成本(USD) × 汇率 × marginMultiplier。
|
// 成本模式下的毛利倍数:最终扣费 = 上游真实成本(USD) × 汇率 × marginMultiplier。
|
||||||
// 默认 1(按成本价卖,零毛利)——启用 useBackendCost 时务必显式设置目标倍数。
|
// 默认 1(按成本价卖,零毛利)——启用 useBackendCost 时务必显式设置目标倍数。
|
||||||
const marginMultiplier = Number(process.env.H5_MARGIN_MULTIPLIER ?? 1);
|
// 生产可通过 memind_adm「计费中心 → 计量公式」覆盖(见 billing-admin-config.mjs)。
|
||||||
|
const marginMultiplier = Number(env.H5_MARGIN_MULTIPLIER ?? 1);
|
||||||
return {
|
return {
|
||||||
useBackendCost,
|
useBackendCost,
|
||||||
usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2),
|
usdCnyRate: Number(env.H5_USD_CNY_RATE ?? 7.2),
|
||||||
marginMultiplier: Number.isFinite(marginMultiplier) && marginMultiplier > 0 ? marginMultiplier : 1,
|
marginMultiplier: Number.isFinite(marginMultiplier) && marginMultiplier > 0 ? marginMultiplier : 1,
|
||||||
inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? 2),
|
inputCentsPer1k: Number(env.H5_BILL_INPUT_CENTS_PER_1K ?? 2),
|
||||||
outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? 6),
|
outputCentsPer1k: Number(env.H5_BILL_OUTPUT_CENTS_PER_1K ?? 6),
|
||||||
minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1),
|
minBillCents: Number(env.H5_MIN_BILL_CENTS ?? 1),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,12 @@ export async function migrateSchema(pool) {
|
|||||||
`ALTER TABLE h5_usage_records ADD UNIQUE KEY uniq_h5_usage_request_id (request_id)`,
|
`ALTER TABLE h5_usage_records ADD UNIQUE KEY uniq_h5_usage_request_id (request_id)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!(await columnExists(pool, 'h5_usage_records', 'billing_source'))) {
|
||||||
|
await pool.query(
|
||||||
|
`ALTER TABLE h5_usage_records
|
||||||
|
ADD COLUMN billing_source VARCHAR(16) NOT NULL DEFAULT 'wallet' AFTER balance_after_cents`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const assetForeignKeys = [
|
const assetForeignKeys = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -410,3 +410,45 @@ Portal,避免在线修改稳定 `.env`,并确保稳定 8081 与其他用户
|
|||||||
- 保留本地分支名仅用于审计追溯。
|
- 保留本地分支名仅用于审计追溯。
|
||||||
- 不要从该分支继续开发、merge、cherry-pick 或发布。
|
- 不要从该分支继续开发、merge、cherry-pick 或发布。
|
||||||
- 后续管理后台 UI 只能在 memind_adm 5174 开发。
|
- 后续管理后台 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` workflowKind;103 曾从该功能分支单包发布。
|
||||||
|
|
||||||
|
### 最终处置
|
||||||
|
|
||||||
|
- 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 + apply(4 用户,合计 ¥94.11)
|
||||||
|
|
||||||
|
### 最终处置
|
||||||
|
|
||||||
|
- 保留本地分支名用于审计追溯。
|
||||||
|
- **不要** merge、cherry-pick 或从该分支构建 runtime/artifact。
|
||||||
|
- 103 生产依据 `44121df` / release manifest `20260805-095542-44121df`。
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
- 仅当上游回传 `accumulatedCost` 时生效;缺失则**回退**原 flat token 路径,不破坏现有计费。
|
- 仅当上游回传 `accumulatedCost` 时生效;缺失则**回退**原 flat token 路径,不破坏现有计费。
|
||||||
- 若 goose Finish / session 均未带 cost,且 `H5_COST_ESTIMATE_FROM_TOKENS=1`(成本模式默认开启),Portal 会按 DeepSeek 中继粗估价(`billing-token-state.mjs`)补齐 `accumulatedCost`,再走 `× margin`。
|
- 若 goose Finish / session 均未带 cost,且 `H5_COST_ESTIMATE_FROM_TOKENS=1`(成本模式默认开启),Portal 会按 DeepSeek 中继粗估价(`billing-token-state.mjs`)补齐 `accumulatedCost`,再走 `× margin`。
|
||||||
- 生产启用:`.env` 设 `H5_USE_BACKEND_COST=1` + `H5_MARGIN_MULTIPLIER=<目标毛利>`。
|
- 生产启用:`.env` 设 `H5_USE_BACKEND_COST=1` + `H5_MARGIN_MULTIPLIER=<目标毛利>`。
|
||||||
|
- 也可在 memind_adm「计费中心 → 计量公式」修改同一套参数(`h5_billing_admin_config`,优先级高于 env;`H5_BILLING_CONFIG_SOURCE=env` 可锁定)。
|
||||||
|
|
||||||
> ⚠️ 上线前需确认:一帧真实 SSE 的 `token_state` 是否带 `accumulated_cost`(goose `sessions.db` 有该列,但要确认 SSE Finish 事件也序列化了它)。确认前 multiplier 改动是安全的(无 cost 即回退)。
|
> ⚠️ 上线前需确认:一帧真实 SSE 的 `token_state` 是否带 `accumulated_cost`(goose `sessions.db` 有该列,但要确认 SSE Finish 事件也序列化了它)。确认前 multiplier 改动是安全的(无 cost 即回退)。
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
+22
-2
@@ -68,6 +68,20 @@ export function resolveAnalyticsOwnerLabel(user = {}) {
|
|||||||
return label.replace(/[\r\n\t]+/g, ' ').slice(0, 80) || '未命名用户';
|
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 } = {}) {
|
export function buildProductAnalyticsContext({ config, user = null } = {}) {
|
||||||
if (!config?.enabled || !config.websiteId) return { enabled: false };
|
if (!config?.enabled || !config.websiteId) return { enabled: false };
|
||||||
const distinctId = user?.id ? resolveAnalyticsIdentity(user.id, config) : '';
|
const distinctId = user?.id ? resolveAnalyticsIdentity(user.id, config) : '';
|
||||||
@@ -109,6 +123,9 @@ export function sendMindSpaceAnalyticsEvent({
|
|||||||
const owner = resolveAnalyticsIdentity(ownerId, config);
|
const owner = resolveAnalyticsIdentity(ownerId, config);
|
||||||
if (!owner) return Promise.resolve(false);
|
if (!owner) return Promise.resolve(false);
|
||||||
const endpoint = `${String(config.analyticsUrl || 'http://127.0.0.1:3100').replace(/\/$/, '')}/api/send`;
|
const endpoint = `${String(config.analyticsUrl || 'http://127.0.0.1:3100').replace(/\/$/, '')}/api/send`;
|
||||||
|
// Umami Memind dashboards resolve「创建用户」from event_data.username (not owner_label).
|
||||||
|
// Keep owner_label for backward compatibility; never omit username on generation events.
|
||||||
|
const username = resolveAnalyticsOwnerLabel({ displayName: ownerLabel });
|
||||||
const payload = {
|
const payload = {
|
||||||
website: config.websiteId,
|
website: config.websiteId,
|
||||||
id: owner,
|
id: owner,
|
||||||
@@ -123,7 +140,8 @@ export function sendMindSpaceAnalyticsEvent({
|
|||||||
channel,
|
channel,
|
||||||
owner_segment: String(ownerSegment || 'unknown'),
|
owner_segment: String(ownerSegment || 'unknown'),
|
||||||
plan_type: String(planType || 'unknown'),
|
plan_type: String(planType || 'unknown'),
|
||||||
owner_label: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }),
|
username,
|
||||||
|
owner_label: username,
|
||||||
generated_at: String(generatedAt || ''),
|
generated_at: String(generatedAt || ''),
|
||||||
identity_mode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous',
|
identity_mode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous',
|
||||||
},
|
},
|
||||||
@@ -152,6 +170,7 @@ export function injectMindSpaceAnalytics(html, {
|
|||||||
planType = 'unknown',
|
planType = 'unknown',
|
||||||
generatedAt = '',
|
generatedAt = '',
|
||||||
channel = 'h5',
|
channel = 'h5',
|
||||||
|
viewerIdentity = null,
|
||||||
config = resolveMindSpaceAnalyticsConfig(),
|
config = resolveMindSpaceAnalyticsConfig(),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const source = String(html ?? '');
|
const source = String(html ?? '');
|
||||||
@@ -167,7 +186,8 @@ export function injectMindSpaceAnalytics(html, {
|
|||||||
`data-host-url="${config.hostPath}"`,
|
`data-host-url="${config.hostPath}"`,
|
||||||
];
|
];
|
||||||
if (config.domains) attrs.push(`data-domains="${config.domains.replaceAll('"', '"')}"`);
|
if (config.domains) attrs.push(`data-domains="${config.domains.replaceAll('"', '"')}"`);
|
||||||
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>`);
|
if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}</head>`);
|
||||||
return source.replace(/<body\b/i, `${block}<body`);
|
return source.replace(/<body\b/i, `${block}<body`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import vm from 'node:vm';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildProductAnalyticsContext,
|
buildProductAnalyticsContext,
|
||||||
|
buildViewerAnalyticsIdentity,
|
||||||
injectMindSpaceAnalytics,
|
injectMindSpaceAnalytics,
|
||||||
pseudonymizeAnalyticsId,
|
pseudonymizeAnalyticsId,
|
||||||
resolveAnalyticsIdentity,
|
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, /src="\/analytics\/script\.js"/);
|
||||||
assert.match(out, /data-host-url="\/analytics"/);
|
assert.match(out, /data-host-url="\/analytics"/);
|
||||||
assert.match(out, /data-auto-track="false"/);
|
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.match(out, /function pageview\(\).*window\.umami\.track\(\)/);
|
||||||
assert.ok(out.indexOf('identify();pageview();') > 0);
|
|
||||||
assert.doesNotMatch(out, /t\('page_view'\)/);
|
assert.doesNotMatch(out, /t\('page_view'\)/);
|
||||||
assert.match(out, /page_id/);
|
assert.match(out, /page_id/);
|
||||||
assert.match(out, /owner_segment/);
|
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);
|
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>', {
|
const out = injectMindSpaceAnalytics('<!doctype html><html><head></head><body></body></html>', {
|
||||||
ownerId: 'user-123',
|
ownerId: 'user-123',
|
||||||
ownerSegment: 'plan:pro',
|
ownerSegment: 'plan:pro',
|
||||||
@@ -177,22 +179,67 @@ test('identifies the pseudonymous owner before sending a standard page view', ()
|
|||||||
documentElement: { scrollHeight: 1600 },
|
documentElement: { scrollHeight: 1600 },
|
||||||
addEventListener: () => {},
|
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: () => {},
|
setTimeout: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [
|
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [
|
||||||
['identify', pseudonymizeAnalyticsId('user-123', 'secret'), {
|
['identify', viewerId, {
|
||||||
username: '张三',
|
username: '李四',
|
||||||
memind_page_url: 'https://m.tkmind.cn/MindSpace/demo/public/page.html',
|
memind_page_url: 'https://m.tkmind.cn/MindSpace/demo/public/page.html',
|
||||||
owner_segment: 'plan:pro',
|
owner_segment: 'plan:free',
|
||||||
channel: 'h5',
|
plan_type: 'free',
|
||||||
|
channel: 'public',
|
||||||
surface: 'generated_page',
|
surface: 'generated_page',
|
||||||
plan_type: 'unknown',
|
|
||||||
identity_mode: 'pseudonymous',
|
identity_mode: 'pseudonymous',
|
||||||
}],
|
}],
|
||||||
['track'],
|
['track'],
|
||||||
]);
|
]);
|
||||||
|
assert.notEqual(viewerId, 'user-123');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not alter non-full-html or disabled pages', () => {
|
test('does not alter non-full-html or disabled pages', () => {
|
||||||
@@ -233,6 +280,7 @@ test('generation events are attributed to raw identity and include analysis dime
|
|||||||
assert.equal(requestBody.payload.id, 'user-123');
|
assert.equal(requestBody.payload.id, 'user-123');
|
||||||
assert.deepEqual(requestBody.payload.data, expectPayload({
|
assert.deepEqual(requestBody.payload.data, expectPayload({
|
||||||
owner_id: 'user-123',
|
owner_id: 'user-123',
|
||||||
|
username: '张三',
|
||||||
owner_label: '张三',
|
owner_label: '张三',
|
||||||
owner_segment: 'plan:pro',
|
owner_segment: 'plan:pro',
|
||||||
plan_type: 'pro',
|
plan_type: 'pro',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export function sendMindSpaceRybbitEvent({
|
|||||||
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
|
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
|
||||||
if (!owner) return Promise.resolve(false);
|
if (!owner) return Promise.resolve(false);
|
||||||
const endpoint = `${String(config.rybbitUrl || 'https://rybbit.tkmind.cn').replace(/\/$/, '')}/api/track`;
|
const endpoint = `${String(config.rybbitUrl || 'https://rybbit.tkmind.cn').replace(/\/$/, '')}/api/track`;
|
||||||
|
const username = resolveAnalyticsOwnerLabel({ displayName: ownerLabel });
|
||||||
const payload = {
|
const payload = {
|
||||||
site_id: String(config.siteId),
|
site_id: String(config.siteId),
|
||||||
type: 'custom_event',
|
type: 'custom_event',
|
||||||
@@ -76,7 +77,8 @@ export function sendMindSpaceRybbitEvent({
|
|||||||
agent_run_id: String(agentRunId || ''),
|
agent_run_id: String(agentRunId || ''),
|
||||||
channel,
|
channel,
|
||||||
owner_segment: String(ownerSegment || 'unknown'),
|
owner_segment: String(ownerSegment || 'unknown'),
|
||||||
owner_label: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }),
|
username,
|
||||||
|
owner_label: username,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
return fetch(endpoint, {
|
return fetch(endpoint, {
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -98,8 +98,17 @@ function buildApp(workspaceRoot, pool = createPool('public')) {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(app, method, url, { body, headers } = {}) {
|
async function listenEphemeral(app) {
|
||||||
const server = app.listen(0);
|
const server = app.listen(0);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('listening', resolve);
|
||||||
|
server.once('error', reject);
|
||||||
|
});
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(app, method, url, { body, headers } = {}) {
|
||||||
|
const server = await listenEphemeral(app);
|
||||||
try {
|
try {
|
||||||
const { port } = server.address();
|
const { port } = server.address();
|
||||||
const response = await fetch(`http://127.0.0.1:${port}${url}`, {
|
const response = await fetch(`http://127.0.0.1:${port}${url}`, {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const CRITICAL_IMPACT_RULES = Object.freeze([
|
|||||||
const NON_RUNTIME_PATHS = Object.freeze([
|
const NON_RUNTIME_PATHS = Object.freeze([
|
||||||
/^(?:AGENTS|README|CHANGELOG)\.md$/i,
|
/^(?:AGENTS|README|CHANGELOG)\.md$/i,
|
||||||
/^ops\/README\.md$/i,
|
/^ops\/README\.md$/i,
|
||||||
|
/^\.gitea\/workflows\//i,
|
||||||
/^\.runtime\//i,
|
/^\.runtime\//i,
|
||||||
/^docs\//i,
|
/^docs\//i,
|
||||||
/^\.cursor\//i,
|
/^\.cursor\//i,
|
||||||
|
|||||||
@@ -511,6 +511,7 @@ CREATE TABLE IF NOT EXISTS h5_usage_records (
|
|||||||
output_tokens INT NOT NULL DEFAULT 0,
|
output_tokens INT NOT NULL DEFAULT 0,
|
||||||
cost_cents BIGINT NOT NULL,
|
cost_cents BIGINT NOT NULL,
|
||||||
balance_after_cents BIGINT NOT NULL,
|
balance_after_cents BIGINT NOT NULL,
|
||||||
|
billing_source VARCHAR(16) NOT NULL DEFAULT 'wallet',
|
||||||
created_at BIGINT NOT NULL,
|
created_at BIGINT NOT NULL,
|
||||||
UNIQUE KEY uniq_h5_usage_request_id (request_id),
|
UNIQUE KEY uniq_h5_usage_request_id (request_id),
|
||||||
KEY idx_h5_usage_user_time (user_id, created_at),
|
KEY idx_h5_usage_user_time (user_id, created_at),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -438,8 +438,42 @@ remount_goosed_after_live_swap() {
|
|||||||
local ready=0
|
local ready=0
|
||||||
|
|
||||||
if [[ ! -x "${docker_bin}" || ! -f "${compose_file}" ]]; then
|
if [[ ! -x "${docker_bin}" || ! -f "${compose_file}" ]]; then
|
||||||
echo "goosed remount failed: docker or ${compose_file} is unavailable" >&2
|
say "重启 native goosed 以刷新 Portal/MindSpace bind mount"
|
||||||
return 1
|
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
|
fi
|
||||||
|
|
||||||
mkdir -p "${APP_DIR}"
|
mkdir -p "${APP_DIR}"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const runId = `browser-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(
|
|||||||
const stack = await createLocalGateStack({ root, runId, port: 19082 });
|
const stack = await createLocalGateStack({ root, runId, port: 19082 });
|
||||||
const checks = [];
|
const checks = [];
|
||||||
const consoleErrors = [];
|
const consoleErrors = [];
|
||||||
|
const forbiddenResponses = [];
|
||||||
let browser;
|
let browser;
|
||||||
|
|
||||||
function record(id, passed, detail) {
|
function record(id, passed, detail) {
|
||||||
@@ -50,6 +51,9 @@ try {
|
|||||||
if (message.type() === 'error') consoleErrors.push(message.text());
|
if (message.type() === 'error') consoleErrors.push(message.text());
|
||||||
});
|
});
|
||||||
page.on('pageerror', (error) => consoleErrors.push(error.message));
|
page.on('pageerror', (error) => consoleErrors.push(error.message));
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.status() === 403) forbiddenResponses.push(response.url());
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto(stack.baseUrl, { waitUntil: 'networkidle' });
|
await page.goto(stack.baseUrl, { waitUntil: 'networkidle' });
|
||||||
await page.getByPlaceholder('用户名').fill(username);
|
await page.getByPlaceholder('用户名').fill(username);
|
||||||
@@ -118,7 +122,6 @@ try {
|
|||||||
`<!doctype html><html lang="zh-CN"><head>
|
`<!doctype html><html lang="zh-CN"><head>
|
||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<meta name="description" content="Release gate browser flow">
|
<meta name="description" content="Release gate browser flow">
|
||||||
<meta name="mindspace-cover" content='{"tag":"测试","subtitle":"浏览器流程"}'>
|
|
||||||
<title>浏览器流程页面</title>
|
<title>浏览器流程页面</title>
|
||||||
<style>body{margin:0;font:16px system-ui}.wrap{max-width:720px;margin:auto;padding:20px}
|
<style>body{margin:0;font:16px system-ui}.wrap{max-width:720px;margin:auto;padding:20px}
|
||||||
label,input,button,a{display:block;margin:10px 0;max-width:100%}</style></head><body>
|
label,input,button,a{display:block;margin:10px 0;max-width:100%}</style></head><body>
|
||||||
@@ -188,6 +191,9 @@ const result=await response.json();document.querySelector('#result').textContent
|
|||||||
);
|
);
|
||||||
|
|
||||||
await page.goto(publicUrl, { waitUntil: 'domcontentloaded' });
|
await page.goto(publicUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
consoleErrors.splice(0);
|
||||||
|
forbiddenResponses.splice(0);
|
||||||
|
await page.waitForTimeout(250);
|
||||||
const accessibility = await page.evaluate(() => {
|
const accessibility = await page.evaluate(() => {
|
||||||
const interactive = [...document.querySelectorAll('button,input,a')];
|
const interactive = [...document.querySelectorAll('button,input,a')];
|
||||||
const named = interactive.every((element) => {
|
const named = interactive.every((element) => {
|
||||||
@@ -212,7 +218,7 @@ const result=await response.json();document.querySelector('#result').textContent
|
|||||||
failed = checks.some((check) => !check.passed);
|
failed = checks.some((check) => !check.passed);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(stack.runRoot, 'browser.json'),
|
path.join(stack.runRoot, 'browser.json'),
|
||||||
`${JSON.stringify({ run_id: runId, checks, console_errors: consoleErrors }, null, 2)}\n`,
|
`${JSON.stringify({ run_id: runId, checks, console_errors: consoleErrors, forbidden_responses: forbiddenResponses }, null, 2)}\n`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await browser?.close();
|
await browser?.close();
|
||||||
|
|||||||
@@ -412,6 +412,8 @@ async function bootstrapUserAuth() {
|
|||||||
});
|
});
|
||||||
subscriptionService =
|
subscriptionService =
|
||||||
authServices.subscriptionService;
|
authServices.subscriptionService;
|
||||||
|
const billingConfigService =
|
||||||
|
authServices.billingConfigService;
|
||||||
userAuth = authServices.userAuth;
|
userAuth = authServices.userAuth;
|
||||||
sessionAccess = authServices.sessionAccess;
|
sessionAccess = authServices.sessionAccess;
|
||||||
wechatPayClient = authServices.wechatPayClient;
|
wechatPayClient = authServices.wechatPayClient;
|
||||||
@@ -501,6 +503,7 @@ async function bootstrapUserAuth() {
|
|||||||
sessionStreamStore,
|
sessionStreamStore,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
billingConfigService,
|
||||||
sessionSnapshotService,
|
sessionSnapshotService,
|
||||||
conversationMemoryService,
|
conversationMemoryService,
|
||||||
memoryV2,
|
memoryV2,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createBillingAdminConfigService } from '../billing-admin-config.mjs';
|
||||||
import { createRechargeService } from '../billing-recharge.mjs';
|
import { createRechargeService } from '../billing-recharge.mjs';
|
||||||
import {
|
import {
|
||||||
createPlanCatalogService,
|
createPlanCatalogService,
|
||||||
@@ -53,6 +54,7 @@ export async function bootstrapPortalAuthServices({
|
|||||||
loadWechatOAuthConfigFn = loadWechatOAuthConfig,
|
loadWechatOAuthConfigFn = loadWechatOAuthConfig,
|
||||||
createWechatOAuthServiceFn = createWechatOAuthService,
|
createWechatOAuthServiceFn = createWechatOAuthService,
|
||||||
createRechargeServiceFn = createRechargeService,
|
createRechargeServiceFn = createRechargeService,
|
||||||
|
createBillingAdminConfigServiceFn = createBillingAdminConfigService,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (
|
if (
|
||||||
!pool ||
|
!pool ||
|
||||||
@@ -77,6 +79,10 @@ export async function bootstrapPortalAuthServices({
|
|||||||
subscriptionService._planCatalogService =
|
subscriptionService._planCatalogService =
|
||||||
planCatalogService;
|
planCatalogService;
|
||||||
|
|
||||||
|
const billingConfigService =
|
||||||
|
createBillingAdminConfigServiceFn(pool, { env });
|
||||||
|
await billingConfigService.ensureSchema();
|
||||||
|
|
||||||
const userAuth = createUserAuthFn(pool, {
|
const userAuth = createUserAuthFn(pool, {
|
||||||
usersRoot,
|
usersRoot,
|
||||||
h5Root,
|
h5Root,
|
||||||
@@ -84,6 +90,7 @@ export async function bootstrapPortalAuthServices({
|
|||||||
env.H5_SIGNUP_BALANCE_CENTS ?? 500,
|
env.H5_SIGNUP_BALANCE_CENTS ?? 500,
|
||||||
),
|
),
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
billingConfigService,
|
||||||
getMindSearchConfig: () =>
|
getMindSearchConfig: () =>
|
||||||
mindSearchConfigService.getEffectiveConfig(),
|
mindSearchConfigService.getEffectiveConfig(),
|
||||||
provisionUserDataSpace: async ({
|
provisionUserDataSpace: async ({
|
||||||
@@ -158,6 +165,7 @@ export async function bootstrapPortalAuthServices({
|
|||||||
return {
|
return {
|
||||||
planCatalogService,
|
planCatalogService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
billingConfigService,
|
||||||
userAuth,
|
userAuth,
|
||||||
sessionAccess,
|
sessionAccess,
|
||||||
routerDecisionMode,
|
routerDecisionMode,
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ function createSetup(overrides = {}) {
|
|||||||
subscriptionOptions = receivedOptions;
|
subscriptionOptions = receivedOptions;
|
||||||
return { id: 'subscription' };
|
return { id: 'subscription' };
|
||||||
},
|
},
|
||||||
|
createBillingAdminConfigServiceFn(receivedPool, receivedOptions) {
|
||||||
|
assert.equal(receivedPool, pool);
|
||||||
|
assert.equal(receivedOptions?.env, options.env);
|
||||||
|
calls.push(['billing-config']);
|
||||||
|
return {
|
||||||
|
id: 'billing-config',
|
||||||
|
async ensureSchema() {
|
||||||
|
calls.push(['billing-config-schema']);
|
||||||
|
},
|
||||||
|
async getEffectiveBillingConfig() {
|
||||||
|
return { marginMultiplier: 1.2 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
createUserAuthFn(receivedPool, receivedOptions) {
|
createUserAuthFn(receivedPool, receivedOptions) {
|
||||||
assert.equal(receivedPool, pool);
|
assert.equal(receivedPool, pool);
|
||||||
calls.push(['user-auth']);
|
calls.push(['user-auth']);
|
||||||
@@ -161,11 +175,13 @@ test('preserves subscription, auth, and user-space wiring', async () => {
|
|||||||
let captured = setup.getCaptured();
|
let captured = setup.getCaptured();
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
setup.calls.slice(0, 4).map(([name]) => name),
|
setup.calls.slice(0, 6).map(([name]) => name),
|
||||||
[
|
[
|
||||||
'plan-schema',
|
'plan-schema',
|
||||||
'plan-service',
|
'plan-service',
|
||||||
'subscription-service',
|
'subscription-service',
|
||||||
|
'billing-config',
|
||||||
|
'billing-config-schema',
|
||||||
'user-auth',
|
'user-auth',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -173,6 +189,11 @@ test('preserves subscription, auth, and user-space wiring', async () => {
|
|||||||
result.subscriptionService._planCatalogService,
|
result.subscriptionService._planCatalogService,
|
||||||
setup.planCatalogService,
|
setup.planCatalogService,
|
||||||
);
|
);
|
||||||
|
assert.equal(result.billingConfigService?.id, 'billing-config');
|
||||||
|
assert.equal(
|
||||||
|
captured.userAuthOptions.billingConfigService?.id,
|
||||||
|
'billing-config',
|
||||||
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await captured.subscriptionOptions.getPlanAsync(
|
await captured.subscriptionOptions.getPlanAsync(
|
||||||
'pro',
|
'pro',
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export function bootstrapPortalGatewayServices({
|
|||||||
sessionStreamStore,
|
sessionStreamStore,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
billingConfigService = null,
|
||||||
sessionSnapshotService,
|
sessionSnapshotService,
|
||||||
conversationMemoryService,
|
conversationMemoryService,
|
||||||
memoryV2,
|
memoryV2,
|
||||||
@@ -176,6 +177,7 @@ export function bootstrapPortalGatewayServices({
|
|||||||
sessionStreamStore,
|
sessionStreamStore,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
billingConfigService,
|
||||||
sessionSnapshotService,
|
sessionSnapshotService,
|
||||||
conversationMemoryService,
|
conversationMemoryService,
|
||||||
memoryV2,
|
memoryV2,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
collectInlineScriptHashes,
|
collectInlineScriptHashes,
|
||||||
} from '../mindspace-public-delivery.mjs';
|
} from '../mindspace-public-delivery.mjs';
|
||||||
import {
|
import {
|
||||||
|
buildViewerAnalyticsIdentity,
|
||||||
injectMindSpaceAnalytics,
|
injectMindSpaceAnalytics,
|
||||||
resolveAnalyticsOwnerLabel,
|
resolveAnalyticsOwnerLabel,
|
||||||
resolveAnalyticsOwnerSegment,
|
resolveAnalyticsOwnerSegment,
|
||||||
@@ -51,6 +52,7 @@ async function decoratePublicationHtmlAnalytics(
|
|||||||
result,
|
result,
|
||||||
analyticsConfig,
|
analyticsConfig,
|
||||||
rybbitConfig,
|
rybbitConfig,
|
||||||
|
viewer = null,
|
||||||
getAuthPool = () => null,
|
getAuthPool = () => null,
|
||||||
getUserAuth = () => null,
|
getUserAuth = () => null,
|
||||||
getMindSpacePages = () => null,
|
getMindSpacePages = () => null,
|
||||||
@@ -112,6 +114,7 @@ async function decoratePublicationHtmlAnalytics(
|
|||||||
generatedAt: pageDataContext?.generatedAt ?? '',
|
generatedAt: pageDataContext?.generatedAt ?? '',
|
||||||
pageId,
|
pageId,
|
||||||
publicationId,
|
publicationId,
|
||||||
|
viewerIdentity: buildViewerAnalyticsIdentity(viewer, analyticsConfig),
|
||||||
config: analyticsConfig,
|
config: analyticsConfig,
|
||||||
});
|
});
|
||||||
decorated = injectMindSpaceRybbit(decorated, {
|
decorated = injectMindSpaceRybbit(decorated, {
|
||||||
@@ -215,6 +218,7 @@ export function createPortalPublishedPageDelivery({
|
|||||||
result,
|
result,
|
||||||
analyticsConfig,
|
analyticsConfig,
|
||||||
rybbitConfig,
|
rybbitConfig,
|
||||||
|
viewer: req.currentUser ?? null,
|
||||||
getAuthPool,
|
getAuthPool,
|
||||||
getUserAuth,
|
getUserAuth,
|
||||||
getMindSpacePages,
|
getMindSpacePages,
|
||||||
@@ -346,6 +350,7 @@ export function createPortalPublishedPageDelivery({
|
|||||||
result,
|
result,
|
||||||
analyticsConfig,
|
analyticsConfig,
|
||||||
rybbitConfig,
|
rybbitConfig,
|
||||||
|
viewer: req.currentUser ?? null,
|
||||||
getAuthPool,
|
getAuthPool,
|
||||||
getUserAuth,
|
getUserAuth,
|
||||||
getMindSpacePages,
|
getMindSpacePages,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
buildViewerAnalyticsIdentity,
|
||||||
injectMindSpaceAnalytics,
|
injectMindSpaceAnalytics,
|
||||||
resolveAnalyticsOwnerLabel,
|
resolveAnalyticsOwnerLabel,
|
||||||
resolveAnalyticsOwnerSegment,
|
resolveAnalyticsOwnerSegment,
|
||||||
@@ -250,6 +251,10 @@ export function createPortalWorkspacePublicationDelivery({
|
|||||||
pageDataContext?.publicationId ??
|
pageDataContext?.publicationId ??
|
||||||
pageDataContext?.publication_id ??
|
pageDataContext?.publication_id ??
|
||||||
'',
|
'',
|
||||||
|
viewerIdentity: buildViewerAnalyticsIdentity(
|
||||||
|
req.currentUser ?? null,
|
||||||
|
analyticsConfig,
|
||||||
|
),
|
||||||
config: analyticsConfig,
|
config: analyticsConfig,
|
||||||
});
|
});
|
||||||
html = injectMindSpaceRybbit(html, {
|
html = injectMindSpaceRybbit(html, {
|
||||||
|
|||||||
@@ -1138,6 +1138,7 @@ export function createTkmindProxy({
|
|||||||
llmProviderService,
|
llmProviderService,
|
||||||
localFetchAsset,
|
localFetchAsset,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
billingConfigService = null,
|
||||||
sessionSnapshotService,
|
sessionSnapshotService,
|
||||||
conversationMemoryService,
|
conversationMemoryService,
|
||||||
memoryV2,
|
memoryV2,
|
||||||
@@ -2097,6 +2098,9 @@ export function createTkmindProxy({
|
|||||||
return resolveBillingTokenState(tokenStateRaw, {
|
return resolveBillingTokenState(tokenStateRaw, {
|
||||||
sessionId,
|
sessionId,
|
||||||
fetchSession: fetchSessionBillingCost,
|
fetchSession: fetchSessionBillingCost,
|
||||||
|
loadEstimateConfig: billingConfigService?.getEffectiveCostEstimateConfig
|
||||||
|
? () => billingConfigService.getEffectiveCostEstimateConfig()
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-7
@@ -142,6 +142,7 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
? options.provisionUserDataSpace
|
? options.provisionUserDataSpace
|
||||||
: null;
|
: null;
|
||||||
const getMindSearchConfig = typeof options.getMindSearchConfig === 'function' ? options.getMindSearchConfig : null;
|
const getMindSearchConfig = typeof options.getMindSearchConfig === 'function' ? options.getMindSearchConfig : null;
|
||||||
|
const billingConfigService = options.billingConfigService ?? null;
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
const loginFailures = new Map();
|
const loginFailures = new Map();
|
||||||
|
|
||||||
@@ -1334,7 +1335,9 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const tokenState = normalizeTokenState(tokenStateRaw);
|
const tokenState = normalizeTokenState(tokenStateRaw);
|
||||||
const config = loadBillingConfig();
|
const config = billingConfigService?.getEffectiveBillingConfig
|
||||||
|
? await billingConfigService.getEffectiveBillingConfig()
|
||||||
|
: loadBillingConfig(env);
|
||||||
const normalizedRequestId = requestId ? String(requestId).trim() || null : null;
|
const normalizedRequestId = requestId ? String(requestId).trim() || null : null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const conn = await pool.getConnection();
|
const conn = await pool.getConnection();
|
||||||
@@ -1439,10 +1442,12 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Subscription quota check: consume tokens from active plan before touching balance.
|
// Subscription quota check: consume tokens from active plan before touching balance.
|
||||||
|
let subscriptionCovered = false;
|
||||||
if (costCents > 0 && subscriptionService) {
|
if (costCents > 0 && subscriptionService) {
|
||||||
const coverage = await subscriptionService.consumeQuota(userId, deltaTokens, conn);
|
const coverage = await subscriptionService.consumeQuota(userId, deltaTokens, conn);
|
||||||
if (coverage.fullyCovers) {
|
if (coverage.fullyCovers) {
|
||||||
costCents = 0;
|
costCents = 0;
|
||||||
|
subscriptionCovered = true;
|
||||||
} else if (coverage.overageRate < 1.0) {
|
} else if (coverage.overageRate < 1.0) {
|
||||||
costCents = Math.max(1, Math.ceil(costCents * coverage.overageRate));
|
costCents = Math.max(1, Math.ceil(costCents * coverage.overageRate));
|
||||||
}
|
}
|
||||||
@@ -1479,8 +1484,8 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
|
|
||||||
await conn.query(
|
await conn.query(
|
||||||
`INSERT INTO h5_usage_records
|
`INSERT INTO h5_usage_records
|
||||||
(user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, created_at)
|
(user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, billing_source, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'wallet', ?)`,
|
||||||
[
|
[
|
||||||
userId,
|
userId,
|
||||||
agentSessionId,
|
agentSessionId,
|
||||||
@@ -1521,6 +1526,28 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
} else if (subscriptionCovered && deltaTokens > 0) {
|
||||||
|
const [walletRows] = await conn.query(
|
||||||
|
`SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`,
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
|
balanceAfter = walletRows[0] ? Number(walletRows[0].balance_cents) : 0;
|
||||||
|
tokensUsedAfter = walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null;
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO h5_usage_records
|
||||||
|
(user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, billing_source, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, ?, 'subscription', ?)`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
agentSessionId,
|
||||||
|
normalizedRequestId,
|
||||||
|
deltaIn,
|
||||||
|
deltaOut,
|
||||||
|
balanceAfter,
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const user = await getUserById(userId);
|
const user = await getUserById(userId);
|
||||||
balanceAfter = user ? Number(user.balance_cents) : null;
|
balanceAfter = user ? Number(user.balance_cents) : null;
|
||||||
@@ -1553,12 +1580,12 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
if (userId) params.push(userId);
|
if (userId) params.push(userId);
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
|
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
|
||||||
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
|
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.billing_source, r.created_at
|
||||||
FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
|
FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
|
||||||
${where} ORDER BY r.created_at DESC LIMIT ${safeLimit}`,
|
${where} ORDER BY r.created_at DESC LIMIT ${safeLimit}`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
return rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) }));
|
return rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), billingSource: row.billing_source ?? 'wallet', createdAt: Number(row.created_at) }));
|
||||||
}
|
}
|
||||||
const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
|
const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
|
||||||
const safePage = Math.max(Number(page) || 1, 1);
|
const safePage = Math.max(Number(page) || 1, 1);
|
||||||
@@ -1572,7 +1599,7 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
);
|
);
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
|
`SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id,
|
||||||
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
|
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.billing_source, r.created_at
|
||||||
FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
|
FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id
|
||||||
${where}
|
${where}
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
@@ -1580,7 +1607,7 @@ export function createUserAuth(pool, options = {}) {
|
|||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
records: rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) })),
|
records: rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), billingSource: row.billing_source ?? 'wallet', createdAt: Number(row.created_at) })),
|
||||||
total: Number(total),
|
total: Number(total),
|
||||||
page: safePage,
|
page: safePage,
|
||||||
pageSize: safePageSize,
|
pageSize: safePageSize,
|
||||||
|
|||||||
@@ -770,6 +770,114 @@ test('billSessionUsage auto gifts low-balance bonus once for eligible new users'
|
|||||||
assert.deepEqual(notificationTypes, ['low_balance_gift']);
|
assert.deepEqual(notificationTypes, ['low_balance_gift']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('billSessionUsage writes usage record when subscription fully covers tokens', async () => {
|
||||||
|
const userRow = {
|
||||||
|
id: 'user-sub-1',
|
||||||
|
username: 'pro_user',
|
||||||
|
slug: 'pro_user',
|
||||||
|
email: 'pro@example.com',
|
||||||
|
display_name: 'Pro User',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
plan_type: 'pro',
|
||||||
|
workspace_root: '/tmp/pro-user',
|
||||||
|
balance_cents: 200,
|
||||||
|
tokens_used: 0,
|
||||||
|
spent_cents: 0,
|
||||||
|
};
|
||||||
|
const stateBySession = new Map();
|
||||||
|
let walletBalance = 200;
|
||||||
|
let tokensUsed = 0;
|
||||||
|
const usageRecords = [];
|
||||||
|
let ledgerCount = 0;
|
||||||
|
let consumeQuotaCalls = 0;
|
||||||
|
|
||||||
|
const subscriptionService = {
|
||||||
|
async consumeQuota(userId, deltaTokens) {
|
||||||
|
consumeQuotaCalls += 1;
|
||||||
|
assert.equal(userId, userRow.id);
|
||||||
|
assert.equal(deltaTokens, 15_000);
|
||||||
|
return { fullyCovers: true, overageRate: 0.5 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) {
|
||||||
|
return [[{ ...userRow, balance_cents: walletBalance, tokens_used: tokensUsed }]];
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected pool query: ${sql}`);
|
||||||
|
},
|
||||||
|
async getConnection() {
|
||||||
|
return {
|
||||||
|
async beginTransaction() {},
|
||||||
|
async commit() {},
|
||||||
|
async rollback() {},
|
||||||
|
release() {},
|
||||||
|
async query(sql, params = []) {
|
||||||
|
if (sql.includes('SELECT cost_cents FROM h5_usage_records WHERE request_id = ? LIMIT 1')) return [[]];
|
||||||
|
if (sql.includes('INSERT INTO h5_session_billing_state') && sql.includes('agent_session_id = agent_session_id')) {
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM h5_session_billing_state') && sql.includes('FOR UPDATE')) {
|
||||||
|
const row = stateBySession.get(params[0]);
|
||||||
|
return [row ? [row] : []];
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO h5_session_billing_state') && sql.includes('ON DUPLICATE KEY UPDATE')) {
|
||||||
|
stateBySession.set(params[0], {
|
||||||
|
last_accumulated_cost: params[2],
|
||||||
|
last_input_tokens: params[3],
|
||||||
|
last_output_tokens: params[4],
|
||||||
|
});
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
if (sql.includes('SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?')) {
|
||||||
|
return [[{ balance_cents: walletBalance, tokens_used: tokensUsed }]];
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO h5_usage_records')) {
|
||||||
|
const subscription = sql.includes("'subscription'");
|
||||||
|
usageRecords.push({
|
||||||
|
user_id: params[0],
|
||||||
|
agent_session_id: params[1],
|
||||||
|
request_id: params[2],
|
||||||
|
input_tokens: params[3],
|
||||||
|
output_tokens: params[4],
|
||||||
|
cost_cents: subscription ? 0 : params[5],
|
||||||
|
balance_after_cents: subscription ? params[5] : params[6],
|
||||||
|
billing_source: subscription ? 'subscription' : 'wallet',
|
||||||
|
});
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
if (sql.includes("INSERT INTO h5_billing_ledger") && sql.includes("'deduct'")) {
|
||||||
|
ledgerCount += 1;
|
||||||
|
return [{ affectedRows: 1 }, []];
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected connection query: ${sql}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const auth = createUserAuth(pool, { persistSessions: false, subscriptionService });
|
||||||
|
const result = await auth.billSessionUsage(
|
||||||
|
userRow.id,
|
||||||
|
'session-sub-1',
|
||||||
|
{ accumulatedOutputTokens: 15_000 },
|
||||||
|
'req-sub-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.costCents, 0);
|
||||||
|
assert.equal(result.balanceCents, 200);
|
||||||
|
assert.equal(consumeQuotaCalls, 1);
|
||||||
|
assert.equal(ledgerCount, 0);
|
||||||
|
assert.equal(usageRecords.length, 1);
|
||||||
|
assert.equal(usageRecords[0].cost_cents, 0);
|
||||||
|
assert.equal(usageRecords[0].billing_source, 'subscription');
|
||||||
|
assert.equal(usageRecords[0].output_tokens, 15_000);
|
||||||
|
assert.equal(walletBalance, 200);
|
||||||
|
});
|
||||||
|
|
||||||
test('updateUser rejects quota smaller than occupied bytes', async () => {
|
test('updateUser rejects quota smaller than occupied bytes', async () => {
|
||||||
const userRow = {
|
const userRow = {
|
||||||
id: 'user-3',
|
id: 'user-3',
|
||||||
|
|||||||
@@ -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)),
|
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)),
|
maxFileBytes: Math.max(1, Number(env.H5_WECHAT_MP_MAX_FILE_BYTES ?? 30 * 1024 * 1024)),
|
||||||
acceptVoice: env.H5_WECHAT_MP_ACCEPT_VOICE !== '0',
|
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',
|
acceptImage: env.H5_WECHAT_MP_ACCEPT_IMAGE !== '0',
|
||||||
acceptFile: env.H5_WECHAT_MP_ACCEPT_FILE !== '0',
|
acceptFile: env.H5_WECHAT_MP_ACCEPT_FILE !== '0',
|
||||||
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
acceptLocation: env.H5_WECHAT_MP_ACCEPT_LOCATION !== '0',
|
||||||
|
|||||||
+33
-8
@@ -12,6 +12,10 @@ import {
|
|||||||
persistWechatImage,
|
persistWechatImage,
|
||||||
uploadWechatGeneratedImage,
|
uploadWechatGeneratedImage,
|
||||||
} from './wechat-media.mjs';
|
} from './wechat-media.mjs';
|
||||||
|
import {
|
||||||
|
buildWechatVoiceRecoVoiceId,
|
||||||
|
transcribeWechatVoiceViaRecoApi,
|
||||||
|
} from './wechat-voice-reco.mjs';
|
||||||
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
|
import { normalizeWechatName, resolveWechatAddressName } from './wechat/user/display-name.mjs';
|
||||||
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
import { buildAckText } from './wechat/ack/ack-provider.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -1624,6 +1628,9 @@ export function createWechatMpService({
|
|||||||
requireFreshPageThumbnail,
|
requireFreshPageThumbnail,
|
||||||
repairFreshPageThumbnail,
|
repairFreshPageThumbnail,
|
||||||
asrTarget: config.asrTarget || DEFAULT_ASR_TARGET,
|
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 });
|
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 '';
|
if (!mediaId) return '';
|
||||||
const accessToken = await getStableAccessToken();
|
const accessToken = await getStableAccessToken();
|
||||||
const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch });
|
const downloaded = await downloadTemporaryMedia(accessToken, mediaId, { wechatFetch });
|
||||||
if (!downloaded.buffer?.length) return '';
|
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 extension = String(format ?? '').trim().toLowerCase() || 'amr';
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append(
|
form.append(
|
||||||
@@ -2584,10 +2609,7 @@ export function createWechatMpService({
|
|||||||
artifacts,
|
artifacts,
|
||||||
forcePageData,
|
forcePageData,
|
||||||
}) => {
|
}) => {
|
||||||
const enabledForUser =
|
if (!config.pageDataAiderReviewEnabled) return { action: 'skip', reason: 'disabled' };
|
||||||
config.pageDataAiderReviewEnabled &&
|
|
||||||
isWechatMediaGrayUser(user, config.pageDataAiderReviewUsers);
|
|
||||||
if (!enabledForUser) return { action: 'skip', reason: 'disabled' };
|
|
||||||
if (typeof pageDataDeliveryReviewer?.reviewIfNeeded !== 'function') {
|
if (typeof pageDataDeliveryReviewer?.reviewIfNeeded !== 'function') {
|
||||||
const error = new Error('微信 Page Data 强制 Aider 审核服务不可用');
|
const error = new Error('微信 Page Data 强制 Aider 审核服务不可用');
|
||||||
error.code = 'PAGE_DATA_REVIEW_UNAVAILABLE';
|
error.code = 'PAGE_DATA_REVIEW_UNAVAILABLE';
|
||||||
@@ -2617,8 +2639,7 @@ export function createWechatMpService({
|
|||||||
const runIntentMessage = async ({ inbound, intent, user }) => {
|
const runIntentMessage = async ({ inbound, intent, user }) => {
|
||||||
const wechatIntent = await resolveWechatIntent(intent, { openid: inbound.fromUserName });
|
const wechatIntent = await resolveWechatIntent(intent, { openid: inbound.fromUserName });
|
||||||
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
|
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
|
||||||
const reliabilityEnabled = isWechatMediaGrayUser(user, config.reliabilityGrayUsers);
|
const agentReplyTimeoutMs = config.agentReplyTimeoutMs;
|
||||||
const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0;
|
|
||||||
const resetCandidate =
|
const resetCandidate =
|
||||||
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
|
||||||
const isPageDataRequest = isWechatPageDataTask(resetCandidate);
|
const isPageDataRequest = isWechatPageDataTask(resetCandidate);
|
||||||
@@ -3581,7 +3602,11 @@ export function createWechatMpService({
|
|||||||
|
|
||||||
if (intent.msgType === 'voice' && !intent.agentText.trim() && intent.media?.mediaId) {
|
if (intent.msgType === 'voice' && !intent.agentText.trim() && intent.media?.mediaId) {
|
||||||
try {
|
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) {
|
if (fallbackText) {
|
||||||
intent.agentText = fallbackText;
|
intent.agentText = fallbackText;
|
||||||
intent.displayText = `语音:${fallbackText}`;
|
intent.displayText = `语音:${fallbackText}`;
|
||||||
|
|||||||
@@ -4444,6 +4444,7 @@ test('wechat mp service falls back to ASR when voice recognition is empty', asyn
|
|||||||
unsupportedText: 'unsupported',
|
unsupportedText: 'unsupported',
|
||||||
unboundTextPrefix: '请先绑定',
|
unboundTextPrefix: '请先绑定',
|
||||||
asrTarget: 'https://asr.example.com',
|
asrTarget: 'https://asr.example.com',
|
||||||
|
wechatVoiceRecoApiEnabled: false,
|
||||||
},
|
},
|
||||||
userAuth: {
|
userAuth: {
|
||||||
async findWechatUserByOpenid() {
|
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 () => {
|
test('wechat mp wildcard media access persists image and routes image url into agent prompt', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
}
|
||||||
@@ -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, '你好');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user