From 5f0b1f9409328a416f0e32179f5d5e45d66d2bc0 Mon Sep 17 00:00:00 2001 From: NathanAlex7 <18898599977@163.com> Date: Mon, 14 Sep 2026 13:18:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E4=B8=8B=E6=8B=89=E5=B9=B6=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E6=9D=83=E9=99=90=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/risk_agent_access.py | 20 +++++++++++++++++--- backend/test_risk_agent_completion.py | 14 ++++++++++++++ deploy/aliyun.env.example | 3 +++ deploy/docker-compose.aliyun.yml | 2 ++ docker-compose.yml | 2 ++ scripts/test_chat_stream.cjs | 7 +++++++ src/app/api/risk-agent/[...path]/route.ts | 2 +- src/app/collateral-workbench/page.tsx | 4 ++-- src/app/data-access/Enhancements.tsx | 13 ++++++++++++- src/app/data-access/page.tsx | 3 ++- src/app/operations-center/page.tsx | 4 ++-- src/app/report-workbench/page.tsx | 2 +- src/app/review-workbench/page.tsx | 2 +- src/app/xingbang-workbench/page.tsx | 2 +- src/components/chat/ChatInput.tsx | 2 +- 15 files changed, 68 insertions(+), 14 deletions(-) diff --git a/backend/api/risk_agent_access.py b/backend/api/risk_agent_access.py index 6853748..f267e8f 100644 --- a/backend/api/risk_agent_access.py +++ b/backend/api/risk_agent_access.py @@ -6,16 +6,29 @@ from urllib.parse import urlsplit from fastapi import HTTPException, Request +ROLES = ("admin", "reviewer", "manager") +DEFAULT_DEMO_PROJECTS = "DEMO-A,DEMO-B,DEMO-C,LAB-001,XB-2026-001" + + def authorize(request: Request): configured = os.getenv("XINGBANG_AUTH_USERS", "") - if not configured: + demo_role = request.headers.get("x-xingbang-demo-role", "").strip() + if demo_role: + forwarded_host = request.headers.get("x-xingbang-client-host", request.headers.get("host", "")) + hostname = urlsplit("//"+forwarded_host).hostname + local_preview = not configured and os.getenv("XINGBANG_REQUIRE_AUTH") != "1" and request.client and request.client.host in ("127.0.0.1", "::1", "testclient") and hostname in ("localhost", "127.0.0.1", "::1", "testserver") + if not (os.getenv("XINGBANG_DEMO_ROLE_LOGIN") == "1" or local_preview) or demo_role not in ROLES: + raise HTTPException(401, "演示身份未启用或身份无效") + projects = [item.strip() for item in os.getenv("XINGBANG_DEMO_PROJECTS", DEFAULT_DEMO_PROJECTS).split(",") if item.strip()] + user = {"id": f"demo-{demo_role}", "role": demo_role, "projects": projects, "preview": False, "demo": True} + elif not configured: forwarded_host = request.headers.get("x-xingbang-client-host", request.headers.get("host", "")) hostname = urlsplit("//"+forwarded_host).hostname if os.getenv("XINGBANG_REQUIRE_AUTH") == "1" or not request.client or request.client.host not in ("127.0.0.1", "::1", "testclient"): raise HTTPException(401, "请配置服务器身份认证后访问") if hostname not in ("localhost", "127.0.0.1", "::1", "testserver"): raise HTTPException(401, "本机预览仅接受回环地址;远程访问必须配置身份认证") - user = {"id": "local-preview", "role": "admin", "projects": ["*"], "preview": True} + user = {"id": "local-preview", "role": "admin", "projects": ["*"], "preview": True, "demo": True} else: try: accounts = json.loads(configured) @@ -23,10 +36,11 @@ def authorize(request: Request): raise HTTPException(503, "身份配置无效") supplied = request.headers.get("authorization", "").removeprefix("Bearer ") user = next((dict(u) for u in accounts if isinstance(u.get("token"), str) and hmac.compare_digest(u["token"], supplied)), None) - if not user or user.get("role") not in ("admin", "reviewer", "manager"): + if not user or user.get("role") not in ROLES: raise HTTPException(401, "访问令牌无效") user.pop("token", None) user["preview"] = False + user["demo"] = False request.state.identity = user path = request.url.path.split("/risk-agent/", 1)[-1] if user["role"] == "manager": diff --git a/backend/test_risk_agent_completion.py b/backend/test_risk_agent_completion.py index 724bdcb..e5eac0e 100644 --- a/backend/test_risk_agent_completion.py +++ b/backend/test_risk_agent_completion.py @@ -77,6 +77,20 @@ class WorkflowTests(unittest.TestCase): self.assertEqual(self.call("/plans/plan-default/run","POST",{"project_id":"P2"},"manager-token").status_code,403) result=self.call("/plans/plan-default/run","POST",{"project_id":"XB-2026-001"},"manager-token").json() self.assertNotIn("configuration",result) + def test_demo_role_dropdown_is_gated_and_preserves_role_permissions(self): + header=lambda role:{"X-Xingbang-Demo-Role":role} + self.assertEqual(self.client.get(self.base+"/access/context",headers=header("manager")).status_code,401) + with patch.dict(os.environ,{"XINGBANG_DEMO_ROLE_LOGIN":"1"}): + for role in ("admin","reviewer","manager"): + response=self.client.get(self.base+"/access/context",headers=header(role)) + self.assertEqual(response.status_code,200,response.text) + identity=response.json() + self.assertEqual(identity["role"],role) + self.assertTrue(identity["demo"]) + self.assertNotIn("*",identity["projects"]) + self.assertEqual(self.client.get(self.base+"/access/context",headers=header("owner")).status_code,401) + self.assertEqual(self.client.post(self.base+"/cards",headers=header("manager"),json={"name":"x"}).status_code,403) + self.assertEqual(self.client.post(self.base+"/cards",headers=header("reviewer"),json={"name":"x"}).status_code,403) def test_review_publishes_exact_version(self): item=self.call("/skills","POST",{"name":"新增检查","data":{"engine":"quality","inputs":["*"]}}).json() item["status"]="published" diff --git a/deploy/aliyun.env.example b/deploy/aliyun.env.example index 54fe36d..d86a924 100644 --- a/deploy/aliyun.env.example +++ b/deploy/aliyun.env.example @@ -9,6 +9,9 @@ DEEPSEEK_MODEL=deepseek-chat DEEPSEEK_BASE_URL=https://api.deepseek.com # For the gateway tested in the chat repair use disabled; otherwise verify support. CHAT_THINKING_MODE= +# Temporary business-workbench role dropdown. Keep disabled for production data. +XINGBANG_DEMO_ROLE_LOGIN=0 +XINGBANG_DEMO_PROJECTS=DEMO-A,DEMO-B,DEMO-C,LAB-001,XB-2026-001 ERP_DB_HOST= ERP_DB_PORT=5432 diff --git a/deploy/docker-compose.aliyun.yml b/deploy/docker-compose.aliyun.yml index 988edb7..2713618 100644 --- a/deploy/docker-compose.aliyun.yml +++ b/deploy/docker-compose.aliyun.yml @@ -11,6 +11,8 @@ services: DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat} DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com} CHAT_THINKING_MODE: ${CHAT_THINKING_MODE:-} + XINGBANG_DEMO_ROLE_LOGIN: ${XINGBANG_DEMO_ROLE_LOGIN:-0} + XINGBANG_DEMO_PROJECTS: ${XINGBANG_DEMO_PROJECTS:-DEMO-A,DEMO-B,DEMO-C,LAB-001,XB-2026-001} ERP_DB_HOST: ${ERP_DB_HOST:-} ERP_DB_PORT: ${ERP_DB_PORT:-5432} ERP_DB_NAME: ${ERP_DB_NAME:-} diff --git a/docker-compose.yml b/docker-compose.yml index 5e160ab..725b90f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat} DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com} CHAT_THINKING_MODE: ${CHAT_THINKING_MODE:-} + XINGBANG_DEMO_ROLE_LOGIN: ${XINGBANG_DEMO_ROLE_LOGIN:-0} + XINGBANG_DEMO_PROJECTS: ${XINGBANG_DEMO_PROJECTS:-DEMO-A,DEMO-B,DEMO-C,LAB-001,XB-2026-001} ERP_DB_HOST: ${ERP_DB_HOST:-} ERP_DB_PORT: ${ERP_DB_PORT:-5432} ERP_DB_NAME: ${ERP_DB_NAME:-} diff --git a/scripts/test_chat_stream.cjs b/scripts/test_chat_stream.cjs index e386798..1b7dd8f 100644 --- a/scripts/test_chat_stream.cjs +++ b/scripts/test_chat_stream.cjs @@ -62,6 +62,7 @@ function responder(chunks) {return async()=>new Response(new ReadableStream({sta assert.equal((await route.GET(request,{params:{path:['unknown']}})).status,404); process.env.NODE_ENV='production'; assert.equal((await route.GET(request,{params:{path:['sessions']}})).status,401); + assert.equal((await route.GET({...request,headers:new Headers({Host:'cloud.example:3200','X-Xingbang-Demo-Role':'admin'})},{params:{path:['sessions']}})).status,401); for (const identity of [{id:'a',role:'admin'},{id:'r',role:'reviewer'},{id:'m',role:'manager'},{id:'p',role:'admin',preview:true},{}]) { let forwarded=false; global.fetch=async(url,opts)=>{ @@ -78,6 +79,12 @@ function responder(chunks) {return async()=>new Response(new ReadableStream({sta const secured=load('src/app/api/core/[...path]/route.ts',{'next/server':{NextResponse}}); assert.equal((await secured.GET({...request,headers:new Headers({Authorization:'Bearer invalid'})},{params:{path:['sessions']}})).status,status===500?503:status); } + let riskHeaders; + global.fetch=async(_url,opts)=>{riskHeaders=opts.headers;return Response.json({role:'reviewer'});}; + const riskRoute=load('src/app/api/risk-agent/[...path]/route.ts',{'next/server':{NextResponse}}); + const riskResponse=await riskRoute.GET({...request,headers:new Headers({Host:'cloud.example:3200','X-Xingbang-Demo-Role':'reviewer'})},{params:{path:['access','context']}}); + assert.equal(riskResponse.status,200); + assert.equal(riskHeaders['X-Xingbang-Demo-Role'],'reviewer'); let forwardedHeaders; global.fetch=async(_url,opts)=>{forwardedHeaders=opts.headers;return Response.json({sessions:[]});}; const {coreFetch}=load('src/lib/core-fetch.ts',{}, {window:{sessionStorage:{getItem:key=>key==='xingbang-access-token'?'existing-token':null}}}); diff --git a/src/app/api/risk-agent/[...path]/route.ts b/src/app/api/risk-agent/[...path]/route.ts index 6cc59d8..93789d1 100644 --- a/src/app/api/risk-agent/[...path]/route.ts +++ b/src/app/api/risk-agent/[...path]/route.ts @@ -4,7 +4,7 @@ async function proxy(request: NextRequest, {params}: {params:{path:string[]}}) { const base=process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002"; try { const response=await fetch(`${base}/api/xingbang/risk-agent/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`,{ - method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Client-Host":request.headers.get("host")||"", "Idempotency-Key":request.headers.get("idempotency-key")||""}, + method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Demo-Role":request.headers.get("x-xingbang-demo-role")||"", "X-Xingbang-Client-Host":request.headers.get("host")||"", "Idempotency-Key":request.headers.get("idempotency-key")||""}, body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),cache:"no-store", }); const headers=new Headers({"Content-Type":response.headers.get("content-type")||"application/json"}); diff --git a/src/app/collateral-workbench/page.tsx b/src/app/collateral-workbench/page.tsx index 9f6c76f..34dd056 100644 --- a/src/app/collateral-workbench/page.tsx +++ b/src/app/collateral-workbench/page.tsx @@ -6,9 +6,9 @@ import "../data-access/workspace.css"; import "./collateral.css"; const BASE="/api/risk-agent/collateral"; const kinds:any={asset:"租赁物",insurance:"保险",deposit:"保证金",guarantee:"保证",mortgage:"抵押",pledge:"质押"}; -function H(){const t=sessionStorage.getItem("xingbang-access-token");return {"Content-Type":"application/json",...(t?{Authorization:`Bearer ${t}`}:{})}} +function H(json=true){const t=sessionStorage.getItem("xingbang-access-token"),r=sessionStorage.getItem("xingbang-demo-role");return {...(json?{"Content-Type":"application/json"}:{}),...(t?{Authorization:`Bearer ${t}`}:{}) ,...(r?{"X-Xingbang-Demo-Role":r}:{})}} async function api(path:string,body?:any){const r=await fetch(BASE+path,{method:body?"POST":"GET",headers:H(),body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok)throw Error(d.detail||"请求失败");return d} export default function Collateral(){const [cases,setCases]=useState([]),[runs,setRuns]=useState([]),[rules,setRules]=useState([]),[selected,setSelected]=useState(""),[tab,setTab]=useState("checks"),[error,setError]=useState(""),[busy,setBusy]=useState(false),[show,setShow]=useState(false),[form,setForm]=useState({}),[file,setFile]=useState(null);const requested=useRequestedProject();const c=cases.find(x=>x.id===selected);const rule=rules.find(x=>x.status==="published"); async function load(){try{const [w,r]=await Promise.all([api("/workspace"),fetch("/api/risk-agent/runs/history",{headers:H()}).then(x=>x.json())]);setCases(w.cases);setRules(w.rules);setRuns(r);setSelected(s=>w.cases.some((x:any)=>x.id===s)?s:(w.cases.find((x:any)=>x.project_id===requested)||w.cases[0])?.id||"")}catch(e:any){setError(e.message)}}useEffect(()=>{load()},[]);async function act(fn:()=>Promise){setBusy(true);setError("");try{await fn()}catch(e:any){setError(e.message)}finally{setBusy(false)}}function update(x:any){setCases(xs=>xs.map(y=>y.id===x.id?x:y));setSelected(x.id)} - async function create(){await act(async()=>{const x=await api("/cases",{run_id:form.run_id,lease_end:form.lease_end});update(x);setShow(false)})}async function upload(){if(!file||!c)return;await act(async()=>{const body=new FormData();body.append("file",file);body.append("version",String(c.version));const r=await fetch(`${BASE}/cases/${c.id}/documents`,{method:"POST",headers:(()=>{const h:any={};const t=sessionStorage.getItem("xingbang-access-token");if(t)h.Authorization=`Bearer ${t}`;return h})(),body});const d=await r.json();if(!r.ok)throw Error(d.detail);update(d);setFile(null)})}async function evaluate(){await act(async()=>update(await api(`/cases/${c.id}/evaluate`,{version:c.version,rules_id:rule.id,haircut:20,note:"已检查输入,执行确定性核验"})))} + async function create(){await act(async()=>{const x=await api("/cases",{run_id:form.run_id,lease_end:form.lease_end});update(x);setShow(false)})}async function upload(){if(!file||!c)return;await act(async()=>{const body=new FormData();body.append("file",file);body.append("version",String(c.version));const r=await fetch(`${BASE}/cases/${c.id}/documents`,{method:"POST",headers:H(false),body});const d=await r.json();if(!r.ok)throw Error(d.detail);update(d);setFile(null)})}async function evaluate(){await act(async()=>update(await api(`/cases/${c.id}/evaluate`,{version:c.version,rules_id:rule.id,haircut:20,note:"已检查输入,执行确定性核验"})))} return

04 / COLLATERAL & ENHANCEMENT

租赁物与增信措施核验

把资产、保单、保证金和担保放到当前方案下,核对真实性、有效性和覆盖能力。

{error&&
{error}
}
{!c?

选择或接入一个项目

:

{c.project_id} · 授信 {c.amount} 万元

风险缓释有效性视图

租期到期日:{c.lease_end} · 数据版本:{c.input_revision}
{c.stale&&结果已失效}
已接入措施{c.measures.length}
材料页数{c.documents.reduce((n:any,d:any)=>n+d.pages.length,0)}
待落实问题{(c.result?.checks||[]).filter((x:any)=>x.status!=="pass").length}
折价覆盖{c.result?`${(Number(c.result.scenario.coverage)*100).toFixed(1)}%`:"--"}
{tab==="checks"&&

核验清单

{rule&&}
{c.measures.map((m:any)=>
{m.name}{m.confirmed_by?"已确认":"待人工确认"}

{kinds[m.kind]} · {m.amount??"金额待补充"} 万元 · 标识 {m.resource_key||"缺失"}

{m.evidence?.reference||"暂无来源材料"}{m.evidence?.document_id?` · 第 ${m.evidence.page} 页:${m.evidence.quote}`:""}
)}{!c.measures.length&&
暂无措施,请补充租赁物、保单和增信信息。
}{c.result&&
{c.result.checks.filter((x:any)=>x.status!=="pass").length?"存在待落实问题":"所选规则检查通过"}

规则 {c.result.rules.name} v{c.result.rules.version} · {c.result.scenario.formula}

}
}{tab==="docs"&&

原始材料与页码证据

{file&&}
{c.documents.map((d:any)=>
{d.name}SHA-256 {d.sha256.slice(0,16)}… · {d.pages.length} 页{d.pages.map((p:any)=>

第 {p.number} 页 · {p.status} · {p.text.slice(0,180)||"无文本"}

)}
)}{!c.documents.length&&
上传 PDF 或 UTF-8 文本后,系统保存文件哈希和页级提取结果。扫描件需要配置本地 OCR。
}
}{tab==="scenario"&&

确定性覆盖测算

默认折价率 20%,仅计算已确认资产与已到账保证金
{c.result?<>
资产折价净值{c.result.scenario.asset_recovery} 万元
已到账保证金{c.result.scenario.deposit_recovery} 万元
总回收{c.result.scenario.total_recovery} 万元
未覆盖敞口{c.result.scenario.uncovered} 万元
{c.result.scenario.formula}
:
执行核验后查看折价情景。
}
}{tab==="advice"&&

mini-openclaw 核验建议

{c.advice?<>

{c.advice.text}

Agent 工具轨迹
{JSON.stringify(c.advice.trace.messages,null,2)}
:
程序先做核验,模型只解释证据并提出待办。
}
}
}
{show&&

接入核验项目

{e.preventDefault();create()}}>
}
} diff --git a/src/app/data-access/Enhancements.tsx b/src/app/data-access/Enhancements.tsx index f00cd8e..3b3fd46 100644 --- a/src/app/data-access/Enhancements.tsx +++ b/src/app/data-access/Enhancements.tsx @@ -24,7 +24,18 @@ export function RuleFields({item,field,json,setJson}:{item:any;field:Fields["fie return <>