diff --git a/json/org_storage_limits.json b/json/org_storage_limits.json new file mode 100644 index 0000000..f40d581 --- /dev/null +++ b/json/org_storage_limits.json @@ -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" + } +} diff --git a/models/org_storage_limits.json b/models/org_storage_limits.json new file mode 100644 index 0000000..f40d581 --- /dev/null +++ b/models/org_storage_limits.json @@ -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" + } +} diff --git a/rag/__pycache__/init.cpython-310.pyc b/rag/__pycache__/init.cpython-310.pyc index f8b6738..3d78310 100644 Binary files a/rag/__pycache__/init.cpython-310.pyc and b/rag/__pycache__/init.cpython-310.pyc differ diff --git a/rag/init.py b/rag/init.py index 24624f4..1b94675 100644 --- a/rag/init.py +++ b/rag/init.py @@ -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 diff --git a/wwwroot/knowledge_bases_list/detail.ui b/wwwroot/knowledge_bases_list/detail.ui index e025145..d03bccd 100644 --- a/wwwroot/knowledge_bases_list/detail.ui +++ b/wwwroot/knowledge_bases_list/detail.ui @@ -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=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=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", diff --git a/wwwroot/knowledge_bases_list/storage_stats.dspy b/wwwroot/knowledge_bases_list/storage_stats.dspy index 93310af..d05e6d9 100644 --- a/wwwroot/knowledge_bases_list/storage_stats.dspy +++ b/wwwroot/knowledge_bases_list/storage_stats.dspy @@ -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) diff --git a/wwwroot/knowledge_bases_list/upload_file.dspy b/wwwroot/knowledge_bases_list/upload_file.dspy index dbde791..da29501 100644 --- a/wwwroot/knowledge_bases_list/upload_file.dspy +++ b/wwwroot/knowledge_bases_list/upload_file.dspy @@ -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()