diff --git a/billing-token-state.mjs b/billing-token-state.mjs index 5408fef..e5d6610 100644 --- a/billing-token-state.mjs +++ b/billing-token-state.mjs @@ -1,4 +1,5 @@ import { normalizeTokenState } from './billing.mjs'; +import { fetchGooseSessionAccumulatedCostUsd } from './goose-session-cost.mjs'; export function loadCostEstimateConfig(env = process.env) { const useBackendCost = env.H5_USE_BACKEND_COST === '1'; @@ -56,22 +57,44 @@ export function enrichTokenStateForBilling( return state; } +async function resolveSessionCostPayload(sessionId, fetchSession, fetchSessionCostFromPg, env) { + let sessionCost = null; + if (typeof fetchSession === 'function' && sessionId) { + try { + sessionCost = await fetchSession(sessionId); + } catch { + sessionCost = null; + } + } + if (pickSessionAccumulatedCost(sessionCost) != null) { + return sessionCost; + } + + const readPgCost = + typeof fetchSessionCostFromPg === 'function' + ? fetchSessionCostFromPg + : (sid) => fetchGooseSessionAccumulatedCostUsd(sid, env); + const pgUsd = env.H5_USE_BACKEND_COST === '1' ? await readPgCost(sessionId) : null; + if (pgUsd != null) { + return { + ...(sessionCost && typeof sessionCost === 'object' ? sessionCost : {}), + accumulated_cost: pgUsd, + }; + } + return sessionCost; +} + export async function resolveBillingTokenState( tokenStateRaw, - { sessionId = null, fetchSession = null } = {}, + { sessionId = null, fetchSession = null, fetchSessionCostFromPg = null } = {}, env = process.env, ) { const state = normalizeTokenState(tokenStateRaw); - - if (typeof fetchSession === 'function' && sessionId) { - try { - const session = await fetchSession(sessionId); - const enriched = enrichTokenStateForBilling(state, { sessionCost: session }, env); - if (enriched.accumulatedCost != null) return enriched; - } catch { - // Best-effort: fall through to inline cost / token estimate. - } - } - - return enrichTokenStateForBilling(state, {}, env); + const sessionCost = await resolveSessionCostPayload( + sessionId, + fetchSession, + fetchSessionCostFromPg, + env, + ); + return enrichTokenStateForBilling(state, { sessionCost }, env); } diff --git a/billing-token-state.test.mjs b/billing-token-state.test.mjs index 7c437ed..4ef7035 100644 --- a/billing-token-state.test.mjs +++ b/billing-token-state.test.mjs @@ -103,6 +103,23 @@ test('resolveBillingTokenState replaces inflated Finish cost with session cost', assert.equal(resolved.accumulatedCost, 0.006738208); }); +test('resolveBillingTokenState uses PG cost when goosed session API omits accumulated_cost', async () => { + const resolved = await resolveBillingTokenState( + { + accumulatedInputTokens: 280030, + accumulatedOutputTokens: 5797, + accumulatedCost: 0.082, + }, + { + sessionId: '20260804_19', + fetchSession: async () => ({ id: '20260804_19', accumulated_cost: null }), + fetchSessionCostFromPg: async () => 0.006738208, + }, + { H5_USE_BACKEND_COST: '1' }, + ); + assert.equal(resolved.accumulatedCost, 0.006738208); +}); + test('enriched cost drives 1.2x billing instead of flat fallback', () => { const previous = { lastInputTokens: 224853, lastOutputTokens: 6685, lastAccumulatedCost: null }; const tokenState = enrichTokenStateForBilling( diff --git a/goose-session-cost.mjs b/goose-session-cost.mjs new file mode 100644 index 0000000..06261e1 --- /dev/null +++ b/goose-session-cost.mjs @@ -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; + } +} diff --git a/goose-session-cost.test.mjs b/goose-session-cost.test.mjs new file mode 100644 index 0000000..be55cbc --- /dev/null +++ b/goose-session-cost.test.mjs @@ -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); +}); diff --git a/mindspace-analytics.mjs b/mindspace-analytics.mjs index 9b85f8f..969949d 100644 --- a/mindspace-analytics.mjs +++ b/mindspace-analytics.mjs @@ -68,6 +68,20 @@ export function resolveAnalyticsOwnerLabel(user = {}) { return label.replace(/[\r\n\t]+/g, ' ').slice(0, 80) || '未命名用户'; } +export function buildViewerAnalyticsIdentity(viewer, config = {}) { + if (!viewer?.id) return null; + const distinctId = resolveAnalyticsIdentity(viewer.id, config); + if (!distinctId) return null; + return { + distinctId, + username: resolveAnalyticsOwnerLabel(viewer), + ownerSegment: resolveAnalyticsOwnerSegment(viewer), + planType: resolveAnalyticsPlan(viewer), + channel: 'public', + identityMode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous', + }; +} + export function buildProductAnalyticsContext({ config, user = null } = {}) { if (!config?.enabled || !config.websiteId) return { enabled: false }; const distinctId = user?.id ? resolveAnalyticsIdentity(user.id, config) : ''; @@ -152,6 +166,7 @@ export function injectMindSpaceAnalytics(html, { planType = 'unknown', generatedAt = '', channel = 'h5', + viewerIdentity = null, config = resolveMindSpaceAnalyticsConfig(), } = {}) { const source = String(html ?? ''); @@ -167,7 +182,8 @@ export function injectMindSpaceAnalytics(html, { `data-host-url="${config.hostPath}"`, ]; if (config.domains) attrs.push(`data-domains="${config.domains.replaceAll('"', '"')}"`); - const block = ``; + const viewerJson = viewerIdentity ? jsonForInlineScript(viewerIdentity) : 'null'; + const block = ``; if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}`); return source.replace(/ { assert.match(out, /src="\/analytics\/script\.js"/); assert.match(out, /data-host-url="\/analytics"/); assert.match(out, /data-auto-track="false"/); - assert.match(out, /window\.umami\.identify\(d\.owner_id,\{username:d\.username,memind_page_url:location\.href,owner_segment:d\.owner_segment,plan_type:d\.plan_type,channel:d\.channel,surface:d\.surface,identity_mode:d\.identity_mode\}\)/); + assert.doesNotMatch(out, /umami\.identify\(d\.owner_id/); + assert.match(out, /function identifyViewer\(\)/); + assert.match(out, /identifyViewer\(\);pageview\(\)/); assert.match(out, /function pageview\(\).*window\.umami\.track\(\)/); - assert.ok(out.indexOf('identify();pageview();') > 0); assert.doesNotMatch(out, /t\('page_view'\)/); assert.match(out, /page_id/); assert.match(out, /owner_segment/); @@ -143,7 +145,7 @@ test('injects one local same-origin tracker with page dimensions', () => { assert.equal(injectMindSpaceAnalytics(out, { ownerId: 'user-123', config: { enabled: true, websiteId: 'local-website', idSecret: 'secret' } }), out); }); -test('identifies the pseudonymous owner before sending a standard page view', () => { +test('public visitors skip creator identify and only send a standard page view', () => { const out = injectMindSpaceAnalytics('', { ownerId: 'user-123', ownerSegment: 'plan:pro', @@ -177,22 +179,67 @@ test('identifies the pseudonymous owner before sending a standard page view', () documentElement: { scrollHeight: 1600 }, addEventListener: () => {}, }, - location: { href: 'https://m.tkmind.cn/MindSpace/demo/public/page.html' }, + location: { href: 'https://m.tkmind.cn/MindSpace/demo/public/page.html', pathname: '/MindSpace/demo/public/page.html' }, + setTimeout: () => {}, + }); + + assert.deepEqual(JSON.parse(JSON.stringify(calls)), [['track']]); +}); + +test('logged-in public visitors identify themselves without using the page creator id', () => { + const viewerId = pseudonymizeAnalyticsId('viewer-456', 'secret'); + const out = injectMindSpaceAnalytics('', { + 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(/