diff --git a/cloud/README.md b/cloud/README.md
index 5f3f96e..caf0add 100644
--- a/cloud/README.md
+++ b/cloud/README.md
@@ -871,6 +871,47 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
`/tasks`、`/projects`、`/workers`、`/health` 同款,否则页面能打开
但环境区永远加载失败)。
+## 租户配额与计费
+
+- 口径:配额是按用户、按 UTC 自然月的 USD 上限(`user_quotas`
+ 表,`quota_usd_micros` 为 NULL 表示不限额,0 表示一分钱也不
+ 能花;金额单位与 cost_records 一致,都是微美元,避免浮点)。
+ 用量只算 `usd_micros` 非空的实际成本行(estimated 行 usd 为
+ NULL,SUM 自动跳过),按任务创建者 `created_by` 归属——一个
+ 用户在多个项目里花的钱都算进他自己的月度账单;项目维度的
+ 拆分由项目成本接口另行提供。
+- 准入:建任务、会话分支、重试在权限校验通过后、写库之前查
+ 配额,本月用量 >= 配额时回 402 `quota_exceeded`(附 month、
+ quota_usd_micros、used_usd_micros),不建任务、不改状态。
+ 老用户与未设配额的用户默认不限额,行为与过去一致。注意
+ 边界:只做准入校验,不做跑中掐断——已在跑的回合、后续消
+ 息触发的新一轮中途超限不会被停掉(worker 侧没有按金额中途
+ 停转的钩子);重试按操作人查配额、费用仍记到原任务创建者
+ 名下(建任务与分支的操作人就是创建者,没有这个错位)。
+ 系统任务(GitHub webhook 的 /fix-review,created_by
+ 是 'system')不记到任何用户名下、不占配额;要把它算到项
+ 目所有者头上需另做归属。
+- 接口:`GET /users/me/quota`(自查,返回 month、quota、
+ used、remaining,不限额时 quota 与 remaining 为 null);
+ `GET /admin/quotas?user_id=` 与 `POST /admin/quotas`
+ (body:user_id、quota_usd_micros,null=不限额,负数 400,
+ 用户不存在 404,改完记 `quota_set` 审计)。管理口径沿用
+ `/admin/backup` 的做法:在任一项目里是 maintainer/owner 即
+ 算管理员,没有任何项目角色的用户无权改任何人的配额。
+ 这还不是真正的收款:没有接支付网关、不开发票、不扣款,
+ 只有记账 + 配额 + 超限拦截;要收钱需另接支付(价格表在
+ `cloud/cost.py`,注意那是 OpenAI 牌价、不是中转站的账单)。
+- 命令台:API Key 卡下方显示本月用量与配额(10 秒自动刷新;
+ 超限建任务时接口的 402 会带“本月配额已用完”提示)。管理
+ 员改配额目前只走 API,控制台没有改配额的表单。
+- 公网 nginx:注意,443 块的默认 `location /` 是代理到主机
+ `127.0.0.1:28080`(不是控制面),而 `/users`、`/admin`
+ 目前没有专属 location——配额接口部署上生产时须同批加
+ `location ^~ /users/me/quota` 与 `location ^~ /admin/quotas`
+ (只放这两个精确前缀,不要整段放行 `/users` 或 `/admin`,
+ 否则会把 `/admin/sweep`、`/admin/backup` 也暴露到公网),
+ 否则公网页面调配额接口会落到 28080 而不是控制面。
+
## 运维批(本轮新增:部署、恢复演练、监控、心跳、多用户验证)
细则在 `deploy/README.md`,这里只记代码侧的接口与默认值:
diff --git a/cloud/console.html b/cloud/console.html
index 99db94c..e6c1988 100644
--- a/cloud/console.html
+++ b/cloud/console.html
@@ -87,6 +87,7 @@
+
未加载
@@ -292,6 +293,7 @@ async function api(path, method, body) {
var msg = (data && data.error) ? data.error : ("HTTP " + r.status);
if (r.status === 401) msg += "(key 未填或无效)";
if (r.status === 429) msg += "(触发限流,稍后再试)";
+ if (r.status === 402) msg += "(本月配额已用完,联系管理员调整)";
throw new Error(msg);
}
return data;
@@ -324,6 +326,23 @@ async function loadHealth() {
} catch (e) { $("#health").textContent = "加载失败:" + e.message; }
}
+async function loadQuota() {
+ // 本月配额与用量:只读展示,管理员改配额走 API(/admin/quotas)。
+ var el = $("#quotaState");
+ if (!getKey()) { el.textContent = "未填 key,配额不显示"; return; }
+ try {
+ var d = await api("/users/me/quota", "GET");
+ var used = (d.used_usd_micros / 1000000).toFixed(2);
+ if (d.quota_usd_micros === null || d.quota_usd_micros === undefined) {
+ el.textContent = "本月用量 $" + used + "(不限额)";
+ } else {
+ var q = (d.quota_usd_micros / 1000000).toFixed(2);
+ var rem = (d.remaining_usd_micros / 1000000).toFixed(2);
+ el.textContent = "本月用量 $" + used + " / $" + q + "(剩余 $" + rem + ")";
+ }
+ } catch (e) { el.textContent = "配额加载失败:" + e.message; }
+}
+
async function loadProjects() {
try {
var d = await api("/projects", "GET");
@@ -610,6 +629,7 @@ $("#saveKey").onclick = function () {
loadWorkers();
if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); }
if (getKey()) loadRelayModels();
+ loadQuota();
};
$("#clearKey").onclick = function () { localStorage.removeItem(KEY_STORE); setKeyState(); };
$("#refreshHealth").onclick = loadHealth;
@@ -789,11 +809,12 @@ setKeyState();
loadHealth();
loadProjects();
loadWorkers();
+loadQuota();
// 回访用户 key 已在 localStorage 里:初始化也要加载模型列表,
// 否则下拉永远只有默认项(此前只在点“保存”时调一次,
// 2026-10-27 实测:存过 key 后重开页面模型选不了)。
if (getKey()) loadRelayModels();
-setInterval(function () { if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); } loadHealth(); }, 10000);
+setInterval(function () { if ($("#projectSelect").value) { loadTasks(); loadEnvironments(); } loadHealth(); loadQuota(); }, 10000);