import { KEYS } from "../data/mock/keys"; import { getSession } from "../data/mock/store"; import { newId } from "./id"; import { readJson, writeJson } from "./storage"; import { nowUnixNano } from "./time"; /** 計費維度(對齊未來付費方案) */ export type UsageMeter = "ai_copy" | "ai_research" | "web_search" | "ai_image"; export type PlanId = "free" | "starter" | "pro"; /** platform = 平台代付扣點;byok = 自備 key 只計次數 */ export type UsageKeyMode = "platform" | "byok"; export type UsageEvent = { id: string; /** 使用者 uid(舊資料可能缺,會歸到 unknown) */ uid: string; meter: UsageMeter; /** 消耗點數(byok 固定 0) */ credits: number; /** 平台 / 自備 key */ key_mode: UsageKeyMode; /** 使用者可讀說明 */ label: string; /** 功能來源,除錯/對帳 */ source: string; /** unix nanoseconds UTC */ created_at: number; }; export type PlanDef = { id: PlanId; name: string; price_label: string; price_twd: number; blurb: string; blurb_key: string; monthly_credits: number; soft_caps: Record; }; /** 每人方案/是否不擋額度(用量一律計算) */ export type UsageMemberPrefs = { plan_id: PlanId; /** 達方案上限仍可繼續用;AI/Search/點數照樣記帳 */ unlimited: boolean; }; export const METER_META: Record< UsageMeter, { label: string; hint: string; order: number; group: "ai" | "search" } > = { ai_copy: { label: "AI 文案", hint: "回覆草稿、靈感聊天、仿寫、結構分析", order: 0, group: "ai", }, ai_research: { label: "AI 研究", hint: "海巡周邊知識、上網功課", order: 1, group: "ai", }, web_search: { label: "搜尋", hint: "熱點刷新、關鍵字搜、研究檢索", order: 2, group: "search", }, ai_image: { label: "AI 生圖", hint: "文生圖/配圖", order: 3, group: "ai", }, }; export const PLANS: Record = { free: { id: "free", name: "Free", price_label: "NT$0/月", price_twd: 0, blurb: "試用與個人輕量經營", blurb_key: "usage.plan.free.blurb", monthly_credits: 80, soft_caps: { ai_copy: 40, ai_research: 8, web_search: 30, ai_image: 3, }, }, starter: { id: "starter", name: "Starter", price_label: "NT$590/月", price_twd: 590, blurb: "小團隊日常發文與海巡", blurb_key: "usage.plan.starter.blurb", monthly_credits: 500, soft_caps: { ai_copy: 250, ai_research: 60, web_search: 200, ai_image: 20, }, }, pro: { id: "pro", name: "Pro", price_label: "NT$1,990/月", price_twd: 1990, blurb: "多帳、重度 AI 與研究", blurb_key: "usage.plan.pro.blurb", monthly_credits: 2000, soft_caps: { ai_copy: 1000, ai_research: 250, web_search: 800, ai_image: 80, }, }, }; export const DEFAULT_CREDIT_COST: Record = { ai_copy: 1, ai_research: 3, web_search: 1, ai_image: 5, }; export type UsageMonthSummary = { month_key: string; uid: string; plan: PlanDef; unlimited: boolean; /** 方案點數(僅 platform)— 進度條用 */ total_credits: number; remaining_credits: number; /** 無限時為 0;有上限才算用掉比例(platform only) */ pct: number; /** @deprecated 平台 AI 次數;請用 platform / byok 分欄 */ ai_calls: number; /** @deprecated 平台搜尋次數 */ search_calls: number; by_meter: Record< UsageMeter, { credits: number; count: number; soft_cap: number; pct: number } >; events: UsageEvent[]; /** 平台代付:點數 + 次數 */ platform: { credits_used: number; credits_remaining: number; credits_total: number; call_count: number; }; /** BYOK:僅次數,不進點數條 */ byok: { call_count: number; }; }; export type TenantUsageMemberRow = { uid: string; email: string; display_name: string; unlimited: boolean; plan_id: PlanId; /** 區間內方案配給(購買/額度) */ purchased_credits: number; /** 區間內消耗 */ total_credits: number; ai_calls: number; search_calls: number; remaining_credits: number; pct: number; }; export type TenantUsageSummary = { month_key: string; /** @deprecated 等同 consumed_credits;保留相容 */ total_credits: number; purchased_credits: number; consumed_credits: number; remaining_credits: number; ai_calls: number; search_calls: number; by_meter: Record; members: TenantUsageMemberRow[]; }; /** 圖表時間粒度 */ export type UsageGranularity = "day" | "month" | "year"; export type UsageTimeBucket = { /** day: YYYY-MM-DD · month: YYYY-MM · year: YYYY */ key: string; label: string; purchased_credits: number; consumed_credits: number; ai_calls: number; search_calls: number; }; export type TenantUsageAnalytics = { granularity: UsageGranularity; /** 區間起迄(含)YYYY-MM-DD */ from: string; to: string; range_label: string; purchased_credits: number; consumed_credits: number; remaining_credits: number; pct: number; ai_calls: number; search_calls: number; by_meter: Record; series: UsageTimeBucket[]; members: TenantUsageMemberRow[]; }; export type TenantAnalyticsQuery = { granularity?: UsageGranularity; /** YYYY-MM-DD */ from?: string; /** YYYY-MM-DD */ to?: string; }; function monthKeyFromMs(ms: number): string { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } export function currentMonthKey(now = Date.now()): string { return monthKeyFromMs(now); } function loadEvents(): UsageEvent[] { const raw = readJson<(UsageEvent & { uid?: string })[]>(KEYS.usageEvents, []); return raw.map((e) => ({ ...e, uid: e.uid || "unknown", })); } function saveEvents(list: UsageEvent[]): void { writeJson(KEYS.usageEvents, list.slice(0, 2000)); } type PrefsMap = Record; function loadPrefsMap(): PrefsMap { return readJson(KEYS.usageMemberPrefs, {} as PrefsMap); } function savePrefsMap(map: PrefsMap): void { writeJson(KEYS.usageMemberPrefs, map); } function normalizePlanId(id: unknown): PlanId { if (id === "starter" || id === "pro" || id === "free") return id; return "free"; } export function getMemberPrefs(uid: string): UsageMemberPrefs { const map = loadPrefsMap(); const row = map[uid]; if (row) { return { plan_id: normalizePlanId(row.plan_id), unlimited: row.unlimited === true, }; } // 相容:舊全域方案 const legacy = readJson(KEYS.usagePlanId, "free"); return { plan_id: normalizePlanId(legacy), unlimited: false }; } export function setMemberPrefs( uid: string, patch: Partial, ): UsageMemberPrefs { const map = loadPrefsMap(); const cur = getMemberPrefs(uid); const next: UsageMemberPrefs = { plan_id: patch.plan_id != null ? normalizePlanId(patch.plan_id) : cur.plan_id, unlimited: patch.unlimited != null ? patch.unlimited === true : cur.unlimited, }; map[uid] = next; savePrefsMap(map); return next; } /** @deprecated 改用 getMemberPrefs(uid) */ export function getPlanId(uid?: string): PlanId { if (uid) return getMemberPrefs(uid).plan_id; const s = getSession()?.member?.uid; return getMemberPrefs(s || "unknown").plan_id; } /** @deprecated 改用 setMemberPrefs */ export function setPlanId(id: PlanId, uid?: string): void { const s = uid || getSession()?.member?.uid || "unknown"; setMemberPrefs(s, { plan_id: id }); } function sessionUid(): string { return getSession()?.member?.uid || "unknown"; } /** * 記一筆用量。 * - 任何人(含「不擋額度」)每次呼叫都寫入,永不跳過計算 * - unlimited 不影響是否記帳,只表示超過方案仍可繼續用 */ export function recordUsage(opts: { meter: UsageMeter; label: string; source: string; credits?: number; uid?: string; /** 預設 platform(mock 記點);byok 時 credits 強制 0 */ key_mode?: UsageKeyMode; }): UsageEvent { const uid = opts.uid || sessionUid(); const key_mode: UsageKeyMode = opts.key_mode ?? "platform"; let credits = opts.credits != null && opts.credits > 0 ? opts.credits : DEFAULT_CREDIT_COST[opts.meter]; if (key_mode === "byok") credits = 0; const ev: UsageEvent = { id: newId("use"), uid, meter: opts.meter, credits, key_mode, label: opts.label, source: opts.source, created_at: nowUnixNano(), }; const list = loadEvents(); list.unshift(ev); saveEvents(list); return ev; } export function listUsageEvents(opts?: { limit?: number; uid?: string; monthKey?: string; }): UsageEvent[] { const limit = opts?.limit ?? 100; const monthKey = opts?.monthKey; let list = loadEvents(); if (opts?.uid) list = list.filter((e) => e.uid === opts.uid); if (monthKey) { list = list.filter((e) => { const ms = Math.floor(e.created_at / 1_000_000); return monthKeyFromMs(ms) === monthKey; }); } return list.slice(0, limit); } function emptyByMeter(plan: PlanDef): UsageMonthSummary["by_meter"] { const by_meter = {} as UsageMonthSummary["by_meter"]; for (const m of Object.keys(METER_META) as UsageMeter[]) { by_meter[m] = { credits: 0, count: 0, soft_cap: plan.soft_caps[m], pct: 0, }; } return by_meter; } function aggregateEvents( events: UsageEvent[], plan: PlanDef, _unlimited: boolean, ): Pick< UsageMonthSummary, | "total_credits" | "remaining_credits" | "pct" | "by_meter" | "ai_calls" | "search_calls" | "platform" | "byok" > { // 平台點數只加 key_mode=platform;BYOK 只計次數 const by_meter = emptyByMeter(plan); let platformCredits = 0; let platformCalls = 0; let byokCalls = 0; for (const e of events) { const mode = e.key_mode ?? "platform"; if (mode === "byok") { byokCalls += 1; continue; } platformCredits += e.credits; platformCalls += 1; const row = by_meter[e.meter]; if (row) { row.credits += e.credits; row.count += 1; } } for (const m of Object.keys(by_meter) as UsageMeter[]) { const row = by_meter[m]; row.pct = row.soft_cap > 0 ? Math.min(999, Math.round((row.count / row.soft_cap) * 100)) : 0; } const remaining = Math.max(0, plan.monthly_credits - platformCredits); const pct = plan.monthly_credits > 0 ? Math.min(999, Math.round((platformCredits / plan.monthly_credits) * 100)) : 0; let ai_calls = 0; let search_calls = 0; for (const m of Object.keys(METER_META) as UsageMeter[]) { if (METER_META[m].group === "ai") ai_calls += by_meter[m].count; else search_calls += by_meter[m].count; } return { total_credits: plan.monthly_credits, remaining_credits: remaining, pct, by_meter, ai_calls, search_calls, platform: { credits_used: platformCredits, credits_remaining: remaining, credits_total: plan.monthly_credits, call_count: platformCalls, }, byok: { call_count: byokCalls }, }; } export function summarizeUsage( uid?: string, monthKey = currentMonthKey(), ): UsageMonthSummary { const target = uid || sessionUid(); const prefs = getMemberPrefs(target); const plan = PLANS[prefs.plan_id]; const events = loadEvents().filter((e) => { if (e.uid !== target) return false; const ms = Math.floor(e.created_at / 1_000_000); return monthKeyFromMs(ms) === monthKey; }); const agg = aggregateEvents(events, plan, prefs.unlimited); return { month_key: monthKey, uid: target, plan, unlimited: prefs.unlimited, ...agg, events: events.slice(0, 80), }; } function pad2(n: number): string { return String(n).padStart(2, "0"); } function toDateKey(d: Date): string { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } function parseDateKey(key: string): Date | null { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec((key || "").trim()); if (!m) return null; const y = Number(m[1]); const mo = Number(m[2]); const day = Number(m[3]); if (!y || mo < 1 || mo > 12 || day < 1 || day > 31) return null; const d = new Date(y, mo - 1, day); if (d.getFullYear() !== y || d.getMonth() !== mo - 1 || d.getDate() !== day) return null; return d; } function startOfDay(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } function addDays(d: Date, n: number): Date { const x = new Date(d); x.setDate(x.getDate() + n); return x; } function daysInMonth(y: number, monthIndex0: number): number { return new Date(y, monthIndex0 + 1, 0).getDate(); } function eventDayKey(e: UsageEvent): string { return toDateKey(new Date(Math.floor(e.created_at / 1_000_000))); } function eventMonthKey(e: UsageEvent): string { return monthKeyFromMs(Math.floor(e.created_at / 1_000_000)); } function eventYearKey(e: UsageEvent): string { return String(new Date(Math.floor(e.created_at / 1_000_000)).getFullYear()); } function memberMonthlyQuota(uid: string): number { return PLANS[getMemberPrefs(uid).plan_id].monthly_credits; } /** 區間內「購買/配給」點數:依每人方案月額度 × 涵蓋月數(日視圖按當月天數比例) */ function purchasedInRange( memberUids: string[], from: Date, to: Date, granularity: UsageGranularity, ): number { let total = 0; for (const uid of memberUids) { const monthly = memberMonthlyQuota(uid); if (granularity === "year") { // 逐年:每年 12 個月 × 月額度(區間裁切到涵蓋月) const start = new Date(from.getFullYear(), from.getMonth(), 1); const end = new Date(to.getFullYear(), to.getMonth(), 1); for (let y = start.getFullYear(); y <= end.getFullYear(); y++) { const m0 = y === start.getFullYear() ? start.getMonth() : 0; const m1 = y === end.getFullYear() ? end.getMonth() : 11; total += monthly * (m1 - m0 + 1); } } else if (granularity === "month") { const start = new Date(from.getFullYear(), from.getMonth(), 1); const end = new Date(to.getFullYear(), to.getMonth(), 1); let cur = start; while (cur <= end) { total += monthly; cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1); } } else { // day:每日配給 = 月額度 / 當月天數 let cur = startOfDay(from); const end = startOfDay(to); while (cur <= end) { const dim = daysInMonth(cur.getFullYear(), cur.getMonth()); total += monthly / dim; cur = addDays(cur, 1); } } } return Math.round(total); } function defaultRange(granularity: UsageGranularity): { from: Date; to: Date } { const to = startOfDay(new Date()); if (granularity === "day") { return { from: addDays(to, -29), to }; } if (granularity === "year") { return { from: new Date(to.getFullYear() - 2, 0, 1), to, }; } // month:近 12 個月 return { from: new Date(to.getFullYear(), to.getMonth() - 11, 1), to, }; } function bucketKeys( from: Date, to: Date, granularity: UsageGranularity, ): { key: string; label: string; start: Date; end: Date }[] { const out: { key: string; label: string; start: Date; end: Date }[] = []; if (granularity === "day") { let cur = startOfDay(from); const end = startOfDay(to); while (cur <= end) { const key = toDateKey(cur); out.push({ key, label: `${cur.getMonth() + 1}/${cur.getDate()}`, start: cur, end: cur, }); cur = addDays(cur, 1); } return out; } if (granularity === "month") { let cur = new Date(from.getFullYear(), from.getMonth(), 1); const end = new Date(to.getFullYear(), to.getMonth(), 1); while (cur <= end) { const y = cur.getFullYear(); const m = cur.getMonth(); const key = `${y}-${pad2(m + 1)}`; const last = new Date(y, m + 1, 0); out.push({ key, label: `${y}/${pad2(m + 1)}`, start: cur, end: last, }); cur = new Date(y, m + 1, 1); } return out; } // year for (let y = from.getFullYear(); y <= to.getFullYear(); y++) { out.push({ key: String(y), label: `${y}`, start: new Date(y, 0, 1), end: new Date(y, 11, 31), }); } return out; } function eventInBucket(e: UsageEvent, granularity: UsageGranularity, key: string): boolean { if (granularity === "day") return eventDayKey(e) === key; if (granularity === "month") return eventMonthKey(e) === key; return eventYearKey(e) === key; } function purchasedForBucket( memberUids: string[], bucket: { start: Date; end: Date; key: string }, granularity: UsageGranularity, ): number { return purchasedInRange(memberUids, bucket.start, bucket.end, granularity); } /** * mock:若歷史事件太少,補近半年示意資料,方便圖表預覽。 * 只在 events < 12 時寫入一次。 */ export function ensureDemoUsageHistory( members: { uid: string }[], ): void { const list = loadEvents(); if (list.length >= 12) return; if (!members.length) return; const meters = Object.keys(METER_META) as UsageMeter[]; const labels: Record = { ai_copy: "示意:文案", ai_research: "示意:研究", web_search: "示意:搜尋", ai_image: "示意:生圖", }; const now = Date.now(); const seeded: UsageEvent[] = []; for (let dayAgo = 160; dayAgo >= 0; dayAgo -= 2 + (dayAgo % 3)) { const uid = members[dayAgo % members.length]!.uid; const meter = meters[dayAgo % meters.length]!; const ms = now - dayAgo * 86_400_000 - (dayAgo % 7) * 3_600_000; const key_mode: UsageKeyMode = dayAgo % 5 === 0 ? "byok" : "platform"; seeded.push({ id: newId("use"), uid, meter, credits: key_mode === "byok" ? 0 : DEFAULT_CREDIT_COST[meter] * (1 + (dayAgo % 3)), key_mode, label: labels[meter], source: "demo.seed", created_at: ms * 1_000_000, }); } saveEvents([...seeded, ...list].slice(0, 2000)); } /** 租戶區間分析:購買 vs 消耗、時間序列、每人 */ export function summarizeTenantAnalytics( members: { uid: string; email: string; display_name: string }[], query: TenantAnalyticsQuery = {}, ): TenantUsageAnalytics { const granularity: UsageGranularity = query.granularity === "day" || query.granularity === "year" ? query.granularity : "month"; const defaults = defaultRange(granularity); let from = parseDateKey(query.from || "") || defaults.from; let to = parseDateKey(query.to || "") || defaults.to; if (from > to) { const tmp = from; from = to; to = tmp; } // 限縮避免爆量桶 const maxSpanDays = granularity === "day" ? 93 : granularity === "month" ? 366 * 3 : 366 * 8; if ((startOfDay(to).getTime() - startOfDay(from).getTime()) / 86_400_000 > maxSpanDays) { from = addDays(to, -maxSpanDays); } const fromKey = toDateKey(from); const toKey = toDateKey(to); const uids = members.map((m) => m.uid); const rangeStartMs = startOfDay(from).getTime(); const rangeEndMs = startOfDay(to).getTime() + 86_400_000 - 1; const rangeEvents = loadEvents().filter((e) => { const ms = Math.floor(e.created_at / 1_000_000); return ms >= rangeStartMs && ms <= rangeEndMs; }); const by_meter = {} as TenantUsageAnalytics["by_meter"]; for (const m of Object.keys(METER_META) as UsageMeter[]) { by_meter[m] = { credits: 0, count: 0 }; } let consumed_credits = 0; for (const e of rangeEvents) { consumed_credits += e.credits; const row = by_meter[e.meter]; if (row) { row.credits += e.credits; row.count += 1; } } let ai_calls = 0; let search_calls = 0; for (const m of Object.keys(METER_META) as UsageMeter[]) { if (METER_META[m].group === "ai") ai_calls += by_meter[m].count; else search_calls += by_meter[m].count; } const purchased_credits = purchasedInRange(uids, from, to, granularity); const remaining_credits = Math.max(0, Math.round(purchased_credits - consumed_credits)); const pct = purchased_credits > 0 ? Math.min(999, Math.round((consumed_credits / purchased_credits) * 100)) : 0; const buckets = bucketKeys(from, to, granularity); const series: UsageTimeBucket[] = buckets.map((b) => { const evs = rangeEvents.filter((e) => eventInBucket(e, granularity, b.key)); let c = 0; let ai = 0; let search = 0; for (const e of evs) { c += e.credits; if (METER_META[e.meter]?.group === "ai") ai += 1; else search += 1; } return { key: b.key, label: b.label, purchased_credits: purchasedForBucket(uids, b, granularity), consumed_credits: c, ai_calls: ai, search_calls: search, }; }); const memberRows: TenantUsageMemberRow[] = members.map((m) => { const prefs = getMemberPrefs(m.uid); const plan = PLANS[prefs.plan_id]; const evs = rangeEvents.filter((e) => e.uid === m.uid); let total = 0; let ai = 0; let search = 0; for (const e of evs) { total += e.credits; if (METER_META[e.meter]?.group === "ai") ai += 1; else search += 1; } const purchased = purchasedInRange([m.uid], from, to, granularity); const remaining = Math.max(0, purchased - total); const memberPct = purchased > 0 ? Math.min(999, Math.round((total / purchased) * 100)) : 0; return { uid: m.uid, email: m.email, display_name: m.display_name, unlimited: prefs.unlimited, plan_id: plan.id, purchased_credits: purchased, total_credits: total, ai_calls: ai, search_calls: search, remaining_credits: remaining, pct: memberPct, }; }); memberRows.sort((a, b) => b.total_credits - a.total_credits); const unit = granularity === "day" ? "日" : granularity === "month" ? "月" : "年"; const range_label = `${fromKey} → ${toKey} · 以${unit}檢視`; return { granularity, from: fromKey, to: toKey, range_label, purchased_credits, consumed_credits, remaining_credits, pct, ai_calls, search_calls, by_meter, series, members: memberRows, }; } /** 租戶本月全體:總體 + 每人(相容舊呼叫) */ export function summarizeTenantUsage( members: { uid: string; email: string; display_name: string }[], monthKey = currentMonthKey(), ): TenantUsageSummary { const [y, mo] = monthKey.split("-").map(Number); const from = `${y}-${pad2(mo)}-01`; const last = daysInMonth(y, mo - 1); const to = `${y}-${pad2(mo)}-${pad2(last)}`; const a = summarizeTenantAnalytics(members, { granularity: "month", from, to, }); return { month_key: monthKey, total_credits: a.consumed_credits, purchased_credits: a.purchased_credits, consumed_credits: a.consumed_credits, remaining_credits: a.remaining_credits, ai_calls: a.ai_calls, search_calls: a.search_calls, by_meter: a.by_meter, members: a.members, }; } export function usageWarning(summary: UsageMonthSummary): string | null { // 不擋額度:只提示已超出,不說「用完」 if (summary.unlimited) { if (summary.total_credits > summary.plan.monthly_credits) { return `本月已用 ${summary.total_credits} 點(方案 ${summary.plan.monthly_credits},不擋額度,仍可繼續)。`; } return null; } if (summary.pct >= 100) { return "本月點數已用完。可升級方案或等待下月重置。"; } if (summary.pct >= 80) { return `本月已用 ${summary.pct}% 點數。`; } for (const m of Object.keys(METER_META) as UsageMeter[]) { const row = summary.by_meter[m]; if (row.pct >= 90) { return `${METER_META[m].label} 接近單項上限(${row.count}/${row.soft_cap} 次)。`; } } return null; }