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

962 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/** 本月 platform 總點數配給 */
monthly_credits: number;
/**
* 各分項點數參考上限(單位=點,不是次數)。
* 加總應等於 monthly_credits。
*/
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 · 組合毛利設計)
*
* 產品漏斗:
* - Free夠用、真能體驗完整流程 → 轉 Starter
* - Starter日常付費主力
* - Pro重度多帳
* - 平台 key 限流或無容量時:僅 BYOK 可用(不扣平台點)
*
* 單位成本platformUSD
* - 文案 ~$0.0040.008/次 · 搜尋 Exa ~$0.007 · 研究 ~$0.015 · 生圖 ~$0.020.04
* - 安全單價 **$0.012/點**TWD≈32 → 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。
*/
export const PLANS: Record<PlanId, PlanDef> = {
free: {
id: "free",
name: "Free",
price_label: "NT$0月",
price_twd: 0,
blurb: "夠用試用,體驗完整創作流程",
blurb_key: "usage.plan.free.blurb",
// 獲客可虧:滿用 ~$1.44;約 23 週輕度日常,用得出價值再升級
monthly_credits: 120,
soft_caps: {
ai_copy: 60, // 60 次文案/回覆
ai_research: 15, // 5 次研究
web_search: 30, // 30 次搜尋
ai_image: 15, // 3 張生圖
}, // sum = 120
},
starter: {
id: "starter",
name: "Starter",
price_label: "NT$590月",
price_twd: 590,
blurb: "小團隊日常發文與海巡",
blurb_key: "usage.plan.starter.blurb",
// 滿用 COGS ≤ $7.2 → 單方案毛利 ~61%;轉付費主力
// 約當:文案 300 · 研究 30 · 搜尋 150 · 生圖 12
monthly_credits: 600,
soft_caps: {
ai_copy: 300,
ai_research: 90,
web_search: 150,
ai_image: 60,
}, // sum = 600
},
pro: {
id: "pro",
name: "Pro",
price_label: "NT$1,990月",
price_twd: 1990,
blurb: "多帳、重度 AI 與研究",
blurb_key: "usage.plan.pro.blurb",
// 滿用 COGS ≤ $24 → 單方案毛利 ~61%;重度天花板
// 約當:文案 1000 · 研究 100 · 搜尋 500 · 生圖 40
monthly_credits: 2000,
soft_caps: {
ai_copy: 1000,
ai_research: 300,
web_search: 500,
ai_image: 200,
}, // sum = 2000
},
};
/** 記帳點數platform與後端 DefaultCreditCost 對齊 */
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;
/**
* 本月已用 platform 點數(不是方案總額)。
* 方案配給請用 plan.monthly_credits 或 platform.credits_total。
*/
total_credits: number;
remaining_credits: number;
/** 已用 / 方案配給 百分比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;
by_meter: Record<UsageMeter, { credits: number; 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<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;
byok_call_count: 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 }>;
byok: {
call_count: 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;
/** 預設 platformmock 記點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=platformBYOK 只計次數
const by_meter = emptyByMeter(plan);
let platformCredits = 0;
let platformCalls = 0;
let byokCalls = 0;
const byokByMeter = {} as UsageMonthSummary["byok"]["by_meter"];
for (const m of Object.keys(METER_META) as UsageMeter[]) {
byokByMeter[m] = { credits: 0, count: 0 };
}
for (const e of events) {
const mode = e.key_mode ?? "platform";
if (mode === "byok") {
byokCalls += 1;
if (byokByMeter[e.meter]) byokByMeter[e.meter].count += 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];
// soft_cap 是點數上限,進度用已用點數
row.pct =
row.soft_cap > 0 ? Math.min(999, Math.round((row.credits / 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].credits;
else search_calls += by_meter[m].credits;
}
return {
// 與 UIwidget 一致total_credits = 已用
total_credits: platformCredits,
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, by_meter: byokByMeter },
};
}
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;
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"];
const byokByMeter = {} as TenantUsageAnalytics["byok"]["by_meter"];
for (const m of Object.keys(METER_META) as UsageMeter[]) {
by_meter[m] = { credits: 0, count: 0 };
byokByMeter[m] = { credits: 0, count: 0 };
}
let consumed_credits = 0;
let byokCallCount = 0;
for (const e of rangeEvents) {
if (e.key_mode === "byok") {
byokCallCount += 1;
byokByMeter[e.meter].count += 1;
continue;
}
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) {
if (e.key_mode === "byok") continue;
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,
byok_call_count: evs.filter((e) => e.key_mode === "byok").length,
};
});
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) {
if (e.key_mode === "byok") continue;
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,
byok: { call_count: byokCallCount, by_meter: byokByMeter },
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,
};
}
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;
});
// 不擋額度:只提示已超出,不說「用完」
if (summary.unlimited) {
if (used > cap) {
return tr("usage.warn.unlimitedOver", { used, cap });
}
return null;
}
if (pct >= 100) {
return tr("usage.warn.exhausted");
}
if (pct >= 80) {
return tr("usage.warn.high", { pct });
}
for (const m of Object.keys(METER_META) as UsageMeter[]) {
const row = summary.by_meter[m];
if (row.pct >= 90) {
return tr("usage.warn.meterNear", {
label: t ? t(`usage.meter.${m}`) : METER_META[m].label,
credits: row.credits,
cap: row.soft_cap,
});
}
}
return null;
}