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

830 lines
22 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";
export type UsageEvent = {
id: string;
/** 使用者 uid舊資料可能缺會歸到 unknown */
uid: string;
meter: UsageMeter;
/** 消耗點數 */
credits: number;
/** 使用者可讀說明 */
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<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",
},
};
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",
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<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;
total_credits: number;
remaining_credits: number;
/** 無限時為 0有上限才算用掉比例 */
pct: number;
/** AI 次數合計(文案 + 研究 + 生圖) */
ai_calls: number;
/** 搜尋次數 */
search_calls: number;
by_meter: Record<
UsageMeter,
{ credits: number; count: number; soft_cap: number; pct: number }
>;
events: UsageEvent[];
};
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;
}): UsageEvent {
const uid = opts.uid || sessionUid();
const credits =
opts.credits != null && opts.credits > 0
? opts.credits
: DEFAULT_CREDIT_COST[opts.meter];
const ev: UsageEvent = {
id: newId("use"),
uid,
meter: opts.meter,
credits,
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"
> {
// 用量一律完整計算(與是否「不擋額度」無關)
const by_meter = emptyByMeter(plan);
let total = 0;
for (const e of events) {
total += e.credits;
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_cappct 最高顯示 100+ 也 cap 在 999 供 UI 參考
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 - total);
const pct =
plan.monthly_credits > 0
? Math.min(999, Math.round((total / 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: total,
remaining_credits: remaining,
pct,
by_meter,
ai_calls,
search_calls,
};
}
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;
seeded.push({
id: newId("use"),
uid,
meter,
credits: DEFAULT_CREDIT_COST[meter] * (1 + (dayAgo % 3)),
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;
}