feat: add v03 user billing entitlements

This commit is contained in:
lyon
2026-06-14 15:34:13 +08:00
parent ed80e6dd1b
commit c84ee72eb9
12 changed files with 712 additions and 46 deletions
+5
View File
@@ -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 成功后才创建 reservationrunning 阶段只保留 reservation,不提前扣费;completed 记录 usage/debitfailed、blocked、timeout、canceled 等未完成状态 release reservation。
新增 AIPOD/HWPOD 云托管产品时,产品维度、资源 id、reservation id、usage amount 和状态变迁应进入 user-billing 的 PK01 表结构或迁移;不要在 cloud-api 内新增平行计费表作为第二账本。
## Secret 与 Bootstrap
+2
View File
@@ -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, ""),
+8 -4
View File
@@ -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,
+16
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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{}
+2 -1
View File
@@ -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);
+37 -2
View File
@@ -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>