Merge pull request #71 from openqj/feat-quotas

租户配额与计费:按用户月度 USD 上限 + 建任务/分支/重试准入 402
This commit is contained in:
openqj authored and GitHub committed 2026-10-27 05:28:25 +08:00
commit 43748804d3
5 files changed
+496 -1

No files matched your search

+41
View File
@@ -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`,这里只记代码侧的接口与默认值:
+22 -1
View File
@@ -87,6 +87,7 @@
<button id="clearKey">清除</button>
<span id="keyState" class="muted"></span>
</div>
<div><span id="quotaState" class="muted">未加载</span></div>
</section>
<section class="card">
@@ -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);
</script>
</body>
</html>
+4
View File
@@ -885,6 +885,10 @@ POSTGRES_SCHEMA = (
"CREATE UNIQUE INDEX IF NOT EXISTS idx_env_name"
" ON environments(project_id, name)"
" WHERE status != 'archived'",
"CREATE TABLE IF NOT EXISTS user_quotas("
"user_id TEXT PRIMARY KEY, "
"quota_usd_micros BIGINT, "
"updated_at DOUBLE PRECISION)",
"CREATE TABLE IF NOT EXISTS summaries("
"id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "
"project_id TEXT, "
+190
View File
@@ -623,6 +623,19 @@ def _ensure_environments_table(conn):
" WHERE status != 'archived'")
def _ensure_quotas_table(conn):
"""幂等建配额表:server init_db 与配额查询都可调它,老库
第一次跑到这里时自动补表,不丢数据。quota_usd_micros 为
NULL 表示不限额(与 0 区分:0 是一分钱也不能花);金额
单位与 cost_records 一致,都是 USD 微单位(1 美元 =
1_000_000),避免浮点。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS user_quotas("
"user_id TEXT PRIMARY KEY, "
"quota_usd_micros BIGINT, "
"updated_at DOUBLE PRECISION)")
_ENV_BRANCH_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$")
@@ -683,6 +696,7 @@ def init_db(db_path):
migrate_projects_table(conn)
_ensure_followups_table(conn)
_ensure_environments_table(conn)
_ensure_quotas_table(conn)
_ensure_uploads_tables(conn)
conn.commit()
return
@@ -714,6 +728,7 @@ def init_db(db_path):
migrate_projects_table(conn)
_ensure_followups_table(conn)
_ensure_environments_table(conn)
_ensure_quotas_table(conn)
_ensure_uploads_tables(conn)
# worker 每轮 poll 都按 status 取任务(见 load_tasks 的
# WHERE status IN (...)),无索引时是全表扫描;任务表随
@@ -1098,6 +1113,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._archive_environment(user_id, rest[: -len("/archive")])
else:
self._send_json(404, {"error": "not found"})
elif path == "/users/me/quota" and method == "GET":
self._user_quota_self(user_id)
elif path == "/tasks" and method == "POST":
self._create_task(user_id)
elif path == "/uploads" and method == "POST":
@@ -1108,6 +1125,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._admin_sweep()
elif path == "/admin/backup" and method == "POST":
self._admin_backup(user_id)
elif path == "/admin/quotas" and method == "GET":
self._admin_quota_get(user_id, query)
elif path == "/admin/quotas" and method == "POST":
self._admin_quota_set(user_id)
elif path == "/admin/push/dispatch" and method == "POST":
self._push_dispatch_endpoint(user_id)
elif path.startswith("/tasks/"):
@@ -2017,6 +2038,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if not store.check_permission(user_id, project_id, "create_task"):
self._send_json(403, {"error": "forbidden"})
return
if self._reject_if_over_quota(user_id):
return
# 环境绑定:environment_id 非空时必须是本项目、已 ready
# 的环境;creating/failed/archived 一律拒绝并把状态写进
# 报错(creating 时用户就知道该等 worker 准备好再建任务,
@@ -2466,6 +2489,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
user_id, task["project_id"], "create_task"):
self._send_json(403, {"error": "forbidden"})
return
if self._reject_if_over_quota(user_id):
return
message = body.get("message")
if not message or not isinstance(message, str) \
or not message.strip():
@@ -2800,6 +2825,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
if self._reject_if_over_quota(user_id):
return
if task["status"] not in ("failed", "interrupted", "merge-failed"):
self._send_json(409, {
"error": "only failed/interrupted tasks can be retried",
@@ -2834,6 +2861,169 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
store.audit(user_id, task["project_id"], "task_retried", task_id)
self._send_json(200, task)
# -- 租户配额与计费 ----------------------------------------------
# 计费口径:只算 cost_records 里 usd_micros 非空的实际成本
# 行(estimated 行 usd 为 NULL,SUM 自动跳过,不算已花
# 的钱);归属按任务创建者 created_by,不按项目——一个
# 用户在多个项目里花的钱都算进他自己的月度账单,项目维
# 度的拆分由 _project_cost 另行提供。窗口是 UTC 自然月,
# 与 cost_records.day(UTC 日期)同口径。
def _quota_month(self):
from datetime import datetime, timezone
return datetime.now(timezone.utc).strftime("%Y-%m")
def _quota_status(self, user_id):
"""返回 (quota_usd_micros|None, used_usd_micros, month)。
quota 为 None 表示不限额:老用户与未设配额的用户默认
不限额,行为与过去一致,不会因为上了配额功能就突然
被挡住。"""
month = self._quota_month()
conn = self._db()
try:
_ensure_quotas_table(conn)
conn.commit()
# cost_records 由 CostStore 懒建:全新库上它还不存在,
# 直接 JOIN 会报 no such table——先实例化一次建表
# (与 _project_cost 同款做法),再跑配额查询。
_cost_store = CostStore(self.server.db_path)
try:
_cost_store._conn.close()
except Exception:
pass
row = conn.execute(
"SELECT quota_usd_micros FROM user_quotas"
" WHERE user_id = ?",
(user_id,)).fetchone()
quota = row[0] if row is not None else None
used = conn.execute(
"SELECT COALESCE(SUM(c.usd_micros), 0)"
" FROM cost_records c JOIN tasks t ON c.task_id = t.id"
" WHERE t.created_by = ? AND c.day LIKE ?",
(user_id, month + "%")).fetchone()[0]
return quota, int(used or 0), month
finally:
conn.close()
def _quota_payload(self, user_id, quota, used, month):
return {"user_id": user_id, "month": month,
"quota_usd_micros": quota, "used_usd_micros": used,
"remaining_usd_micros": (quota - used)
if quota is not None else None}
def _check_quota(self, user_id):
"""配额准入:放行返回 None;超限返回 (quota, used,
month),调用方一律回 402——这是钱用完了,不是请求
太快,所以不用 429。注意:只做建任务/分支/重跑时的
准入校验;跑起来的回合中途超限不会被掐断(worker
侧没有按金额中途停转的钩子,那是下一步的事)。"""
quota, used, month = self._quota_status(user_id)
if quota is not None and used >= quota:
return quota, used, month
return None
def _reject_if_over_quota(self, user_id):
blocked = self._check_quota(user_id)
if blocked is not None:
quota, used, month = blocked
self._send_json(402, {"error": "quota_exceeded",
"month": month,
"quota_usd_micros": quota,
"used_usd_micros": used})
return True
return False
def _is_global_maintainer(self, user_id):
"""全局管理判定:沿用 _admin_backup 的口径——在任一
项目里是 maintainer/owner 即算(没有单设的全局管理
员表;没有任何项目角色的用户无权改任何人的配额)。"""
conn = self._db()
try:
row = conn.execute(
"SELECT COUNT(*) FROM project_members"
" WHERE user_id = ? AND role IN ('maintainer', 'owner')",
(user_id,)).fetchone()
return (row[0] if row is not None else 0) > 0
finally:
conn.close()
def _user_exists(self, target):
conn = self._db()
try:
return conn.execute(
"SELECT 1 FROM users WHERE id = ?",
(target,)).fetchone() is not None
finally:
conn.close()
def _user_quota_self(self, user_id):
quota, used, month = self._quota_status(user_id)
self._send_json(200,
self._quota_payload(user_id, quota, used, month))
def _admin_quota_get(self, user_id, query):
if not self._is_global_maintainer(user_id):
self._send_json(403, {"error": "forbidden"})
return
target = (query.get("user_id") or [None])[0]
if not target or not isinstance(target, str):
self._send_json(400, {"error": "user_id required"})
return
if not self._user_exists(target):
self._send_json(404, {"error": "user not found"})
return
quota, used, month = self._quota_status(target)
self._send_json(200,
self._quota_payload(target, quota, used, month))
def _admin_quota_set(self, user_id):
if not self._is_global_maintainer(user_id):
self._send_json(403, {"error": "forbidden"})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
target = body.get("user_id")
if not target or not isinstance(target, str):
self._send_json(400, {"error": "user_id required"})
return
if not self._user_exists(target):
self._send_json(404, {"error": "user not found"})
return
quota = body.get("quota_usd_micros")
if quota is not None:
try:
quota = int(quota)
except (TypeError, ValueError):
self._send_json(400, {
"error": "quota_usd_micros must be an integer or null"})
return
if quota < 0:
self._send_json(400, {
"error": "quota_usd_micros must be >= 0"})
return
now = time.time()
conn = self._db()
try:
cur = conn.execute(
"UPDATE user_quotas SET quota_usd_micros = ?,"
" updated_at = ? WHERE user_id = ?",
(quota, now, target))
if cur.rowcount != 1:
conn.execute(
"INSERT INTO user_quotas(user_id, quota_usd_micros,"
" updated_at) VALUES(?, ?, ?)",
(target, quota, now))
conn.commit()
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, None, "quota_set",
"%s:%s" % (target, quota))
quota, used, month = self._quota_status(target)
self._send_json(200,
self._quota_payload(target, quota, used, month))
# -- worker 池 -----------------------------------------------------
def _workers_list(self, user_id):
"""列出注册 worker:任何已认证用户可读(worker 是基础设施
+239
View File
@@ -0,0 +1,239 @@
"""租户配额与计费单测:月度 USD 上限 + 准入 402 + 管理端点。
不跑真实模型回合、不联网:server 侧走本地临时端口 + 临时
SQLite;用量直接插 cost_records 行(按任务 created_by 归属、
UTC day),验证 JOIN 归属、自然月窗口与三路准入(建任务 /
分支 / 重试)。配额单位是微美元(1 美元 = 1_000_000),
与 cost_records 一致。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import unittest
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.server import create_server
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.server import create_server
def _req(port, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _set_task_status(db_path, task_id, status):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
(status, task_id))
conn.commit()
finally:
conn.close()
def _insert_cost(db_path, task_id, project_id, usd_micros, day):
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO cost_records(task_id, project_id, model,"
" input_tokens, output_tokens, total_tokens, usd_micros,"
" source, day, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(task_id, project_id, "gpt-5.6-terra", 100, 50, 150,
usd_micros, "actual", day, 0))
conn.commit()
finally:
conn.close()
def _utc_today():
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _prev_month_day():
today = date.today()
first = today.replace(day=1)
return (first - timedelta(days=1)).isoformat()
class ServerQuotasTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.other_id, self.other_key = self.auth.create_user("other")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
s, proj = _req(self.port, "POST", "/projects",
{"name": "qproj"}, self.owner_key)
self.assertEqual(s, 201, proj)
self.project_id = proj["id"]
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.tmp.cleanup()
def _create_task(self, key=None):
return _req(self.port, "POST", "/tasks",
{"project_id": self.project_id,
"prompt": "quota probe"},
key if key is not None else self.owner_key)
def _set_quota(self, target, quota, key=None):
return _req(self.port, "POST", "/admin/quotas",
{"user_id": target, "quota_usd_micros": quota},
key if key is not None else self.owner_key)
def test_self_quota_default_unlimited(self):
s, q = _req(self.port, "GET", "/users/me/quota",
key=self.owner_key)
self.assertEqual(s, 200, q)
self.assertIsNone(q["quota_usd_micros"])
self.assertEqual(q["used_usd_micros"], 0)
self.assertIsNone(q["remaining_usd_micros"])
self.assertEqual(q["month"],
datetime.now(timezone.utc).strftime("%Y-%m"))
def test_admin_set_and_get(self):
s, q = self._set_quota(self.owner_id, 5000000)
self.assertEqual(s, 200, q)
self.assertEqual(q["quota_usd_micros"], 5000000)
s, q = _req(self.port, "GET",
"/admin/quotas?user_id=" + self.owner_id,
key=self.owner_key)
self.assertEqual(s, 200, q)
self.assertEqual(q["quota_usd_micros"], 5000000)
s, q = _req(self.port, "GET", "/users/me/quota",
key=self.owner_key)
self.assertEqual(q["quota_usd_micros"], 5000000)
# null = 恢复不限额
s, q = self._set_quota(self.owner_id, None)
self.assertEqual(s, 200, q)
self.assertIsNone(q["quota_usd_micros"])
def test_admin_set_forbidden_and_validation(self):
# other 没有任何项目角色,不是管理员
s, q = self._set_quota(self.owner_id, 100, self.other_key)
self.assertEqual(s, 403, q)
s, q = _req(self.port, "GET",
"/admin/quotas?user_id=" + self.owner_id,
key=self.other_key)
self.assertEqual(s, 403, q)
s, q = self._set_quota(self.owner_id, -1)
self.assertEqual(s, 400, q)
s, q = self._set_quota("no-such-user", 100)
self.assertEqual(s, 404, q)
s, q = _req(self.port, "POST", "/admin/quotas",
{"quota_usd_micros": 100}, self.owner_key)
self.assertEqual(s, 400, q)
s, q = _req(self.port, "GET", "/admin/quotas",
key=self.owner_key)
self.assertEqual(s, 400, q)
def test_usage_counts_current_month_only(self):
s, task = self._create_task()
self.assertEqual(s, 201, task)
_insert_cost(self.db_path, task["id"], self.project_id,
5000000, _utc_today())
_insert_cost(self.db_path, task["id"], self.project_id,
100000000, _prev_month_day())
s, q = _req(self.port, "GET", "/users/me/quota",
key=self.owner_key)
self.assertEqual(s, 200, q)
self.assertEqual(q["used_usd_micros"], 5000000)
def test_create_blocked_at_quota(self):
s, task = self._create_task()
self.assertEqual(s, 201, task)
_insert_cost(self.db_path, task["id"], self.project_id,
5000000, _utc_today())
s, _ = self._set_quota(self.owner_id, 5000000)
self.assertEqual(s, 200)
s, body = self._create_task()
self.assertEqual(s, 402, body)
self.assertEqual(body["error"], "quota_exceeded")
self.assertEqual(body["quota_usd_micros"], 5000000)
self.assertEqual(body["used_usd_micros"], 5000000)
# 额度调高后放行
s, _ = self._set_quota(self.owner_id, 6000000)
self.assertEqual(s, 200)
s, task2 = self._create_task()
self.assertEqual(s, 201, task2)
def test_fork_blocked_at_quota(self):
s, task = self._create_task()
self.assertEqual(s, 201, task)
_insert_cost(self.db_path, task["id"], self.project_id,
3000000, _utc_today())
_set_task_status(self.db_path, task["id"], "completed")
s, _ = self._set_quota(self.owner_id, 3000000)
self.assertEqual(s, 200)
s, body = _req(self.port, "POST",
"/tasks/%s/fork" % task["id"],
{"message": "branch probe"}, self.owner_key)
self.assertEqual(s, 402, body)
s, _ = self._set_quota(self.owner_id, None)
self.assertEqual(s, 200)
s, forked = _req(self.port, "POST",
"/tasks/%s/fork" % task["id"],
{"message": "branch probe"}, self.owner_key)
self.assertEqual(s, 201, forked)
def test_retry_blocked_at_quota(self):
s, task = self._create_task()
self.assertEqual(s, 201, task)
_insert_cost(self.db_path, task["id"], self.project_id,
2000000, _utc_today())
_set_task_status(self.db_path, task["id"], "failed")
s, _ = self._set_quota(self.owner_id, 2000000)
self.assertEqual(s, 200)
s, body = _req(self.port, "POST",
"/tasks/%s/retry" % task["id"], {},
self.owner_key)
self.assertEqual(s, 402, body)
s, row = _req(self.port, "GET", "/tasks/" + task["id"],
key=self.owner_key)
self.assertEqual(row["status"], "failed")
s, _ = self._set_quota(self.owner_id, None)
self.assertEqual(s, 200)
s, retried = _req(self.port, "POST",
"/tasks/%s/retry" % task["id"], {},
self.owner_key)
self.assertEqual(s, 200, retried)
self.assertEqual(retried["status"], "queued")
if __name__ == "__main__":
unittest.main()