thread-master/apps/web/src/lib/usageMeter.ts

939 lines
26 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
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";
2026-07-13 01:15:30 +00:00
/** platform = 平台代付扣點byok = 自備 key 只計次數 */
export type UsageKeyMode = "platform" | "byok";
2026-07-10 05:10:31 +00:00
export type UsageEvent = {
id: string;
/** 使用者 uid舊資料可能缺會歸到 unknown */
uid: string;
meter: UsageMeter;
2026-07-13 01:15:30 +00:00
/** 消耗點數byok 固定 0 */
2026-07-10 05:10:31 +00:00
credits: number;
2026-07-13 01:15:30 +00:00
/** 平台 / 自備 key */
key_mode: UsageKeyMode;
2026-07-10 05:10:31 +00:00
/** 使用者可讀說明 */
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;
2026-07-13 03:18:08 +00:00
/** 本月 platform 總點數配給 */
2026-07-10 05:10:31 +00:00
monthly_credits: number;
2026-07-13 03:18:08 +00:00
/**
*
* monthly_credits
*/
2026-07-10 05:10:31 +00:00
soft_caps: Record<UsageMeter, number>;
};
/** 每人方案/是否不擋額度(用量一律計算) */
export type UsageMemberPrefs = {
plan_id: PlanId;
/** 達方案上限仍可繼續用AISearch點數照樣記帳 */
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",
},
};
2026-07-13 03:18:08 +00:00
/**
* 2026-07 ·
*
*
* - Free Starter
* - Starter
* - Pro
* - key BYOK
*
* platformUSD
* - ~$0.0040.008 · Exa ~$0.007 · ~$0.015 · ~$0.020.04
* - **$0.012**TWD32 Starter $18.4 / Pro $62.2
*
* 50%Free
* 5 Free 滿 + 2 Starter 滿 + 1 Pro 滿
* COGS 5×$1.44 + 2×$7.2 + $24 = $45.6 · $99 ~54%
*
* soft_caps = = monthly_credits
*/
2026-07-10 05:10:31 +00:00
export const PLANS: Record<PlanId, PlanDef> = {
free: {
id: "free",
name: "Free",
price_label: "NT$0月",
price_twd: 0,
2026-07-13 03:18:08 +00:00
blurb: "夠用試用,體驗完整創作流程",
2026-07-10 05:10:31 +00:00
blurb_key: "usage.plan.free.blurb",
2026-07-13 03:18:08 +00:00
// 獲客可虧:滿用 ~$1.44;約 23 週輕度日常,用得出價值再升級
monthly_credits: 120,
2026-07-10 05:10:31 +00:00
soft_caps: {
2026-07-13 03:18:08 +00:00
ai_copy: 60, // 60 次文案/回覆
ai_research: 15, // 5 次研究
web_search: 30, // 30 次搜尋
ai_image: 15, // 3 張生圖
}, // sum = 120
2026-07-10 05:10:31 +00:00
},
starter: {
id: "starter",
name: "Starter",
price_label: "NT$590月",
price_twd: 590,
blurb: "小團隊日常發文與海巡",
blurb_key: "usage.plan.starter.blurb",
2026-07-13 03:18:08 +00:00
// 滿用 COGS ≤ $7.2 → 單方案毛利 ~61%;轉付費主力
// 約當:文案 300 · 研究 30 · 搜尋 150 · 生圖 12
monthly_credits: 600,
2026-07-10 05:10:31 +00:00
soft_caps: {
2026-07-13 03:18:08 +00:00
ai_copy: 300,
ai_research: 90,
web_search: 150,
ai_image: 60,
}, // sum = 600
2026-07-10 05:10:31 +00:00
},
pro: {
id: "pro",
name: "Pro",
price_label: "NT$1,990月",
price_twd: 1990,
blurb: "多帳、重度 AI 與研究",
blurb_key: "usage.plan.pro.blurb",
2026-07-13 03:18:08 +00:00
// 滿用 COGS ≤ $24 → 單方案毛利 ~61%;重度天花板
// 約當:文案 1000 · 研究 100 · 搜尋 500 · 生圖 40
2026-07-10 05:10:31 +00:00
monthly_credits: 2000,
soft_caps: {
ai_copy: 1000,
2026-07-13 03:18:08 +00:00
ai_research: 300,
web_search: 500,
ai_image: 200,
}, // sum = 2000
2026-07-10 05:10:31 +00:00
},
};
2026-07-13 03:18:08 +00:00
/** 記帳點數platform與後端 DefaultCreditCost 對齊 */
2026-07-10 05:10:31 +00:00
export const DEFAULT_CREDIT_COST: Record<UsageMeter, number> = {
ai_copy: 1,
ai_research: 3,
web_search: 1,
ai_image: 5,
};
export type UsageMonthSummary = {
month_key: string;
uid: string;
plan: PlanDef;
unlimited: boolean;
2026-07-13 03:18:08 +00:00
/**
* platform
* plan.monthly_credits platform.credits_total
*/
2026-07-10 05:10:31 +00:00
total_credits: number;
remaining_credits: number;
2026-07-13 03:18:08 +00:00
/** 已用 / 方案配給 百分比platform only */
2026-07-10 05:10:31 +00:00
pct: number;
2026-07-13 01:15:30 +00:00
/** @deprecated 平台 AI 次數;請用 platform / byok 分欄 */
2026-07-10 05:10:31 +00:00
ai_calls: number;
2026-07-13 01:15:30 +00:00
/** @deprecated 平台搜尋次數 */
2026-07-10 05:10:31 +00:00
search_calls: number;
by_meter: Record<
UsageMeter,
{ credits: number; count: number; soft_cap: number; pct: number }
>;
events: UsageEvent[];
2026-07-13 01:15:30 +00:00
/** 平台代付:點數 + 次數 */
platform: {
credits_used: number;
credits_remaining: number;
credits_total: number;
call_count: number;
};
/** BYOK僅次數不進點數條 */
byok: {
call_count: number;
};
2026-07-10 05:10:31 +00:00
};
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<UsageMeter, { credits: number; count: number }>;
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<UsageMeter, { credits: number; count: number }>;
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<string, UsageMemberPrefs>;
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<string>(KEYS.usagePlanId, "free");
return { plan_id: normalizePlanId(legacy), unlimited: false };
}
export function setMemberPrefs(
uid: string,
patch: Partial<UsageMemberPrefs>,
): 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;
2026-07-13 01:15:30 +00:00
/** 預設 platformmock 記點byok 時 credits 強制 0 */
key_mode?: UsageKeyMode;
2026-07-10 05:10:31 +00:00
}): UsageEvent {
const uid = opts.uid || sessionUid();
2026-07-13 01:15:30 +00:00
const key_mode: UsageKeyMode = opts.key_mode ?? "platform";
let credits =
2026-07-10 05:10:31 +00:00
opts.credits != null && opts.credits > 0
? opts.credits
: DEFAULT_CREDIT_COST[opts.meter];
2026-07-13 01:15:30 +00:00
if (key_mode === "byok") credits = 0;
2026-07-10 05:10:31 +00:00
const ev: UsageEvent = {
id: newId("use"),
uid,
meter: opts.meter,
credits,
2026-07-13 01:15:30 +00:00
key_mode,
2026-07-10 05:10:31 +00:00
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,
2026-07-13 01:15:30 +00:00
| "total_credits"
| "remaining_credits"
| "pct"
| "by_meter"
| "ai_calls"
| "search_calls"
| "platform"
| "byok"
2026-07-10 05:10:31 +00:00
> {
2026-07-13 01:15:30 +00:00
// 平台點數只加 key_mode=platformBYOK 只計次數
2026-07-10 05:10:31 +00:00
const by_meter = emptyByMeter(plan);
2026-07-13 01:15:30 +00:00
let platformCredits = 0;
let platformCalls = 0;
let byokCalls = 0;
2026-07-10 05:10:31 +00:00
for (const e of events) {
2026-07-13 01:15:30 +00:00
const mode = e.key_mode ?? "platform";
if (mode === "byok") {
byokCalls += 1;
continue;
}
platformCredits += e.credits;
platformCalls += 1;
2026-07-10 05:10:31 +00:00
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];
2026-07-13 03:18:08 +00:00
// soft_cap 是點數上限,進度用已用點數
2026-07-10 05:10:31 +00:00
row.pct =
2026-07-13 03:18:08 +00:00
row.soft_cap > 0 ? Math.min(999, Math.round((row.credits / row.soft_cap) * 100)) : 0;
2026-07-10 05:10:31 +00:00
}
2026-07-13 01:15:30 +00:00
const remaining = Math.max(0, plan.monthly_credits - platformCredits);
2026-07-10 05:10:31 +00:00
const pct =
plan.monthly_credits > 0
2026-07-13 01:15:30 +00:00
? Math.min(999, Math.round((platformCredits / plan.monthly_credits) * 100))
2026-07-10 05:10:31 +00:00
: 0;
2026-07-13 03:18:08 +00:00
// 狀態列用「點數」加總,與方案配給同一單位
2026-07-10 05:10:31 +00:00
let ai_calls = 0;
let search_calls = 0;
for (const m of Object.keys(METER_META) as UsageMeter[]) {
2026-07-13 03:18:08 +00:00
if (METER_META[m].group === "ai") ai_calls += by_meter[m].credits;
else search_calls += by_meter[m].credits;
2026-07-10 05:10:31 +00:00
}
return {
2026-07-13 03:18:08 +00:00
// 與 UIwidget 一致total_credits = 已用
total_credits: platformCredits,
2026-07-10 05:10:31 +00:00
remaining_credits: remaining,
pct,
by_meter,
ai_calls,
search_calls,
2026-07-13 01:15:30 +00:00
platform: {
credits_used: platformCredits,
credits_remaining: remaining,
credits_total: plan.monthly_credits,
call_count: platformCalls,
},
byok: { call_count: byokCalls },
2026-07-10 05:10:31 +00:00
};
}
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<UsageMeter, string> = {
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;
2026-07-13 01:15:30 +00:00
const key_mode: UsageKeyMode = dayAgo % 5 === 0 ? "byok" : "platform";
2026-07-10 05:10:31 +00:00
seeded.push({
id: newId("use"),
uid,
meter,
2026-07-13 01:15:30 +00:00
credits: key_mode === "byok" ? 0 : DEFAULT_CREDIT_COST[meter] * (1 + (dayAgo % 3)),
key_mode,
2026-07-10 05:10:31 +00:00
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,
};
}
2026-07-13 03:18:08 +00:00
type WarnTFn = (key: string, params?: Record<string, string | number>) => string;
/** 用量警示(需傳入 t避免硬編碼語系 */
export function usageWarning(summary: UsageMonthSummary, t?: WarnTFn): string | null {
const used = summary.platform?.credits_used ?? summary.total_credits;
// 上限永遠跟方案表,避免舊 API 的 credits_total 造成 120 文案 / 80 上限
const cap = summary.plan.monthly_credits;
const pct =
summary.pct > 0
? summary.pct
: cap > 0
? Math.round((used / cap) * 100)
: 0;
const tr =
t ??
((key: string, params?: Record<string, string | number>) => {
// fallback 中文(測試/無 i18n 時)
const fb: Record<string, string> = {
"usage.warn.unlimitedOver": `本月已用 ${params?.used} 點(方案 ${params?.cap},不擋額度,仍可繼續)。`,
"usage.warn.exhausted": "本月點數已用完。可升級方案或等待下月重置。",
"usage.warn.high": `本月已用 ${params?.pct}% 點數。`,
"usage.warn.meterNear": `${params?.label} 接近單項上限(${params?.credits}/${params?.cap} 點)。`,
};
return fb[key] ?? key;
});
2026-07-10 05:10:31 +00:00
// 不擋額度:只提示已超出,不說「用完」
if (summary.unlimited) {
2026-07-13 03:18:08 +00:00
if (used > cap) {
return tr("usage.warn.unlimitedOver", { used, cap });
2026-07-10 05:10:31 +00:00
}
return null;
}
2026-07-13 03:18:08 +00:00
if (pct >= 100) {
return tr("usage.warn.exhausted");
2026-07-10 05:10:31 +00:00
}
2026-07-13 03:18:08 +00:00
if (pct >= 80) {
return tr("usage.warn.high", { pct });
2026-07-10 05:10:31 +00:00
}
for (const m of Object.keys(METER_META) as UsageMeter[]) {
const row = summary.by_meter[m];
if (row.pct >= 90) {
2026-07-13 03:18:08 +00:00
return tr("usage.warn.meterNear", {
label: t ? t(`usage.meter.${m}`) : METER_META[m].label,
credits: row.credits,
cap: row.soft_cap,
});
2026-07-10 05:10:31 +00:00
}
}
return null;
}