feat: add v03 user billing entitlements
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
- `POST /internal/auth/introspect`,供 `hwlab-cloud-api` 将 session/API key 归一成 HWLAB actor。
|
||||
- `POST /internal/billing/preflight` 和 `POST /internal/billing/record`,供 Code Agent、AIPOD、HWPOD 等消耗型操作做余额预检、reservation 和 usage record。
|
||||
- `GET /internal/admin/billing/summary`、`POST /internal/admin/credits/adjust`、`POST /internal/admin/users/status`,供 admin-only cloud-api 路径代理。
|
||||
- `GET /internal/admin/billing/plans`,供 admin-only cloud-api 路径查看 plan、resource entitlement、quota、concurrency 和 RPM 的权威配置。
|
||||
|
||||
`hwlab-cloud-api` 仍是浏览器和 AgentRun/HWPOD 等业务入口的应用层边界。它不得直连 user-billing 数据表完成用户状态、余额或 API key 判定;用户态请求必须通过 user-billing introspection 或 admin/internal route 得到结构化结果。
|
||||
|
||||
@@ -45,6 +46,10 @@ Cloud Web admin Users page
|
||||
|
||||
token/credit 计价遵循 reservation/record 分层:消耗型操作先通过 user-billing preflight/reservation 确认余额和计价上下文,再在操作完成或失败时记录 usage/release。Cloud API 可以保存业务 trace、agent session 和运行证据,但不能把这些派生记录当成余额账本。
|
||||
|
||||
plan/entitlement 是 user-billing 的资源授权 authority。`hwlab_credit_accounts.plan_id` 必须指向 PK01 中的 plan;`hwlab_resource_entitlements` 按 `resource_type` 表达服务是否启用、月度 quota、并发 reservation 上限和 RPM 上限。`0` 代表 unlimited,禁用必须通过 `enabled=false` 或 plan 状态表达,不能靠缺行或硬编码默认值猜测。`code_agent`、`aipod`、`hwpod` 是 v0.3 多用户云服务的基础资源类型,新增资源类型必须进入同一 plan/entitlement 模型。
|
||||
|
||||
`/internal/billing/preflight` 必须在余额预留前执行 entitlement、monthly quota、active reservation concurrency 和 RPM 检查。RPM 与 quota 的权威统计来自 PK01 PostgreSQL reservation/usage 记录;Redis 只能作为后续 cache 或短期加速层,不能成为限流、余额、quota 或授权的唯一来源。preflight 成功后才创建 reservation;running 阶段只保留 reservation,不提前扣费;completed 记录 usage/debit;failed、blocked、timeout、canceled 等未完成状态 release reservation。
|
||||
|
||||
新增 AIPOD/HWPOD 云托管产品时,产品维度、资源 id、reservation id、usage amount 和状态变迁应进入 user-billing 的 PK01 表结构或迁移;不要在 cloud-api 内新增平行计费表作为第二账本。
|
||||
|
||||
## Secret 与 Bootstrap
|
||||
|
||||
@@ -2162,6 +2162,8 @@ function redactBillingReservation(value) {
|
||||
if (!reservationId) return null;
|
||||
return pruneEmpty({
|
||||
reservationId,
|
||||
resourceType: textOr(reservation.resourceType, ""),
|
||||
planId: textOr(reservation.planId, ""),
|
||||
estimatedCredits: numberOrNull(reservation.estimatedCredits),
|
||||
estimatedTokens: numberOrNull(reservation.estimatedTokens),
|
||||
expiresAt: textOr(reservation.expiresAt, ""),
|
||||
|
||||
@@ -842,23 +842,23 @@ async function preflightCodeAgentBilling({ params = {}, options = {}, traceId, r
|
||||
};
|
||||
const result = await client.billingPreflight(body);
|
||||
if (result.ok && result.body?.allowed !== false) return { reservation: sanitizeBillingReservation(result.body) };
|
||||
const status = result.status === 402 ? 402 : 503;
|
||||
const status = [402, 403, 429].includes(Number(result.status)) ? Number(result.status) : 503;
|
||||
sendJson(response, status, {
|
||||
ok: false,
|
||||
accepted: false,
|
||||
status: status === 402 ? "payment_required" : "billing_unavailable",
|
||||
status: status === 402 ? "payment_required" : status === 403 ? "not_entitled" : status === 429 ? "rate_limited" : "billing_unavailable",
|
||||
traceId,
|
||||
error: {
|
||||
code: result.error?.code ?? (status === 402 ? "insufficient_credits" : "user_billing_preflight_failed"),
|
||||
layer: "billing",
|
||||
retryable: status !== 402,
|
||||
retryable: status === 429 || status === 503,
|
||||
message: result.error?.message ?? "Code Agent billing preflight failed",
|
||||
route: "/v1/agent/chat"
|
||||
},
|
||||
blocker: {
|
||||
code: result.error?.code ?? (status === 402 ? "insufficient_credits" : "user_billing_preflight_failed"),
|
||||
layer: "billing",
|
||||
retryable: status !== 402,
|
||||
retryable: status === 429 || status === 503,
|
||||
summary: result.error?.message ?? "Code Agent billing preflight failed"
|
||||
},
|
||||
valuesRedacted: true
|
||||
@@ -986,6 +986,8 @@ function sanitizeBillingReservation(value = {}) {
|
||||
return {
|
||||
allowed: value.allowed === true,
|
||||
reservationId: textValue(value.reservationId) || null,
|
||||
resourceType: textValue(value.resourceType) || null,
|
||||
planId: textValue(value.planId) || null,
|
||||
estimatedCredits: Number.isFinite(Number(value.estimatedCredits)) ? Number(value.estimatedCredits) : null,
|
||||
estimatedTokens: Number.isFinite(Number(value.estimatedTokens)) ? Number(value.estimatedTokens) : null,
|
||||
expiresAt: textValue(value.expiresAt) || null,
|
||||
@@ -1861,6 +1863,8 @@ function codeAgentBillingReservationEvidence(value = null) {
|
||||
if (!reservationId) return null;
|
||||
return {
|
||||
reservationId,
|
||||
resourceType: textValue(value.resourceType) || null,
|
||||
planId: textValue(value.planId) || null,
|
||||
estimatedCredits: numberOrNull(value.estimatedCredits),
|
||||
estimatedTokens: numberOrNull(value.estimatedTokens),
|
||||
expiresAt: textValue(value.expiresAt) || null,
|
||||
|
||||
@@ -325,6 +325,11 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1/admin/billing/plans" && request.method === "GET") {
|
||||
await handleAdminBillingPlansHttp(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1/admin/billing/users" && (request.method === "GET" || request.method === "POST")) {
|
||||
await handleAdminBillingUsersHttp(request, response, url, options);
|
||||
return;
|
||||
@@ -1121,6 +1126,17 @@ async function handleAdminBillingUsersHttp(request, response, url, options) {
|
||||
sendUserBillingProxyResult({ request, response, result, operation: `${request.method} /v1/admin/billing/users`, route: "/v1/admin/billing/users", actor: adminActorSummary(auth.actor) });
|
||||
}
|
||||
|
||||
async function handleAdminBillingPlansHttp(request, response, options) {
|
||||
const context = await requireAdminBillingClient(request, response, options, "GET /v1/admin/billing/plans");
|
||||
if (!context) return;
|
||||
const { auth, client } = context;
|
||||
if (typeof client.adminBillingPlans !== "function") {
|
||||
return sendRestError(request, response, 503, "user_billing_not_configured", "HWLAB user-billing admin plans API is not configured", { operation: "GET /v1/admin/billing/plans", target: { type: "service", id: "hwlab-user-billing" } });
|
||||
}
|
||||
const result = await client.adminBillingPlans();
|
||||
sendUserBillingProxyResult({ request, response, result, operation: "GET /v1/admin/billing/plans", route: "/v1/admin/billing/plans", actor: adminActorSummary(auth.actor) });
|
||||
}
|
||||
|
||||
async function handleAdminBillingUserHttp(request, response, url, userId, options) {
|
||||
const context = await requireAdminBillingClient(request, response, options, `${request.method} /v1/admin/billing/users/{userId}`);
|
||||
if (!context) return;
|
||||
|
||||
@@ -119,6 +119,9 @@ export function createUserBillingClient({ env = process.env, fetchImpl = fetch }
|
||||
const query = new URLSearchParams({ limit: String(limit) });
|
||||
return requestJson(`/internal/admin/billing/summary?${query.toString()}`, { method: "GET" });
|
||||
},
|
||||
async adminBillingPlans() {
|
||||
return requestJson("/internal/admin/billing/plans", { method: "GET" });
|
||||
},
|
||||
async adminUsers({ page = 1, pageSize = 50, search = "", status = "", role = "" } = {}) {
|
||||
const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (text(search)) query.set("search", text(search));
|
||||
|
||||
@@ -649,6 +649,105 @@ test("cloud api proxies R1 admin user management and credit adjustment to user-b
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api proxies R4 admin billing plans and preserves plan updates", async () => {
|
||||
const calls = [];
|
||||
const userBillingClient = {
|
||||
configured: true,
|
||||
async adminBillingPlans() {
|
||||
calls.push({ op: "adminBillingPlans" });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
contractVersion: "user-billing-admin-plans-v1",
|
||||
plans: [{ plan: { id: "default", displayName: "Default", status: "active" }, entitlements: [{ resourceType: "code_agent", enabled: true, monthlyQuotaUnlimited: true, concurrencyUnlimited: true, rpmUnlimited: true }] }],
|
||||
count: 1,
|
||||
valuesRedacted: true
|
||||
}
|
||||
};
|
||||
},
|
||||
async updateAdminUser(userId, body = {}) {
|
||||
calls.push({ op: "updateAdminUser", userId, body });
|
||||
assert.equal(userId, "usr_billing_admin_subject");
|
||||
assert.equal(body.planId, "default");
|
||||
return { ok: true, status: 200, body: { contractVersion: "user-billing-admin-user-v1", updated: true, detail: { summary: { user: { id: userId }, planId: body.planId } } } };
|
||||
}
|
||||
};
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
||||
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
||||
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-password-1127"
|
||||
},
|
||||
userBillingClient
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const login = await fetch(`http://127.0.0.1:${port}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "admin-password-1127" })
|
||||
});
|
||||
assert.equal(login.status, 200);
|
||||
const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||
const plans = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/plans`, { headers: { cookie } });
|
||||
assert.equal(plans.status, 200);
|
||||
const plansBody = await plans.json();
|
||||
assert.equal(plansBody.proxy.source, "hwlab-user-billing");
|
||||
assert.equal(plansBody.plans[0].entitlements[0].resourceType, "code_agent");
|
||||
|
||||
const update = await fetch(`http://127.0.0.1:${port}/v1/admin/billing/users/usr_billing_admin_subject`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", cookie },
|
||||
body: JSON.stringify({ planId: "default" })
|
||||
});
|
||||
assert.equal(update.status, 200);
|
||||
assert.equal((await update.json()).detail.summary.planId, "default");
|
||||
assert.deepEqual(calls.map((call) => call.op), ["adminBillingPlans", "updateAdminUser"]);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api maps R4 Code Agent entitlement denial to a user-facing 403", async () => {
|
||||
const userBillingClient = {
|
||||
configured: true,
|
||||
async introspect(token) {
|
||||
assert.equal(token, "hwl_user_billing_entitlement_denied");
|
||||
return { ok: true, status: 200, body: { active: true, principal: { userId: "usr_denied", email: "denied@hwlab.local", username: "denied", role: "user", scopes: ["api"], authType: "api-key", keyId: "key_denied" } } };
|
||||
},
|
||||
async billingPreflight(body) {
|
||||
assert.equal(body.serviceId, "hwlab-code-agent");
|
||||
return { ok: false, status: 403, error: { code: "resource_not_entitled", message: "resource is not enabled for this billing plan" } };
|
||||
}
|
||||
};
|
||||
const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1" }, userBillingClient });
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const session = await fetch(`http://127.0.0.1:${port}/v1/agent/sessions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer hwl_user_billing_entitlement_denied" },
|
||||
body: JSON.stringify({ conversationId: "cnv_denied", sessionId: "ses_denied", providerProfile: "deepseek" })
|
||||
});
|
||||
assert.equal(session.status, 201);
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer hwl_user_billing_entitlement_denied", "x-trace-id": "trc_user_billing_entitlement_denied", prefer: "respond-async" },
|
||||
body: JSON.stringify({ conversationId: "cnv_denied", sessionId: "ses_denied", shortConnection: true, message: "should be blocked by entitlement" })
|
||||
});
|
||||
assert.equal(response.status, 403);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "not_entitled");
|
||||
assert.equal(payload.error.code, "resource_not_entitled");
|
||||
assert.equal(payload.blocker.retryable, false);
|
||||
assert.equal(JSON.stringify(payload).includes("hwl_user_billing_entitlement_denied"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api proxies R1 user-billing API key and profile self-service routes", async () => {
|
||||
const calls = [];
|
||||
const bearer = "hwl_user_billing_session_secret";
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
CREATE TABLE IF NOT EXISTS hwlab_billing_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CHECK (status IN ('active', 'disabled'))
|
||||
);
|
||||
|
||||
INSERT INTO hwlab_billing_plans (id, display_name, status, description, metadata)
|
||||
VALUES ('default', 'Default', 'active', 'Default HWLAB v0.3 resource plan', '{"source":"hwlab-v03","limitSemantics":"zero-means-unlimited"}'::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
status = EXCLUDED.status,
|
||||
description = EXCLUDED.description,
|
||||
metadata = hwlab_billing_plans.metadata || EXCLUDED.metadata,
|
||||
updated_at = now();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hwlab_resource_entitlements (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES hwlab_billing_plans(id) ON DELETE CASCADE,
|
||||
resource_type TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
monthly_quota_credits BIGINT NOT NULL DEFAULT 0,
|
||||
max_concurrent_reservations INTEGER NOT NULL DEFAULT 0,
|
||||
rpm_limit INTEGER NOT NULL DEFAULT 0,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (plan_id, resource_type),
|
||||
CHECK (resource_type <> ''),
|
||||
CHECK (monthly_quota_credits >= 0),
|
||||
CHECK (max_concurrent_reservations >= 0),
|
||||
CHECK (rpm_limit >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS hwlab_resource_entitlements_plan_idx ON hwlab_resource_entitlements(plan_id);
|
||||
CREATE INDEX IF NOT EXISTS hwlab_resource_entitlements_resource_idx ON hwlab_resource_entitlements(resource_type);
|
||||
|
||||
INSERT INTO hwlab_resource_entitlements (id, plan_id, resource_type, enabled, monthly_quota_credits, max_concurrent_reservations, rpm_limit, metadata)
|
||||
VALUES
|
||||
('ent_default_code_agent', 'default', 'code_agent', TRUE, 0, 0, 0, '{"serviceIds":["hwlab-code-agent"],"limitSemantics":"zero-means-unlimited"}'::jsonb),
|
||||
('ent_default_aipod', 'default', 'aipod', TRUE, 0, 0, 0, '{"serviceIds":["hwlab-aipod"],"limitSemantics":"zero-means-unlimited"}'::jsonb),
|
||||
('ent_default_hwpod', 'default', 'hwpod', TRUE, 0, 0, 0, '{"serviceIds":["hwlab-hwpod"],"limitSemantics":"zero-means-unlimited"}'::jsonb)
|
||||
ON CONFLICT (plan_id, resource_type) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
monthly_quota_credits = EXCLUDED.monthly_quota_credits,
|
||||
max_concurrent_reservations = EXCLUDED.max_concurrent_reservations,
|
||||
rpm_limit = EXCLUDED.rpm_limit,
|
||||
metadata = hwlab_resource_entitlements.metadata || EXCLUDED.metadata,
|
||||
updated_at = now();
|
||||
|
||||
ALTER TABLE hwlab_billing_reservations ADD COLUMN IF NOT EXISTS resource_type TEXT NOT NULL DEFAULT 'generic';
|
||||
ALTER TABLE hwlab_usage_records ADD COLUMN IF NOT EXISTS resource_type TEXT NOT NULL DEFAULT 'generic';
|
||||
|
||||
UPDATE hwlab_billing_reservations
|
||||
SET resource_type = CASE
|
||||
WHEN lower(service_id) LIKE '%code-agent%' OR lower(service_id) LIKE '%code_agent%' THEN 'code_agent'
|
||||
WHEN lower(service_id) LIKE '%aipod%' THEN 'aipod'
|
||||
WHEN lower(service_id) LIKE '%hwpod%' THEN 'hwpod'
|
||||
ELSE resource_type
|
||||
END
|
||||
WHERE resource_type = 'generic';
|
||||
|
||||
UPDATE hwlab_usage_records
|
||||
SET resource_type = CASE
|
||||
WHEN lower(service_id) LIKE '%code-agent%' OR lower(service_id) LIKE '%code_agent%' THEN 'code_agent'
|
||||
WHEN lower(service_id) LIKE '%aipod%' THEN 'aipod'
|
||||
WHEN lower(service_id) LIKE '%hwpod%' THEN 'hwpod'
|
||||
ELSE resource_type
|
||||
END
|
||||
WHERE resource_type = 'generic';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS hwlab_billing_reservations_user_resource_idx ON hwlab_billing_reservations(user_id, resource_type, status, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS hwlab_billing_reservations_user_resource_created_idx ON hwlab_billing_reservations(user_id, resource_type, created_at);
|
||||
CREATE INDEX IF NOT EXISTS hwlab_usage_records_user_resource_created_idx ON hwlab_usage_records(user_id, resource_type, created_at);
|
||||
+384
-34
@@ -79,11 +79,12 @@ type Principal struct {
|
||||
}
|
||||
|
||||
type serviceUsageSummary struct {
|
||||
ServiceID string `json:"serviceId"`
|
||||
Credits int64 `json:"credits"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
RecordCount int64 `json:"recordCount"`
|
||||
LastUsedAt time.Time `json:"lastUsedAt"`
|
||||
ServiceID string `json:"serviceId"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
Credits int64 `json:"credits"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
RecordCount int64 `json:"recordCount"`
|
||||
LastUsedAt time.Time `json:"lastUsedAt"`
|
||||
}
|
||||
|
||||
type ledgerSummaryRow struct {
|
||||
@@ -98,6 +99,7 @@ type ledgerSummaryRow struct {
|
||||
type reservationSummaryRow struct {
|
||||
ReservationID string `json:"reservationId"`
|
||||
ServiceID string `json:"serviceId"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
EstimatedCredits int64 `json:"estimatedCredits"`
|
||||
EstimatedTokens int64 `json:"estimatedTokens"`
|
||||
Status string `json:"status"`
|
||||
@@ -105,13 +107,42 @@ type reservationSummaryRow struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type billingPlanSummary struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type resourceEntitlementSummary struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
PlanID string `json:"planId,omitempty"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MonthlyQuotaCredits int64 `json:"monthlyQuotaCredits"`
|
||||
MonthlyQuotaUnlimited bool `json:"monthlyQuotaUnlimited"`
|
||||
MonthlyUsageCredits int64 `json:"monthlyUsageCredits"`
|
||||
MonthlyRemainingCredits int64 `json:"monthlyRemainingCredits"`
|
||||
MaxConcurrentReservations int64 `json:"maxConcurrentReservations"`
|
||||
ConcurrencyUnlimited bool `json:"concurrencyUnlimited"`
|
||||
ActiveReservations int64 `json:"activeReservations"`
|
||||
RPMLimit int64 `json:"rpmLimit"`
|
||||
RPMUnlimited bool `json:"rpmUnlimited"`
|
||||
RPMUsed int64 `json:"rpmUsed"`
|
||||
LimitSemantics string `json:"limitSemantics"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type adminBillingUserSummary struct {
|
||||
User User `json:"user"`
|
||||
PlanID string `json:"planId"`
|
||||
Credits creditSummary `json:"credits"`
|
||||
Usage userUsageAggregate `json:"usage"`
|
||||
APIKeys apiKeyAggregate `json:"apiKeys"`
|
||||
Reservations reservationAdminSummary `json:"reservations"`
|
||||
User User `json:"user"`
|
||||
PlanID string `json:"planId"`
|
||||
Plan billingPlanSummary `json:"plan,omitempty"`
|
||||
Entitlements []resourceEntitlementSummary `json:"entitlements,omitempty"`
|
||||
Credits creditSummary `json:"credits"`
|
||||
Usage userUsageAggregate `json:"usage"`
|
||||
APIKeys apiKeyAggregate `json:"apiKeys"`
|
||||
Reservations reservationAdminSummary `json:"reservations"`
|
||||
}
|
||||
|
||||
type creditSummary struct {
|
||||
@@ -160,6 +191,11 @@ type adminUserDetail struct {
|
||||
Reservations []reservationSummaryRow `json:"reservations"`
|
||||
}
|
||||
|
||||
type billingPlanDetail struct {
|
||||
Plan billingPlanSummary `json:"plan"`
|
||||
Entitlements []resourceEntitlementSummary `json:"entitlements"`
|
||||
}
|
||||
|
||||
type reservationAdminSummary struct {
|
||||
ActiveCount int64 `json:"activeCount"`
|
||||
Active []reservationSummaryRow `json:"active"`
|
||||
@@ -286,6 +322,7 @@ func (s *Server) routes() {
|
||||
s.route(http.MethodPost, "/internal/billing/record", s.handleBillingRecord)
|
||||
s.route(http.MethodPost, "/internal/billing/release", s.handleBillingRelease)
|
||||
s.route(http.MethodGet, "/internal/admin/billing/summary", s.handleAdminBillingSummary)
|
||||
s.route(http.MethodGet, "/internal/admin/billing/plans", s.handleAdminBillingPlans)
|
||||
s.route(http.MethodPost, "/internal/admin/credits/adjust", s.handleAdminCreditAdjust)
|
||||
s.mux.HandleFunc("/internal/admin/users", s.handleAdminUsers)
|
||||
s.mux.HandleFunc("/internal/admin/users/", s.handleAdminUserByID)
|
||||
@@ -663,6 +700,12 @@ func (s *Server) handleBillingSummary(w http.ResponseWriter, r *http.Request) {
|
||||
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load active reservations")
|
||||
return
|
||||
}
|
||||
planID, plan, entitlements, err := s.accountPlanContext(ctx, principal.UserID)
|
||||
if err != nil {
|
||||
s.logDatabaseError("billing_summary_entitlements", err)
|
||||
writeAPIError(w, http.StatusInternalServerError, "billing_summary_failed", "could not load billing plan entitlements")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"contractVersion": "user-billing-summary-v1",
|
||||
@@ -674,6 +717,9 @@ func (s *Server) handleBillingSummary(w http.ResponseWriter, r *http.Request) {
|
||||
"reserved": reserved,
|
||||
"available": balance - reserved,
|
||||
},
|
||||
"planId": planID,
|
||||
"plan": plan,
|
||||
"entitlements": entitlements,
|
||||
"usage": map[string]any{
|
||||
"totalCredits": totalCredits,
|
||||
"totalQuantity": totalQuantity,
|
||||
@@ -903,6 +949,11 @@ func (s *Server) handleBillingPreflight(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusPaymentRequired, map[string]any{"allowed": false, "reason": "insufficient_credits"})
|
||||
return
|
||||
}
|
||||
var limitErr billingLimitError
|
||||
if errors.As(err, &limitErr) {
|
||||
writeJSON(w, limitErr.Status, map[string]any{"allowed": false, "reason": limitErr.Code, "error": apiError{Code: limitErr.Code, Message: limitErr.Message}})
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusInternalServerError, "billing_preflight_failed", "could not reserve credits")
|
||||
return
|
||||
}
|
||||
@@ -1114,6 +1165,7 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request, u
|
||||
Password *string `json:"password"`
|
||||
Role *string `json:"role"`
|
||||
Status *string `json:"status"`
|
||||
PlanID *string `json:"planId"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
@@ -1175,7 +1227,15 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request, u
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if len(updates) == 0 {
|
||||
planID := ""
|
||||
if req.PlanID != nil {
|
||||
planID = strings.TrimSpace(*req.PlanID)
|
||||
if planID == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid_plan", "planId is required when updating a billing plan")
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 && planID == "" {
|
||||
detail, err := s.adminUserDetail(ctx, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeAPIError(w, http.StatusNotFound, "user_not_found", "user not found")
|
||||
@@ -1188,6 +1248,19 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request, u
|
||||
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": false, "detail": detail, "valuesRedacted": true})
|
||||
return
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
detail, err := s.updateUserPlan(ctx, userID, planID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeAPIError(w, http.StatusNotFound, "user_or_plan_not_found", "user or plan not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "plan_update_failed", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": true, "detail": detail, "valuesRedacted": true})
|
||||
return
|
||||
}
|
||||
query := `UPDATE hwlab_users SET ` + strings.Join(updates, ", ") + `, updated_at = now() WHERE id = $1 RETURNING id, email, username, display_name, status, role, email_verified, created_at`
|
||||
var user User
|
||||
err := s.db.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.Email, &user.Username, &user.DisplayName, &user.Status, &user.Role, &user.EmailVerified, &user.CreatedAt)
|
||||
@@ -1203,6 +1276,12 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request, u
|
||||
writeAPIError(w, http.StatusInternalServerError, "user_update_failed", "could not update user")
|
||||
return
|
||||
}
|
||||
if planID != "" {
|
||||
if _, err := s.updateUserPlan(ctx, userID, planID); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, "plan_update_failed", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-user-v1", "updated": true, "user": user, "valuesRedacted": true})
|
||||
}
|
||||
|
||||
@@ -1302,8 +1381,31 @@ func (s *Server) handleAdminBillingSummary(w http.ResponseWriter, r *http.Reques
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminBillingPlans(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
plans, err := s.billingPlans(ctx)
|
||||
if err != nil {
|
||||
s.logDatabaseError("admin_billing_plans", err)
|
||||
writeAPIError(w, http.StatusInternalServerError, "admin_billing_plans_failed", "could not load billing plans")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-plans-v1", "plans": plans, "count": len(plans), "limitSemantics": "zero-means-unlimited", "valuesRedacted": true})
|
||||
}
|
||||
|
||||
var errInsufficientCredits = errors.New("insufficient credits")
|
||||
|
||||
type billingLimitError struct {
|
||||
Code string
|
||||
Message string
|
||||
Status int
|
||||
}
|
||||
|
||||
func (err billingLimitError) Error() string { return err.Message }
|
||||
|
||||
func (s *Server) reserveCredits(ctx context.Context, userID, service string, credits, tokens int64, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
@@ -1313,16 +1415,23 @@ func (s *Server) reserveCredits(ctx context.Context, userID, service string, cre
|
||||
if idempotencyKey != "" {
|
||||
var existingID, status string
|
||||
var estimated int64
|
||||
err := tx.QueryRowContext(ctx, `SELECT id, status, estimated_credits FROM hwlab_billing_reservations WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID, &status, &estimated)
|
||||
var resourceType string
|
||||
err := tx.QueryRowContext(ctx, `SELECT id, status, estimated_credits, resource_type FROM hwlab_billing_reservations WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID, &status, &estimated, &resourceType)
|
||||
if err == nil {
|
||||
return map[string]any{"allowed": status == "reserved", "reservationId": existingID, "estimatedCredits": estimated, "status": status, "idempotent": true}, tx.Commit()
|
||||
return map[string]any{"allowed": status == "reserved", "reservationId": existingID, "estimatedCredits": estimated, "resourceType": resourceType, "status": status, "idempotent": true}, tx.Commit()
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var balance, reserved int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance, &reserved); err != nil {
|
||||
var planID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits, plan_id FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance, &reserved, &planID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resourceType := resourceTypeForService(service)
|
||||
entitlement, err := s.evaluateResourceEntitlement(ctx, tx, userID, planID, resourceType, credits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
available := balance - reserved
|
||||
@@ -1332,7 +1441,7 @@ func (s *Server) reserveCredits(ctx context.Context, userID, service string, cre
|
||||
reservationID := newID("res")
|
||||
metadataJSON := jsonObject(metadata)
|
||||
expiresAt := time.Now().UTC().Add(s.config.ReservationTTL)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_billing_reservations (id, user_id, service_id, estimated_credits, estimated_tokens, idempotency_key, metadata, expires_at) VALUES ($1, $2, $3, $4, $5, nullif($6, ''), $7::jsonb, $8)`, reservationID, userID, service, credits, nonNegative(tokens), idempotencyKey, metadataJSON, expiresAt)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_billing_reservations (id, user_id, service_id, resource_type, estimated_credits, estimated_tokens, idempotency_key, metadata, expires_at) VALUES ($1, $2, $3, $4, $5, $6, nullif($7, ''), $8::jsonb, $9)`, reservationID, userID, service, resourceType, credits, nonNegative(tokens), idempotencyKey, metadataJSON, expiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1343,7 +1452,7 @@ func (s *Server) reserveCredits(ctx context.Context, userID, service string, cre
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"allowed": true, "reservationId": reservationID, "estimatedCredits": credits, "estimatedTokens": tokens, "availableBefore": available, "expiresAt": expiresAt}, nil
|
||||
return map[string]any{"allowed": true, "reservationId": reservationID, "estimatedCredits": credits, "estimatedTokens": tokens, "resourceType": resourceType, "planId": planID, "entitlement": entitlement, "availableBefore": available, "expiresAt": expiresAt}, nil
|
||||
}
|
||||
|
||||
func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID, token, apiKey, userID, serviceID string, usedCredits, usedTokens int64, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
|
||||
@@ -1363,9 +1472,10 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID
|
||||
}
|
||||
}
|
||||
var reserved int64
|
||||
resourceType := ""
|
||||
if reservationID != "" {
|
||||
var status string
|
||||
err := tx.QueryRowContext(ctx, `SELECT user_id, service_id, estimated_credits, status FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &serviceID, &reserved, &status)
|
||||
err := tx.QueryRowContext(ctx, `SELECT user_id, service_id, resource_type, estimated_credits, status FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &serviceID, &resourceType, &reserved, &status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1379,15 +1489,25 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID
|
||||
}
|
||||
userID = principal.UserID
|
||||
serviceID = strings.TrimSpace(serviceID)
|
||||
resourceType = resourceTypeForService(serviceID)
|
||||
}
|
||||
if serviceID == "" {
|
||||
return nil, errors.New("serviceId is required")
|
||||
}
|
||||
if resourceType == "" {
|
||||
resourceType = resourceTypeForService(serviceID)
|
||||
}
|
||||
credits := s.estimateCredits(usedCredits, usedTokens)
|
||||
var balance, accountReserved int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance, &accountReserved); err != nil {
|
||||
var planID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT balance_credits, reserved_credits, plan_id FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance, &accountReserved, &planID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reservationID == "" {
|
||||
if _, err := s.evaluateResourceEntitlement(ctx, tx, userID, planID, resourceType, credits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if reservationID == "" && balance-accountReserved < credits {
|
||||
return nil, errInsufficientCredits
|
||||
}
|
||||
@@ -1405,7 +1525,7 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID
|
||||
}
|
||||
recordID := newID("use")
|
||||
metadataJSON := jsonObject(metadata)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_usage_records (id, reservation_id, user_id, service_id, quantity, credits, idempotency_key, metadata) VALUES ($1, nullif($2, ''), $3, $4, $5, $6, nullif($7, ''), $8::jsonb)`, recordID, reservationID, userID, serviceID, nonNegative(usedTokens), credits, idempotencyKey, metadataJSON)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_usage_records (id, reservation_id, user_id, service_id, resource_type, quantity, credits, idempotency_key, metadata) VALUES ($1, nullif($2, ''), $3, $4, $5, $6, $7, nullif($8, ''), $9::jsonb)`, recordID, reservationID, userID, serviceID, resourceType, nonNegative(usedTokens), credits, idempotencyKey, metadataJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1426,7 +1546,7 @@ func (s *Server) recordUsage(ctx context.Context, r *http.Request, reservationID
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"recordId": recordID, "userId": userID, "serviceId": serviceID, "credits": credits, "balance": newBalance}, nil
|
||||
return map[string]any{"recordId": recordID, "userId": userID, "serviceId": serviceID, "resourceType": resourceType, "credits": credits, "balance": newBalance}, nil
|
||||
}
|
||||
|
||||
func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceID, reason, idempotencyKey string, metadata map[string]any) (map[string]any, error) {
|
||||
@@ -1439,9 +1559,9 @@ func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceI
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userID, actualServiceID, status string
|
||||
var userID, actualServiceID, resourceType, status string
|
||||
var reserved int64
|
||||
err = tx.QueryRowContext(ctx, `SELECT user_id, service_id, estimated_credits, status FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &actualServiceID, &reserved, &status)
|
||||
err = tx.QueryRowContext(ctx, `SELECT user_id, service_id, resource_type, estimated_credits, status FROM hwlab_billing_reservations WHERE id = $1 FOR UPDATE`, reservationID).Scan(&userID, &actualServiceID, &resourceType, &reserved, &status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1450,7 +1570,7 @@ func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceI
|
||||
return nil, fmt.Errorf("reservation service is %s", actualServiceID)
|
||||
}
|
||||
if status == "cancelled" || status == "expired" {
|
||||
return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "releasedCredits": int64(0), "status": status, "idempotent": true}, tx.Commit()
|
||||
return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "resourceType": resourceType, "releasedCredits": int64(0), "status": status, "idempotent": true}, tx.Commit()
|
||||
}
|
||||
if status != "reserved" {
|
||||
return nil, fmt.Errorf("reservation status is %s", status)
|
||||
@@ -1468,8 +1588,8 @@ func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceI
|
||||
return nil, err
|
||||
}
|
||||
releaseMetadata := map[string]any{
|
||||
"releaseReason": strings.TrimSpace(reason),
|
||||
"releaseIdempotency": strings.TrimSpace(idempotencyKey),
|
||||
"releaseReason": strings.TrimSpace(reason),
|
||||
"releaseIdempotency": strings.TrimSpace(idempotencyKey),
|
||||
"releaseValuesRedacted": true,
|
||||
}
|
||||
for key, value := range metadata {
|
||||
@@ -1482,7 +1602,7 @@ func (s *Server) releaseReservation(ctx context.Context, reservationID, serviceI
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "releasedCredits": reserved, "status": "cancelled"}, nil
|
||||
return map[string]any{"released": true, "reservationId": reservationID, "userId": userID, "serviceId": actualServiceID, "resourceType": resourceType, "releasedCredits": reserved, "status": "cancelled"}, nil
|
||||
}
|
||||
|
||||
func (s *Server) adjustCredits(ctx context.Context, userID string, delta int64, kind, reason, idempotencyKey string, metadata map[string]any) (int64, error) {
|
||||
@@ -1520,6 +1640,201 @@ func (s *Server) adjustCredits(ctx context.Context, userID string, delta int64,
|
||||
return newBalance, tx.Commit()
|
||||
}
|
||||
|
||||
type txQueryer interface {
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
func (s *Server) evaluateResourceEntitlement(ctx context.Context, tx txQueryer, userID, planID, resourceType string, requestedCredits int64) (resourceEntitlementSummary, error) {
|
||||
plan, err := s.planByID(ctx, tx, planID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return resourceEntitlementSummary{}, billingLimitError{Code: "plan_not_found", Message: "billing plan is not active", Status: http.StatusForbidden}
|
||||
}
|
||||
return resourceEntitlementSummary{}, err
|
||||
}
|
||||
if plan.Status != "active" {
|
||||
return resourceEntitlementSummary{}, billingLimitError{Code: "plan_disabled", Message: "billing plan is disabled", Status: http.StatusForbidden}
|
||||
}
|
||||
entitlements, err := s.resourceEntitlements(ctx, tx, planID, userID)
|
||||
if err != nil {
|
||||
return resourceEntitlementSummary{}, err
|
||||
}
|
||||
var entitlement resourceEntitlementSummary
|
||||
found := false
|
||||
for _, item := range entitlements {
|
||||
if item.ResourceType == resourceType {
|
||||
entitlement = item
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found || !entitlement.Enabled {
|
||||
return resourceEntitlementSummary{}, billingLimitError{Code: "resource_not_entitled", Message: "resource is not enabled for this billing plan", Status: http.StatusForbidden}
|
||||
}
|
||||
if entitlement.MaxConcurrentReservations > 0 && entitlement.ActiveReservations >= entitlement.MaxConcurrentReservations {
|
||||
return entitlement, billingLimitError{Code: "resource_concurrency_exceeded", Message: "resource concurrency limit exceeded", Status: http.StatusTooManyRequests}
|
||||
}
|
||||
if entitlement.RPMLimit > 0 && entitlement.RPMUsed >= entitlement.RPMLimit {
|
||||
return entitlement, billingLimitError{Code: "resource_rpm_exceeded", Message: "resource RPM limit exceeded", Status: http.StatusTooManyRequests}
|
||||
}
|
||||
if entitlement.MonthlyQuotaCredits > 0 && entitlement.MonthlyUsageCredits+requestedCredits > entitlement.MonthlyQuotaCredits {
|
||||
return entitlement, billingLimitError{Code: "resource_monthly_quota_exceeded", Message: "resource monthly quota exceeded", Status: http.StatusPaymentRequired}
|
||||
}
|
||||
return entitlement, nil
|
||||
}
|
||||
|
||||
func (s *Server) planByID(ctx context.Context, queryer txQueryer, planID string) (billingPlanSummary, error) {
|
||||
planID = strings.TrimSpace(planID)
|
||||
if planID == "" {
|
||||
planID = "default"
|
||||
}
|
||||
var plan billingPlanSummary
|
||||
var metadataRaw string
|
||||
err := queryer.QueryRowContext(ctx, `SELECT id, display_name, status, description, metadata::text FROM hwlab_billing_plans WHERE id = $1`, planID).Scan(&plan.ID, &plan.DisplayName, &plan.Status, &plan.Description, &metadataRaw)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
plan.Metadata = parseJSONMap(metadataRaw)
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (s *Server) billingPlans(ctx context.Context) ([]billingPlanDetail, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, display_name, status, description, metadata::text FROM hwlab_billing_plans ORDER BY id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
plans := []billingPlanDetail{}
|
||||
for rows.Next() {
|
||||
var detail billingPlanDetail
|
||||
var metadataRaw string
|
||||
if err := rows.Scan(&detail.Plan.ID, &detail.Plan.DisplayName, &detail.Plan.Status, &detail.Plan.Description, &metadataRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail.Plan.Metadata = parseJSONMap(metadataRaw)
|
||||
plans = append(plans, detail)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range plans {
|
||||
entitlements, err := s.resourceEntitlements(ctx, s.db, plans[i].Plan.ID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plans[i].Entitlements = entitlements
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func (s *Server) updateUserPlan(ctx context.Context, userID, planID string) (adminUserDetail, error) {
|
||||
planID = strings.TrimSpace(planID)
|
||||
if planID == "" {
|
||||
return adminUserDetail{}, errors.New("planId is required")
|
||||
}
|
||||
if _, err := s.planByID(ctx, s.db, planID); err != nil {
|
||||
return adminUserDetail{}, err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET plan_id = $2, updated_at = now() WHERE user_id = $1`, userID, planID)
|
||||
if err != nil {
|
||||
return adminUserDetail{}, err
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
return adminUserDetail{}, sql.ErrNoRows
|
||||
}
|
||||
return s.adminUserDetail(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Server) resourceEntitlements(ctx context.Context, queryer txQueryer, planID, userID string) ([]resourceEntitlementSummary, error) {
|
||||
rows, err := queryer.QueryContext(ctx, `
|
||||
SELECT id, plan_id, resource_type, enabled, monthly_quota_credits, max_concurrent_reservations, rpm_limit, metadata::text
|
||||
FROM hwlab_resource_entitlements
|
||||
WHERE plan_id = $1
|
||||
ORDER BY resource_type ASC`, planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []resourceEntitlementSummary{}
|
||||
for rows.Next() {
|
||||
var item resourceEntitlementSummary
|
||||
var metadataRaw string
|
||||
if err := rows.Scan(&item.ID, &item.PlanID, &item.ResourceType, &item.Enabled, &item.MonthlyQuotaCredits, &item.MaxConcurrentReservations, &item.RPMLimit, &metadataRaw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Metadata = parseJSONMap(metadataRaw)
|
||||
item.LimitSemantics = "zero-means-unlimited"
|
||||
item.MonthlyQuotaUnlimited = item.MonthlyQuotaCredits == 0
|
||||
item.ConcurrencyUnlimited = item.MaxConcurrentReservations == 0
|
||||
item.RPMUnlimited = item.RPMLimit == 0
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(userID) == "" || len(items) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
for i := range items {
|
||||
if err := s.populateEntitlementUsage(ctx, queryer, userID, &items[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Server) populateEntitlementUsage(ctx context.Context, queryer txQueryer, userID string, item *resourceEntitlementSummary) error {
|
||||
monthStart := time.Now().UTC().Format("2006-01-02")
|
||||
if err := queryer.QueryRowContext(ctx, `SELECT COALESCE(SUM(credits), 0) FROM hwlab_usage_records WHERE user_id = $1 AND resource_type = $2 AND created_at >= date_trunc('month', $3::timestamptz)`, userID, item.ResourceType, monthStart).Scan(&item.MonthlyUsageCredits); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := queryer.QueryRowContext(ctx, `SELECT COUNT(*) FROM hwlab_billing_reservations WHERE user_id = $1 AND resource_type = $2 AND status = 'reserved' AND expires_at > now()`, userID, item.ResourceType).Scan(&item.ActiveReservations); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := queryer.QueryRowContext(ctx, `SELECT COUNT(*) FROM hwlab_billing_reservations WHERE user_id = $1 AND resource_type = $2 AND created_at >= now() - interval '1 minute'`, userID, item.ResourceType).Scan(&item.RPMUsed); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.MonthlyQuotaCredits > 0 {
|
||||
item.MonthlyRemainingCredits = item.MonthlyQuotaCredits - item.MonthlyUsageCredits
|
||||
if item.MonthlyRemainingCredits < 0 {
|
||||
item.MonthlyRemainingCredits = 0
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceTypeForService(serviceID string) string {
|
||||
value := strings.ToLower(strings.TrimSpace(serviceID))
|
||||
switch {
|
||||
case strings.Contains(value, "code-agent") || strings.Contains(value, "code_agent") || strings.Contains(value, "codex"):
|
||||
return "code_agent"
|
||||
case strings.Contains(value, "aipod") || strings.Contains(value, "ai-pod"):
|
||||
return "aipod"
|
||||
case strings.Contains(value, "hwpod") || strings.Contains(value, "hw-pod"):
|
||||
return "hwpod"
|
||||
case value != "":
|
||||
return strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
return r
|
||||
}
|
||||
if r == '-' || r == '.' || r == '/' {
|
||||
return '_'
|
||||
}
|
||||
return -1
|
||||
}, value)
|
||||
default:
|
||||
return "generic"
|
||||
}
|
||||
}
|
||||
|
||||
func parseJSONMap(raw string) map[string]any {
|
||||
result := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(raw), &result)
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) principalForBilling(ctx context.Context, r *http.Request, token, apiKey, userID string) (*Principal, error) {
|
||||
if token == "" && apiKey == "" {
|
||||
token = bearerToken(r)
|
||||
@@ -1684,6 +1999,19 @@ func (s *Server) accountBalance(ctx context.Context, userID string) (int64, int6
|
||||
return balance, reserved, err
|
||||
}
|
||||
|
||||
func (s *Server) accountPlanContext(ctx context.Context, userID string) (string, billingPlanSummary, []resourceEntitlementSummary, error) {
|
||||
planID := "default"
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(plan_id, 'default') FROM hwlab_credit_accounts WHERE user_id = $1`, userID).Scan(&planID); err != nil {
|
||||
return "", billingPlanSummary{}, nil, err
|
||||
}
|
||||
plan, err := s.planByID(ctx, s.db, planID)
|
||||
if err != nil {
|
||||
return planID, billingPlanSummary{}, nil, err
|
||||
}
|
||||
entitlements, err := s.resourceEntitlements(ctx, s.db, planID, userID)
|
||||
return planID, plan, entitlements, err
|
||||
}
|
||||
|
||||
func (s *Server) usageTotals(ctx context.Context, userID string) (int64, int64, int64, error) {
|
||||
var credits, quantity, count int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(credits), 0), COALESCE(SUM(quantity), 0), COUNT(*) FROM hwlab_usage_records WHERE user_id = $1`, userID).Scan(&credits, &quantity, &count)
|
||||
@@ -1691,7 +2019,7 @@ func (s *Server) usageTotals(ctx context.Context, userID string) (int64, int64,
|
||||
}
|
||||
|
||||
func (s *Server) usageByService(ctx context.Context, userID string, limit int) ([]serviceUsageSummary, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT service_id, COALESCE(SUM(credits), 0), COALESCE(SUM(quantity), 0), COUNT(*), MAX(created_at) FROM hwlab_usage_records WHERE user_id = $1 GROUP BY service_id ORDER BY MAX(created_at) DESC NULLS LAST, service_id ASC LIMIT $2`, userID, limit)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT service_id, resource_type, COALESCE(SUM(credits), 0), COALESCE(SUM(quantity), 0), COUNT(*), MAX(created_at) FROM hwlab_usage_records WHERE user_id = $1 GROUP BY service_id, resource_type ORDER BY MAX(created_at) DESC NULLS LAST, service_id ASC LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1699,7 +2027,7 @@ func (s *Server) usageByService(ctx context.Context, userID string, limit int) (
|
||||
items := []serviceUsageSummary{}
|
||||
for rows.Next() {
|
||||
var item serviceUsageSummary
|
||||
if err := rows.Scan(&item.ServiceID, &item.Credits, &item.Quantity, &item.RecordCount, &item.LastUsedAt); err != nil {
|
||||
if err := rows.Scan(&item.ServiceID, &item.ResourceType, &item.Credits, &item.Quantity, &item.RecordCount, &item.LastUsedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
@@ -1725,7 +2053,7 @@ func (s *Server) ledgerRows(ctx context.Context, userID string, limit int) ([]le
|
||||
}
|
||||
|
||||
func (s *Server) activeReservations(ctx context.Context, userID string, limit int) ([]reservationSummaryRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, service_id, estimated_credits, estimated_tokens, status, expires_at, created_at FROM hwlab_billing_reservations WHERE user_id = $1 AND status = 'reserved' AND expires_at > now() ORDER BY expires_at ASC, created_at DESC LIMIT $2`, userID, limit)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, service_id, resource_type, estimated_credits, estimated_tokens, status, expires_at, created_at FROM hwlab_billing_reservations WHERE user_id = $1 AND status = 'reserved' AND expires_at > now() ORDER BY expires_at ASC, created_at DESC LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1733,7 +2061,7 @@ func (s *Server) activeReservations(ctx context.Context, userID string, limit in
|
||||
items := []reservationSummaryRow{}
|
||||
for rows.Next() {
|
||||
var item reservationSummaryRow
|
||||
if err := rows.Scan(&item.ReservationID, &item.ServiceID, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&item.ReservationID, &item.ServiceID, &item.ResourceType, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
@@ -1819,6 +2147,9 @@ LIMIT $1`, limit)
|
||||
for i := range users {
|
||||
users[i].Reservations.Active = reservations[users[i].User.ID]
|
||||
}
|
||||
if err := s.enrichAdminBillingUsers(ctx, users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
@@ -1836,9 +2167,9 @@ func (s *Server) activeReservationsForUsers(ctx context.Context, userIDs []strin
|
||||
limitParam := "$" + strconv.Itoa(len(args)+1)
|
||||
args = append(args, perUserLimit)
|
||||
query := `
|
||||
SELECT user_id, id, service_id, estimated_credits, estimated_tokens, status, expires_at, created_at
|
||||
SELECT user_id, id, service_id, resource_type, estimated_credits, estimated_tokens, status, expires_at, created_at
|
||||
FROM (
|
||||
SELECT user_id, id, service_id, estimated_credits, estimated_tokens, status, expires_at, created_at,
|
||||
SELECT user_id, id, service_id, resource_type, estimated_credits, estimated_tokens, status, expires_at, created_at,
|
||||
row_number() OVER (PARTITION BY user_id ORDER BY expires_at ASC, created_at DESC) AS rn
|
||||
FROM hwlab_billing_reservations
|
||||
WHERE user_id IN (` + strings.Join(placeholders, ", ") + `) AND status = 'reserved' AND expires_at > now()
|
||||
@@ -1853,7 +2184,7 @@ ORDER BY expires_at ASC, created_at DESC`
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
var item reservationSummaryRow
|
||||
if err := rows.Scan(&userID, &item.ReservationID, &item.ServiceID, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&userID, &item.ReservationID, &item.ServiceID, &item.ResourceType, &item.EstimatedCredits, &item.EstimatedTokens, &item.Status, &item.ExpiresAt, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items[userID] = append(items[userID], item)
|
||||
@@ -2036,9 +2367,28 @@ LIMIT `+limitParam+` OFFSET `+offsetParam, args...)
|
||||
for i := range users {
|
||||
users[i].Reservations.Active = reservations[users[i].User.ID]
|
||||
}
|
||||
if err := s.enrichAdminBillingUsers(ctx, users); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func (s *Server) enrichAdminBillingUsers(ctx context.Context, users []adminBillingUserSummary) error {
|
||||
for i := range users {
|
||||
plan, err := s.planByID(ctx, s.db, users[i].PlanID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entitlements, err := s.resourceEntitlements(ctx, s.db, users[i].PlanID, users[i].User.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users[i].Plan = plan
|
||||
users[i].Entitlements = entitlements
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func adminUsersWhere(query adminUsersQuery) (string, []any) {
|
||||
clauses := []string{}
|
||||
args := []any{}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { fetchJson } from "./client";
|
||||
import type { AccessUserStatus, AdminBillingMutationResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, ApiResult, UsageSummary } from "@/types";
|
||||
import type { AccessUserStatus, AdminBillingMutationResponse, AdminBillingPlansResponse, AdminBillingSummary, AdminBillingUserDetailResponse, AdminBillingUsersResponse, ApiResult, UsageSummary } from "@/types";
|
||||
|
||||
export const usageAPI = {
|
||||
summary: (): Promise<ApiResult<UsageSummary>> => fetchJson("/v1/usage/summary", { timeoutMs: 12000, timeoutName: "usage summary" }),
|
||||
adminBillingSummary: (): Promise<ApiResult<AdminBillingSummary>> => fetchJson("/v1/admin/billing/summary", { timeoutMs: 12000, timeoutName: "admin billing summary" }),
|
||||
adminPlans: (): Promise<ApiResult<AdminBillingPlansResponse>> => fetchJson("/v1/admin/billing/plans", { timeoutMs: 12000, timeoutName: "admin billing plans" }),
|
||||
adminUsers: (params: { page?: number; pageSize?: number; search?: string; status?: string; role?: string } = {}): Promise<ApiResult<AdminBillingUsersResponse>> => {
|
||||
const query = new URLSearchParams({ page: String(params.page ?? 1), pageSize: String(params.pageSize ?? 25) });
|
||||
if (params.search) query.set("search", params.search);
|
||||
|
||||
@@ -320,14 +320,37 @@ export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: u
|
||||
export interface AdminAccessMatrixResponse { user?: AuthUser; tools?: Array<Record<string, unknown>>; [key: string]: unknown }
|
||||
export interface AdminAccessMutationResponse { ok?: boolean; user?: AuthUser; [key: string]: unknown }
|
||||
export interface ApiKeyRecord { id: string; name?: string; prefix?: string; createdAt?: string; lastUsedAt?: string | null; revokedAt?: string | null; displaySecret?: string | null }
|
||||
export interface UsageServiceSummary { serviceId: string; credits?: number; quantity?: number; recordCount?: number; lastUsedAt?: string }
|
||||
export interface UsageServiceSummary { serviceId: string; resourceType?: string; credits?: number; quantity?: number; recordCount?: number; lastUsedAt?: string }
|
||||
export interface UsageLedgerRow { id: string; kind?: string; reason?: string; deltaCredits?: number; balanceAfter?: number; createdAt?: string }
|
||||
export interface UsageReservationRow { reservationId: string; serviceId?: string; estimatedCredits?: number; estimatedTokens?: number; status?: string; expiresAt?: string; createdAt?: string }
|
||||
export interface UsageReservationRow { reservationId: string; serviceId?: string; resourceType?: string; estimatedCredits?: number; estimatedTokens?: number; status?: string; expiresAt?: string; createdAt?: string }
|
||||
export interface BillingPlanSummary { id?: string; displayName?: string; status?: string; description?: string; metadata?: Record<string, unknown> }
|
||||
export interface ResourceEntitlementSummary {
|
||||
id?: string;
|
||||
planId?: string;
|
||||
resourceType?: string;
|
||||
enabled?: boolean;
|
||||
monthlyQuotaCredits?: number;
|
||||
monthlyQuotaUnlimited?: boolean;
|
||||
monthlyUsageCredits?: number;
|
||||
monthlyRemainingCredits?: number;
|
||||
maxConcurrentReservations?: number;
|
||||
concurrencyUnlimited?: boolean;
|
||||
activeReservations?: number;
|
||||
rpmLimit?: number;
|
||||
rpmUnlimited?: boolean;
|
||||
rpmUsed?: number;
|
||||
limitSemantics?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
export interface BillingPlanDetail { plan?: BillingPlanSummary; entitlements?: ResourceEntitlementSummary[] }
|
||||
export interface UsageSummary {
|
||||
status?: string;
|
||||
contractVersion?: string;
|
||||
rows?: Array<Record<string, unknown>>;
|
||||
credits?: { balance?: number; reserved?: number; available?: number };
|
||||
planId?: string;
|
||||
plan?: BillingPlanSummary;
|
||||
entitlements?: ResourceEntitlementSummary[];
|
||||
usage?: { totalCredits?: number; totalQuantity?: number; recordCount?: number; byService?: UsageServiceSummary[] };
|
||||
ledger?: { count?: number; limit?: number; rows?: UsageLedgerRow[] };
|
||||
reservations?: { activeCount?: number; active?: UsageReservationRow[] };
|
||||
@@ -340,6 +363,8 @@ export interface UsageSummary {
|
||||
export interface AdminBillingUserSummary {
|
||||
user?: AuthUser & { email?: string; emailVerified?: boolean; createdAt?: string };
|
||||
planId?: string;
|
||||
plan?: BillingPlanSummary;
|
||||
entitlements?: ResourceEntitlementSummary[];
|
||||
credits?: { balance?: number; reserved?: number; available?: number };
|
||||
usage?: { totalCredits?: number; totalQuantity?: number; recordCount?: number; lastUsedAt?: string | null };
|
||||
apiKeys?: { activeCount?: number; totalCount?: number; lastUsedAt?: string | null };
|
||||
@@ -372,6 +397,16 @@ export interface AdminBillingUsersResponse {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AdminBillingPlansResponse {
|
||||
status?: string;
|
||||
contractVersion?: string;
|
||||
count?: number;
|
||||
plans?: BillingPlanDetail[];
|
||||
proxy?: Record<string, unknown>;
|
||||
valuesRedacted?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AdminBillingUserDetail {
|
||||
summary?: AdminBillingUserSummary;
|
||||
ledger?: { count?: number; rows?: UsageLedgerRow[] };
|
||||
|
||||
@@ -3,9 +3,10 @@ import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { usageAPI } from "@/api";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AuthUser } from "@/types";
|
||||
import type { AccessUserStatus, AdminBillingUserDetail, AdminBillingUserSummary, AuthUser, BillingPlanDetail, ResourceEntitlementSummary } from "@/types";
|
||||
|
||||
const users = ref<AdminBillingUserSummary[]>([]);
|
||||
const plans = ref<BillingPlanDetail[]>([]);
|
||||
const detail = ref<AdminBillingUserDetail | null>(null);
|
||||
const selectedUserId = ref<string | null>(null);
|
||||
const loading = ref(true);
|
||||
@@ -20,17 +21,26 @@ const stateAuthority = ref("pk01-postgres");
|
||||
|
||||
const filters = reactive({ search: "", status: "", role: "" });
|
||||
const createForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", initialCredits: 0 });
|
||||
const editForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active" });
|
||||
const editForm = reactive({ email: "", username: "", displayName: "", password: "", role: "user", status: "active", planId: "default" });
|
||||
const creditForm = reactive({ deltaCredits: 0, reason: "admin_adjust", idempotencyKey: "" });
|
||||
|
||||
const selectedSummary = computed(() => detail.value?.summary ?? users.value.find((item) => item.user?.id === selectedUserId.value) ?? null);
|
||||
const selectedUser = computed(() => selectedSummary.value?.user ?? null);
|
||||
const selectedEntitlements = computed<ResourceEntitlementSummary[]>(() => selectedSummary.value?.entitlements ?? []);
|
||||
const ledgerRows = computed(() => detail.value?.ledger?.rows ?? []);
|
||||
const apiKeys = computed(() => detail.value?.apiKeys?.keys ?? []);
|
||||
const reservations = computed(() => detail.value?.reservations?.rows ?? detail.value?.reservations?.active ?? selectedSummary.value?.reservations?.active ?? []);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)));
|
||||
|
||||
onMounted(refresh);
|
||||
onMounted(async () => {
|
||||
await loadPlans();
|
||||
await refresh();
|
||||
});
|
||||
|
||||
async function loadPlans(): Promise<void> {
|
||||
const response = await usageAPI.adminPlans();
|
||||
if (response.ok) plans.value = response.data?.plans ?? [];
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
loading.value = true;
|
||||
@@ -73,6 +83,7 @@ function fillEditForm(user: (AuthUser & { email?: string }) | null | undefined):
|
||||
editForm.password = "";
|
||||
editForm.role = user?.role === "admin" ? "admin" : "user";
|
||||
editForm.status = String(user?.status ?? "active");
|
||||
editForm.planId = selectedSummary.value?.planId ?? "default";
|
||||
}
|
||||
|
||||
async function createUser(): Promise<void> {
|
||||
@@ -104,7 +115,8 @@ async function saveUser(): Promise<void> {
|
||||
username: editForm.username.trim(),
|
||||
displayName: editForm.displayName.trim(),
|
||||
role: editForm.role,
|
||||
status: editForm.status
|
||||
status: editForm.status,
|
||||
planId: editForm.planId || "default"
|
||||
};
|
||||
if (editForm.password) body.password = editForm.password;
|
||||
const response = await usageAPI.updateAdminUser(userId, body);
|
||||
@@ -182,6 +194,10 @@ function formatDate(value: unknown): string {
|
||||
if (Number.isNaN(date.getTime())) return text;
|
||||
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function entitlementLimit(current: unknown, limit: unknown, unlimited: unknown): string {
|
||||
return `${formatNumber(current)} / ${unlimited ? "unlimited" : formatNumber(limit)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -258,6 +274,7 @@ function formatDate(value: unknown): string {
|
||||
<template v-else>
|
||||
<div class="overview-grid usage-metrics">
|
||||
<article class="metric-card"><span>Available</span><strong>{{ formatNumber(selectedSummary?.credits?.available) }}</strong></article>
|
||||
<article class="metric-card"><span>Plan</span><strong>{{ selectedSummary?.plan?.displayName || selectedSummary?.planId || "default" }}</strong></article>
|
||||
<article class="metric-card"><span>Usage credits</span><strong>{{ formatNumber(selectedSummary?.usage?.totalCredits) }}</strong></article>
|
||||
<article class="metric-card"><span>Active keys</span><strong>{{ formatNumber(selectedSummary?.apiKeys?.activeCount) }}</strong></article>
|
||||
</div>
|
||||
@@ -272,6 +289,7 @@ function formatDate(value: unknown): string {
|
||||
<label>重置密码<input v-model="editForm.password" class="input" type="password" autocomplete="new-password" placeholder="留空则不变" /></label>
|
||||
<label>角色<select v-model="editForm.role" class="input"><option value="user">user</option><option value="admin">admin</option></select></label>
|
||||
<label>状态<select v-model="editForm.status" class="input"><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select></label>
|
||||
<label class="span-2">计费 plan<select v-model="editForm.planId" class="input"><option v-for="item in plans" :key="item.plan?.id" :value="item.plan?.id">{{ item.plan?.displayName || item.plan?.id }} / {{ item.plan?.status }}</option></select></label>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="button" :disabled="pending === `save:${selectedUser?.id}`" @click="saveUser">{{ pending === `save:${selectedUser?.id}` ? "保存中" : "保存资料" }}</button>
|
||||
</section>
|
||||
@@ -287,6 +305,23 @@ function formatDate(value: unknown): string {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="detail-subpanel">
|
||||
<h3>资源授权</h3>
|
||||
<table v-if="selectedEntitlements.length" class="data-table usage-table">
|
||||
<thead><tr><th>Resource</th><th>Status</th><th>Monthly</th><th>Concurrent</th><th>RPM</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in selectedEntitlements" :key="item.resourceType">
|
||||
<td><code>{{ item.resourceType }}</code></td>
|
||||
<td><span class="status-pill" :data-status="item.enabled ? 'active' : 'disabled'">{{ item.enabled ? "enabled" : "disabled" }}</span></td>
|
||||
<td>{{ entitlementLimit(item.monthlyUsageCredits, item.monthlyQuotaCredits, item.monthlyQuotaUnlimited) }}</td>
|
||||
<td>{{ entitlementLimit(item.activeReservations, item.maxConcurrentReservations, item.concurrencyUnlimited) }}</td>
|
||||
<td>{{ entitlementLimit(item.rpmUsed, item.rpmLimit, item.rpmUnlimited) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted-box">当前用户 plan 没有资源授权记录。</p>
|
||||
</section>
|
||||
|
||||
<div class="usage-panels">
|
||||
<section class="detail-subpanel">
|
||||
<h3>API keys</h3>
|
||||
@@ -302,7 +337,7 @@ function formatDate(value: unknown): string {
|
||||
|
||||
<section class="detail-subpanel">
|
||||
<h3>Reservations</h3>
|
||||
<table v-if="reservations.length" class="data-table usage-table"><tbody><tr v-for="item in reservations" :key="item.reservationId"><td><code>{{ item.reservationId }}</code></td><td><code>{{ item.serviceId }}</code></td><td>{{ formatNumber(item.estimatedCredits) }}</td><td>{{ item.status }}</td><td>{{ formatDate(item.expiresAt) }}</td></tr></tbody></table>
|
||||
<table v-if="reservations.length" class="data-table usage-table"><tbody><tr v-for="item in reservations" :key="item.reservationId"><td><code>{{ item.reservationId }}</code></td><td><code>{{ item.serviceId }}</code><small>{{ item.resourceType || "generic" }}</small></td><td>{{ formatNumber(item.estimatedCredits) }}</td><td>{{ item.status }}</td><td>{{ formatDate(item.expiresAt) }}</td></tr></tbody></table>
|
||||
<p v-else class="muted-box">没有 active reservation。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,8 @@ const error = ref<string | null>(null);
|
||||
|
||||
const credits = computed(() => summary.value?.credits ?? {});
|
||||
const usage = computed(() => summary.value?.usage ?? {});
|
||||
const plan = computed(() => summary.value?.plan ?? {});
|
||||
const entitlements = computed(() => summary.value?.entitlements ?? []);
|
||||
const services = computed(() => usage.value.byService ?? []);
|
||||
const ledgerRows = computed(() => summary.value?.ledger?.rows ?? []);
|
||||
const reservations = computed(() => summary.value?.reservations?.active ?? []);
|
||||
@@ -57,6 +59,10 @@ function formatDate(value: unknown): string {
|
||||
<EmptyState v-else-if="!summary" title="暂无用量数据" description="当前账号还没有账务 summary。" />
|
||||
<template v-else>
|
||||
<div class="overview-grid usage-metrics">
|
||||
<article class="metric-card">
|
||||
<span>Plan</span>
|
||||
<strong>{{ plan.displayName || summary.planId || "default" }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>可用 credit</span>
|
||||
<strong>{{ formatNumber(credits.available) }}</strong>
|
||||
@@ -71,6 +77,34 @@ function formatDate(value: unknown): string {
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="data-panel usage-panel">
|
||||
<header class="panel-header">
|
||||
<h2>资源授权</h2>
|
||||
<small>{{ entitlements.length }} resources / {{ plan.status || "active" }}</small>
|
||||
</header>
|
||||
<table v-if="entitlements.length" class="data-table usage-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Resource</th>
|
||||
<th>Status</th>
|
||||
<th>Monthly</th>
|
||||
<th>Concurrent</th>
|
||||
<th>RPM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in entitlements" :key="item.resourceType">
|
||||
<td><code>{{ item.resourceType }}</code></td>
|
||||
<td><span class="status-pill" :data-status="item.enabled ? 'active' : 'disabled'">{{ item.enabled ? "enabled" : "disabled" }}</span></td>
|
||||
<td>{{ formatNumber(item.monthlyUsageCredits) }} / {{ item.monthlyQuotaUnlimited ? "unlimited" : formatNumber(item.monthlyQuotaCredits) }}</td>
|
||||
<td>{{ formatNumber(item.activeReservations) }} / {{ item.concurrencyUnlimited ? "unlimited" : formatNumber(item.maxConcurrentReservations) }}</td>
|
||||
<td>{{ formatNumber(item.rpmUsed) }} / {{ item.rpmUnlimited ? "unlimited" : formatNumber(item.rpmLimit) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted-box">当前 plan 没有资源授权记录。</p>
|
||||
</section>
|
||||
|
||||
<div class="usage-panels">
|
||||
<section class="data-panel usage-panel">
|
||||
<header class="panel-header">
|
||||
@@ -81,6 +115,7 @@ function formatDate(value: unknown): string {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Resource</th>
|
||||
<th>Credits</th>
|
||||
<th>Quantity</th>
|
||||
<th>Records</th>
|
||||
@@ -90,6 +125,7 @@ function formatDate(value: unknown): string {
|
||||
<tbody>
|
||||
<tr v-for="item in services" :key="item.serviceId">
|
||||
<td><code>{{ item.serviceId }}</code></td>
|
||||
<td><code>{{ item.resourceType || "generic" }}</code></td>
|
||||
<td>{{ formatNumber(item.credits) }}</td>
|
||||
<td>{{ formatNumber(item.quantity) }}</td>
|
||||
<td>{{ formatNumber(item.recordCount) }}</td>
|
||||
@@ -139,6 +175,7 @@ function formatDate(value: unknown): string {
|
||||
<tr>
|
||||
<th>Reservation</th>
|
||||
<th>Service</th>
|
||||
<th>Resource</th>
|
||||
<th>Credits</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
@@ -147,6 +184,7 @@ function formatDate(value: unknown): string {
|
||||
<tr v-for="item in reservations" :key="item.reservationId">
|
||||
<td><code>{{ item.reservationId }}</code></td>
|
||||
<td><code>{{ item.serviceId }}</code></td>
|
||||
<td><code>{{ item.resourceType || "generic" }}</code></td>
|
||||
<td>{{ formatNumber(item.estimatedCredits) }}</td>
|
||||
<td>{{ formatDate(item.expiresAt) }}</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user