feat: per-org storage quota enforcement — org_storage_limits table, upload rejection, UI display

This commit is contained in:
yumoqing 2026-08-04 15:36:53 +08:00
parent f160589370
commit 548fc6013e
7 changed files with 92 additions and 24 deletions

View File

@ -0,0 +1,17 @@
{
"tblname": "org_storage_limits",
"params": {
"title": "机构存储限额",
"browserfields": [
{"field": "org_id", "title": "机构ID", "width": "35%"},
{"field": "limit_bytes", "title": "限额(字节)", "width": "35%"},
{"field": "updated_at", "title": "更新时间", "width": "30%"}
],
"editfields": [
{"field": "org_id", "uitype": "Text", "required": true},
{"field": "limit_bytes", "uitype": "Text", "required": true, "default": "104857600"}
],
"searchfields": ["org_id"],
"sort": "updated_at desc"
}
}

View File

@ -0,0 +1,17 @@
{
"tblname": "org_storage_limits",
"params": {
"title": "机构存储限额",
"browserfields": [
{"field": "org_id", "title": "机构ID", "width": "35%"},
{"field": "limit_bytes", "title": "限额(字节)", "width": "35%"},
{"field": "updated_at", "title": "更新时间", "width": "30%"}
],
"editfields": [
{"field": "org_id", "uitype": "Text", "required": true},
{"field": "limit_bytes", "uitype": "Text", "required": true, "default": "104857600"}
],
"searchfields": ["org_id"],
"sort": "updated_at desc"
}
}

Binary file not shown.

View File

@ -285,6 +285,12 @@ async def _enrich_search_results(env, hits):
return hits
def _fmt_bytes(n):
if n < 1024: return str(n) + 'B'
if n < 1048576: return str(round(n/1024, 1)) + 'KB'
return str(round(n/1048576, 1)) + 'MB'
async def doc_upload_handler(request, params_kw, *args, **kwargs):
"""文件上传 → 保存 → DB记录 → 触发RAG入库"""
env = request._run_ns
@ -301,6 +307,20 @@ async def doc_upload_handler(request, params_kw, *args, **kwargs):
if not file_data:
return json.dumps({"error": "no file data"})
file_size = len(file_data)
# ---- ORG STORAGE QUOTA CHECK (per-org limit, not global) ----
quota_limit = 104857600
used = 0
async with get_sor_context(env, 'rag') as sor:
rec = await sor.sqlExe("SELECT COALESCE(SUM(file_size),0) AS used FROM documents WHERE org_id=${org_id}$", {"org_id": userorgid})
if rec: used = int(rec[0].used)
lim = await sor.sqlExe("SELECT limit_bytes FROM org_storage_limits WHERE org_id=${org_id}$", {"org_id": userorgid})
if lim: quota_limit = int(lim[0].limit_bytes)
if used + file_size > quota_limit:
return json.dumps({"error": "storage_quota_exceeded",
"message": "存储配额超限:机构已用 " + _fmt_bytes(used) + ",限额 " + _fmt_bytes(quota_limit) + ",本文件 " + _fmt_bytes(file_size)}, ensure_ascii=False)
# Save file
doc_id = uuid.uuid4().hex[:16]
ext = os.path.splitext(file_name)[1] or ".bin"
@ -311,7 +331,6 @@ async def doc_upload_handler(request, params_kw, *args, **kwargs):
with open(file_path, "wb") as f:
f.write(file_data)
file_size = len(file_data)
file_type = _detect_file_type(file_name, "application/octet-stream")
# Create document record

View File

@ -219,7 +219,7 @@
"event": "changed",
"actiontype": "script",
"target": "file_drop_zone",
"script": "var kb_id=this.opts.kb_id||'';var folder=window._rag_folder||'';if(folder==='__root__')folder='';var files=this.value;if(!files)return;if(!Array.isArray(files))files=[files];var st=document.getElementById('upload_status');var done=0;var total=files.length;function refreshList(){var fp=bricks.getWidgetById('file_list_panel',bricks.app.root);fp.clear_widgets();var url=location.origin+'/rag/knowledge_bases_list/file_list.dspy?_webbricks_=1&kb_id='+kb_id+(folder?'&id='+folder:'');bricks.widgetBuild({widgettype:'urlwidget',options:{url:url,css:'filler'}},fp).then(function(w){if(w)fp.add_widget(w)})};for(var i=0;i<files.length;i++){(function(f){var x=new XMLHttpRequest();x.open('POST','/rag/knowledge_bases_list/upload_file.dspy?kb_id='+encodeURIComponent(kb_id)+'&file_name='+encodeURIComponent(f.name)+'&folder='+encodeURIComponent(folder));x.onload=function(){done++;if(x.status===200){if(st)st.innerText='上传成功 '+done+'/'+total}else{if(st)st.innerText='上传失败'}if(done>=total)refreshList()};x.onerror=function(){done++;if(st)st.innerText='上传失败';if(done>=total)refreshList()};x.send(f)})(files[i])}"
"script": "var kb_id=this.opts.kb_id||'';var folder=window._rag_folder||'';if(folder==='__root__')folder='';var files=this.value;if(!files)return;if(!Array.isArray(files))files=[files];var st=document.getElementById('upload_status');var done=0;var total=files.length;function refreshList(){var fp=bricks.getWidgetById('file_list_panel',bricks.app.root);fp.clear_widgets();var url=location.origin+'/rag/knowledge_bases_list/file_list.dspy?_webbricks_=1&kb_id='+kb_id+(folder?'&id='+folder:'');bricks.widgetBuild({widgettype:'urlwidget',options:{url:url,css:'filler'}},fp).then(function(w){if(w)fp.add_widget(w)})};for(var i=0;i<files.length;i++){(function(f){var x=new XMLHttpRequest();x.open('POST','/rag/knowledge_bases_list/upload_file.dspy?kb_id='+encodeURIComponent(kb_id)+'&file_name='+encodeURIComponent(f.name)+'&folder='+encodeURIComponent(folder));x.onload=function(){done++;var ok=x.status===200;var msg='';try{var r=JSON.parse(x.responseText);if(r&&(r.status==='error'||r.error)){ok=false;msg=r.message||r.error||'上传失败'}}catch(e){}if(st){st.innerText=ok?('上传成功 '+done+'/'+total):(msg||'上传失败');st.style.color=ok?'#50b86c':'#f56c6c'}if(done>=total)refreshList()};x.onerror=function(){done++;if(st){st.innerText='上传失败';st.style.color='#f56c6c'}if(done>=total)refreshList()};x.send(f)})(files[i])}"
},
{
"wid": "dir_tree",

View File

@ -1,42 +1,40 @@
ns = params_kw.copy()
env = request._run_ns
userorgid = await env.get_userorgid()
# Per-org metering: documents.file_size by org_id (auto-decreases on delete)
used = 0
limit = 104857600
docs = 0
async with get_sor_context(env, 'rag') as sor:
kb_id = ns.get('kb_id', '')
if kb_id:
recs = await sor.sqlExe("SELECT doc_count, total_size, chunk_count FROM knowledge_bases WHERE id=${id}$", {"id": kb_id})
else:
recs = await sor.sqlExe("SELECT COALESCE(SUM(doc_count),0) AS doc_count, COALESCE(SUM(total_size),0) AS total_size, COALESCE(SUM(chunk_count),0) AS chunk_count FROM knowledge_bases", {})
if recs:
r = recs[0]
docs = int(r.doc_count or 0)
size = int(r.total_size or 0)
chunks = int(r.chunk_count or 0)
else:
docs = 0
size = 0
chunks = 0
rec = await sor.sqlExe(
"SELECT COALESCE(SUM(file_size),0) AS used, COUNT(*) AS docs FROM documents WHERE org_id=${org_id}$",
{"org_id": userorgid})
if rec:
used = int(rec[0].used)
docs = int(rec[0].docs)
lim = await sor.sqlExe("SELECT limit_bytes FROM org_storage_limits WHERE org_id=${org_id}$", {"org_id": userorgid})
if lim:
limit = int(lim[0].limit_bytes)
def fmt(sz):
if sz < 1024: return str(sz) + 'B'
elif sz < 1024*1024: return str(round(sz/1024,1)) + 'KB'
else: return str(round(sz/(1024*1024),1)) + 'MB'
pct = min(round(size / (100*1024*1024) * 100, 1), 100) if size > 0 else 0
if pct > 100:
pct = 100
pct = min(round(used / max(limit, 1) * 100, 1), 100) if used > 0 else 0
bar_color = "#f56c6c" if pct >= 90 else ("#e6a23c" if pct >= 70 else "#4a90d9")
return json.dumps({
"widgettype": "VBox",
"options": {"width": "100%", "bgcolor": "#f8f9fa", "padding": "16px", "css": "card", "spacing": "4px"},
"subwidgets": [
{"widgettype": "HBox", "options": {"width": "100%", "justifyContent": "space-between"}, "subwidgets": [
{"widgettype": "Text", "options": {"text": "💾 存储容量", "cfontsize": 14, "fontWeight": "bold", "color": "#333"}},
{"widgettype": "Text", "options": {"text": f"已用 {fmt(size)} / 总计 100MB | {docs} 文档 {chunks} 块", "cfontsize": 12, "color": "#888"}}
{"widgettype": "Text", "options": {"text": "💾 存储容量(本机构)", "cfontsize": 14, "fontWeight": "bold", "color": "#333"}},
{"widgettype": "Text", "options": {"text": "已用 " + fmt(used) + " / 限额 " + fmt(limit) + " | " + str(docs) + " 文档", "cfontsize": 12, "color": "#888"}}
]},
{"widgettype": "VBox", "options": {"width": "100%", "cheight": 0.5, "bgcolor": "#e0e0e0"}, "subwidgets": [
{"widgettype": "VBox", "options": {"cwidth": pct, "cheight": 0.5, "bgcolor": "#4a90d9"}}
{"widgettype": "VBox", "options": {"cwidth": pct, "cheight": 0.5, "bgcolor": bar_color}}
]}
]
}, ensure_ascii=False, default=str)

View File

@ -12,13 +12,30 @@ if not file_data:
env = request._run_ns
userorgid = await env.get_userorgid()
file_size = len(file_data)
# ---- ORG STORAGE QUOTA CHECK (per-org limit, not global) ----
def fmt_bytes(n):
if n < 1024: return str(n) + 'B'
if n < 1048576: return str(round(n/1024, 1)) + 'KB'
return str(round(n/1048576, 1)) + 'MB'
quota_limit = 104857600
used = 0
async with get_sor_context(env, 'rag') as sor:
rec = await sor.sqlExe("SELECT COALESCE(SUM(file_size),0) AS used FROM documents WHERE org_id=${org_id}$", {"org_id": userorgid})
if rec: used = int(rec[0].used)
lim = await sor.sqlExe("SELECT limit_bytes FROM org_storage_limits WHERE org_id=${org_id}$", {"org_id": userorgid})
if lim: quota_limit = int(lim[0].limit_bytes)
if used + file_size > quota_limit:
return json.dumps({"status": "error", "error": "storage_quota_exceeded",
"message": "存储配额超限:机构已用 " + fmt_bytes(used) + ",限额 " + fmt_bytes(quota_limit) + ",本文件 " + fmt_bytes(file_size)}, ensure_ascii=False)
# Save file via FileStorage (returns web path e.g. /idfile/191/193/197/97/xxx.txt)
web_path = await env.save_file(file_data, file_name)
real_path = env.realpath(web_path)
doc_id = str(uuid()).replace('-', '')[:16]
file_size = len(file_data)
ext = '.' + file_name.rsplit('.', 1)[1] if '.' in file_name else '.bin'
ext_l = ext.lower()