diff --git a/app/.env b/app/.env index d84ea08..84c6c08 100644 --- a/app/.env +++ b/app/.env @@ -12,8 +12,8 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot client_name=ShuFuAI # java_api_base=http://api.aishufu.top:18080/ -# java_api_base=http://api.aishufu.top:18080/ -java_api_base=http://127.0.0.1:18080/ +java_api_base=http://api.aishufu.top:18080/ +# java_api_base=http://127.0.0.1:18080/ # 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致 AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes diff --git a/app/assets/appearance-patent-26DWDYwd.css b/app/assets/appearance-patent-26DWDYwd.css new file mode 100644 index 0000000..13af4ca --- /dev/null +++ b/app/assets/appearance-patent-26DWDYwd.css @@ -0,0 +1 @@ +.module-page[data-v-aad32577]{min-height:100vh;background:#1a1a1a}.main-content[data-v-aad32577]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-aad32577]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-aad32577]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-aad32577],.subsection-title[data-v-aad32577]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-aad32577]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-aad32577],.loading-msg[data-v-aad32577],.files[data-v-aad32577],.muted[data-v-aad32577]{color:#888;font-size:12px;line-height:1.5}.link[data-v-aad32577]{color:#6ea8fe;text-decoration:none}.link[data-v-aad32577]:hover{color:#9fc5ff}.btns[data-v-aad32577],.run-row[data-v-aad32577]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-aad32577],.btn-run[data-v-aad32577],.btn-delete[data-v-aad32577],.download[data-v-aad32577]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-aad32577]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-aad32577]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-aad32577]{background:#27ae60}.btn-run[data-v-aad32577]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-aad32577]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-aad32577]{display:block;margin:4px 0}.prompt-card[data-v-aad32577],.secret-card[data-v-aad32577]{margin-bottom:18px}.secret-card[data-v-aad32577]{display:none}.prompt-input[data-v-aad32577]{width:100%;box-sizing:border-box;resize:vertical;min-height:180px;padding:10px 12px;border:1px solid #333;border-radius:8px;background:#202020;color:#d8d8d8;font-size:12px;line-height:1.6;outline:none}.secret-input[data-v-aad32577]{width:100%;box-sizing:border-box;height:38px;padding:0 12px;border:1px solid #333;border-radius:8px;background:#202020;color:#d8d8d8;font-size:12px;outline:none}.prompt-input[data-v-aad32577]:focus,.secret-input[data-v-aad32577]:focus{border-color:#3498db}.parse-card[data-v-aad32577],.queue-payload[data-v-aad32577]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-aad32577]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-aad32577]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-aad32577]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-aad32577]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-aad32577]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-aad32577]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-aad32577]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-aad32577]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-aad32577]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-aad32577]{color:#666;font-size:13px;padding:18px;text-align:center}.result-table[data-v-aad32577]{--el-table-bg-color: #222;--el-table-tr-bg-color: #222;--el-table-header-bg-color: #2a2a2a;--el-table-text-color: #ccc;--el-table-border-color: #333}.task-list[data-v-aad32577]{list-style:none;margin:0;padding:12px}.task-item[data-v-aad32577]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-aad32577]{flex:1;min-width:0}.id[data-v-aad32577]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-aad32577]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-aad32577]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-aad32577]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-aad32577]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-aad32577]{background:#3498db2e;color:#3498db}.status.pending[data-v-aad32577]{background:#95a5a62e;color:#bdc3c7}.result-hint[data-v-aad32577]{margin-top:6px;color:#e0b96d}.file-progress[data-v-aad32577]{margin-top:8px;max-width:520px}.file-progress-meta[data-v-aad32577]{display:flex;justify-content:space-between;gap:12px;color:#d8c278;font-size:12px}.file-progress-track[data-v-aad32577]{margin-top:5px;height:8px;border-radius:999px;overflow:hidden;background:#303030;border:1px solid #3b3b3b}.file-progress-bar[data-v-aad32577]{height:100%;border-radius:inherit;background:linear-gradient(90deg,#4aa3ff,#f0c75e);transition:width .25s ease}.download[data-v-aad32577]{padding:6px 10px;color:#d6ecff;background:#3498db2e}.btn-delete[data-v-aad32577]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-aad32577]{flex-direction:column;height:auto}.left-panel[data-v-aad32577]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-aad32577]{grid-template-columns:repeat(2,minmax(0,1fr))}} diff --git a/app/assets/appearance-patent-Cb_Fx4da.css b/app/assets/appearance-patent-Cb_Fx4da.css deleted file mode 100644 index 02ec43b..0000000 --- a/app/assets/appearance-patent-Cb_Fx4da.css +++ /dev/null @@ -1 +0,0 @@ -.module-page[data-v-95ba428f]{min-height:100vh;background:#1a1a1a}.main-content[data-v-95ba428f]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-95ba428f]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-95ba428f]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-95ba428f],.subsection-title[data-v-95ba428f]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-95ba428f]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-95ba428f],.loading-msg[data-v-95ba428f],.files[data-v-95ba428f],.muted[data-v-95ba428f]{color:#888;font-size:12px;line-height:1.5}.link[data-v-95ba428f]{color:#6ea8fe;text-decoration:none}.link[data-v-95ba428f]:hover{color:#9fc5ff}.btns[data-v-95ba428f],.run-row[data-v-95ba428f]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-95ba428f],.btn-run[data-v-95ba428f],.btn-delete[data-v-95ba428f],.download[data-v-95ba428f]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-95ba428f]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-95ba428f]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-95ba428f]{background:#27ae60}.btn-run[data-v-95ba428f]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-95ba428f]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-95ba428f]{display:block;margin:4px 0}.prompt-card[data-v-95ba428f],.secret-card[data-v-95ba428f]{margin-bottom:18px}.secret-card[data-v-95ba428f]{display:none}.prompt-input[data-v-95ba428f]{width:100%;box-sizing:border-box;resize:vertical;min-height:180px;padding:10px 12px;border:1px solid #333;border-radius:8px;background:#202020;color:#d8d8d8;font-size:12px;line-height:1.6;outline:none}.secret-input[data-v-95ba428f]{width:100%;box-sizing:border-box;height:38px;padding:0 12px;border:1px solid #333;border-radius:8px;background:#202020;color:#d8d8d8;font-size:12px;outline:none}.prompt-input[data-v-95ba428f]:focus,.secret-input[data-v-95ba428f]:focus{border-color:#3498db}.parse-card[data-v-95ba428f],.queue-payload[data-v-95ba428f]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-95ba428f]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-95ba428f]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-95ba428f]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-95ba428f]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-95ba428f]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-95ba428f]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-95ba428f]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-95ba428f]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-95ba428f]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-95ba428f]{color:#666;font-size:13px;padding:18px;text-align:center}.result-table[data-v-95ba428f]{--el-table-bg-color: #222;--el-table-tr-bg-color: #222;--el-table-header-bg-color: #2a2a2a;--el-table-text-color: #ccc;--el-table-border-color: #333}.task-list[data-v-95ba428f]{list-style:none;margin:0;padding:12px}.task-item[data-v-95ba428f]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-95ba428f]{flex:1;min-width:0}.id[data-v-95ba428f]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-95ba428f]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-95ba428f]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-95ba428f]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-95ba428f]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-95ba428f]{background:#3498db2e;color:#3498db}.status.pending[data-v-95ba428f]{background:#95a5a62e;color:#bdc3c7}.result-hint[data-v-95ba428f]{margin-top:6px;color:#e0b96d}.file-progress[data-v-95ba428f]{margin-top:8px;max-width:520px}.file-progress-meta[data-v-95ba428f]{display:flex;justify-content:space-between;gap:12px;color:#d8c278;font-size:12px}.file-progress-track[data-v-95ba428f]{margin-top:5px;height:8px;border-radius:999px;overflow:hidden;background:#303030;border:1px solid #3b3b3b}.file-progress-bar[data-v-95ba428f]{height:100%;border-radius:inherit;background:linear-gradient(90deg,#4aa3ff,#f0c75e);transition:width .25s ease}.download[data-v-95ba428f]{padding:6px 10px;color:#d6ecff;background:#3498db2e}.btn-delete[data-v-95ba428f]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-95ba428f]{flex-direction:column;height:auto}.left-panel[data-v-95ba428f]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-95ba428f]{grid-template-columns:repeat(2,minmax(0,1fr))}} diff --git a/app/assets/appearance-patent.js b/app/assets/appearance-patent.js index d18e58a..cff41de 100644 --- a/app/assets/appearance-patent.js +++ b/app/assets/appearance-patent.js @@ -1 +1 @@ -import{d as Je,o as qe,M as je,a as i,c,b as Oe,B as Ve,e as a,F as se,r as te,t as r,w as We,H as Ye,f as _,O as he,n as Qe,g as v,h as q,i as j,E as p,W as Xe,X as Ze,Y as es,V as ye,Z as ss,$ as ts,a0 as as,s as ns,a1 as ls,a2 as rs,a3 as ke,a4 as os,_ as us,q as is,u as cs}from"./java-modules-WSWRTEaR.js";import{e as ds}from"./brand-5GpfAyHD.js";import{c as fs}from"./categorized-timers-JPA-olTr.js";const ps={class:"page-shell module-page"},vs={class:"main-content"},gs={class:"left-panel"},hs={class:"upload-zone"},ys={class:"selected-files"},ks={key:0},Is={class:"prompt-card"},_s={class:"run-row"},Ps=["disabled"],ws=["disabled"],Cs={key:0,class:"parse-card"},Ss={key:1,class:"queue-payload"},bs={class:"right-panel"},ms={class:"task-list-wrap"},Ns={class:"clean-result-summary"},Fs={class:"summary-card"},Ts={class:"summary-card"},Rs={class:"summary-card"},As={class:"summary-card"},Es={class:"result-list-wrap"},xs={key:0,class:"empty-tasks"},Us={key:1,class:"task-list clean-result-list"},Ds={class:"left"},$s={class:"id"},Ms={class:"files"},zs={class:"files"},Gs={class:"files"},Bs={class:"files"},Hs={key:0,class:"files result-hint"},Ls={key:1,class:"file-progress"},Ks={class:"file-progress-meta"},Js={class:"file-progress-track"},qs={class:"task-right"},js={class:"status running"},Os=["onClick"],Vs={class:"result-list-wrap"},Ws={key:0,class:"empty-tasks"},Ys={key:1,class:"task-list clean-result-list"},Qs={class:"left"},Xs={class:"id"},Zs={class:"files"},et={class:"files"},st={class:"files"},tt={key:0,class:"files"},at={key:1,class:"files result-hint"},nt={key:2,class:"file-progress"},lt={class:"file-progress-meta"},rt={class:"file-progress-track"},ot={key:3,class:"files"},ut={class:"task-right"},it=["onClick"],ct=["onClick"],dt=3e3,ft=Je({__name:"BrandAppearancePatentTab",setup(vt){const D=v([]),F=v([]),P=v(null),T=v(null),$=v(""),M=v(!1),z=v(!1),C=v(""),f=v([]),u=v([]),h=v(null),O=v(!1);let R=null,ae=0,A=!1;const E=fs("appearance-patent"),x=v({pendingTaskCount:0,processedTaskCount:0,successTaskCount:0,failedTaskCount:0}),g=v([]),w=v({}),b=q(()=>P.value||T.value),V=q(()=>{const e=P.value;return!e?.taskId||g.value.some(t=>t.taskId===e.taskId)?null:{taskId:e.taskId,sourceFilename:e.sourceFilename,rowCount:e.acceptedRows,taskStatus:"PENDING",success:!1}}),ne=q(()=>{const e=new Set([...f.value,...u.value]),s=g.value.filter(n=>{const l=k(n);return n.taskId!=null&&(e.has(n.taskId)||l==="RUNNING"||l==="PENDING")}).map(n=>re(n));for(const[n,l]of Object.entries(w.value)){const o=Number(n);!Number.isFinite(o)||o<=0||!e.has(o)&&k(l)!=="RUNNING"&&k(l)!=="PENDING"||s.some(d=>d.taskId===o)||s.push(re(l))}return V.value?s.some(n=>n.taskId===V.value?.taskId)?s:[V.value,...s]:s}),le=q(()=>g.value.filter(e=>{const s=k(e);return s!=="RUNNING"&&s!=="PENDING"}));function re(e){if(e.taskId==null)return e;const s=w.value[e.taskId];return s?{...e,...s,sourceFilename:s.sourceFilename||e.sourceFilename,resultFilename:s.resultFilename||e.resultFilename,rowCount:s.rowCount??e.rowCount,createdAt:s.createdAt||e.createdAt}:e}function Ie(){return $.value.trim()}function _e(){return $.value.trim()}function G(){return ke("appearance-patent").trim()}function oe(){return ke("appearance-patent-token").trim()}function Pe(){return typeof window<"u"&&window.localStorage.getItem("uid")||"0"}function W(){return`appearance-patent:tasks:${Pe()}`}function we(e){return{taskId:e.taskId,totalRows:e.totalRows,acceptedRows:e.acceptedRows,groupCount:e.groupCount,droppedRows:e.droppedRows}}function S(){if(typeof window>"u")return;const e=Array.from(new Set([...f.value,...u.value]));e.length?window.localStorage.setItem(W(),JSON.stringify(e)):window.localStorage.removeItem(W())}function Ce(){if(!(typeof window>"u"))try{const e=window.localStorage.getItem(W()),s=e?JSON.parse(e):[];f.value=Array.isArray(s)?s.filter(t=>typeof t=="number"&&t>0):[]}catch{f.value=[]}}function B(e){if(!e)return"-";const s=new Date(e);if(Number.isNaN(s.getTime()))return e;const t=s.getFullYear(),n=String(s.getMonth()+1).padStart(2,"0"),l=String(s.getDate()).padStart(2,"0"),o=String(s.getHours()).padStart(2,"0"),d=String(s.getMinutes()).padStart(2,"0"),y=String(s.getSeconds()).padStart(2,"0");return`${t}-${n}-${l} ${o}:${d}:${y}`}async function ue(e){const s=j();if(!s?.upload_file_to_java)throw new Error("当前桌面端未提供文件上传能力");const t=[];for(const n of e){const l=typeof n=="string"?n:n.absolutePath,o=typeof n=="string"?void 0:n.relativePath,d=await s.upload_file_to_java(l,o);if(!d?.success||!d.data)throw new Error(d?.error||d?.message||`上传失败:${l}`);t.push(d.data)}return t}async function Se(){const e=j();if(!e?.select_brand_xlsx_files||!e.upload_file_to_java){p.warning("当前环境不支持文件选择或上传");return}const s=await e.select_brand_xlsx_files();if(!s?.length)return;const t=await ue(s);F.value=t,D.value=t.map(n=>n.relativePath||n.originalFilename||n.fileKey),P.value=null,T.value=null,C.value=""}async function be(){const e=j();if(!e?.select_brand_folder){p.warning("当前环境不支持文件夹选择,请在本地客户端中打开");return}try{const s=await e.select_brand_folder();if(!s)return;const t=await ds(s);if(!t.success||!t.items?.length){p.warning(t.error||"该文件夹下没有可用的 xlsx 文件");return}const n=await ue(t.items);F.value=n,D.value=t.items.map(l=>l.relativePath||l.absolutePath),P.value=null,T.value=null,C.value="",p.success(`已选择文件夹内 ${t.items.length} 个 Excel 文件`)}catch(s){p.error(s instanceof Error?s.message:"选择失败")}}async function me(){if(!F.value.length){p.warning("请先选择 Excel");return}if(!G()){p.warning("请先在左上角设置中填写外观专利密钥");return}M.value=!0;try{const e=F.value.map(t=>({fileKey:t.fileKey,originalFilename:t.originalFilename,relativePath:t.relativePath})),s=await Xe(e,Ie(),G(),oe());P.value=s,T.value=null,C.value="",p.success(`解析完成,共 ${s.groupCount||0} 组 / ${s.acceptedRows} 条`)}catch(e){p.error(e instanceof Error?e.message:"解析失败")}finally{M.value=!1}}async function Ne(){const e=j(),s=P.value,t=s?.taskId;if(!t){p.warning("请先解析文件");return}if(!e?.enqueue_json){p.error("当前环境未启用 pywebview enqueue_json");return}if(!G()){p.warning("请先在左上角设置中填写外观专利密钥");return}z.value=!0;try{const l=(await Ze(t)).groups||[],o={type:"appearance-patent-run",ts:Date.now(),data:{taskId:t,prompt:_e(),api_key:G(),patent_token:oe(),sourceFileCount:s.sourceFileCount||0,totalRows:s.totalRows||0,acceptedRows:s.acceptedRows||0,groupCount:s.groupCount||0,groups:l.map(y=>({sourceFileKey:y.sourceFileKey||"",sourceFilename:y.sourceFilename||"",groupKey:y.groupKey||"",baseId:y.baseId||"",displayId:y.displayId||"",items:(y.items||[]).map(I=>({sourceFileKey:I.sourceFileKey||"",sourceFilename:I.sourceFilename||"",rowToken:I.rowToken||"",groupKey:I.groupKey||"",id:I.displayId,asin:I.asin,country:I.country,sku:I.sku||"",url:I.url||"",title:I.title||""}))}))}};C.value=`任务 ${t} 已准备推送,共 ${l.length} 组`,await es(t);const d=await e.enqueue_json(o);if(!d?.success){p.error(d?.error||"推送失败");return}T.value=we(s),Te(t),Fe(),await H(),await L({force:!0}),p.success("已推送到 Python 队列")}finally{z.value=!1}}function Fe(){P.value=null,C.value=""}function Te(e){f.value.includes(e)||(f.value=[...f.value,e],S()),m()}function Y(e){if(f.value=f.value.filter(s=>s!==e),!u.value.includes(e)){const s={...w.value};delete s[e],w.value=s}S()}function Re(e){u.value.includes(e)||(u.value=[...u.value,e],S()),m()}function Q(e){if(u.value=u.value.filter(s=>s!==e),!f.value.includes(e)){const s={...w.value};delete s[e],w.value=s}S()}function m(){A||h.value==null&&Ae(!0)}function ie(){h.value!=null&&(E.clearTimer("task-poll",h.value),h.value=null)}function Ae(e=!1){if(A)return;if(h.value!=null){if(!e)return;E.clearTimer("task-poll",h.value),h.value=null}const s=async()=>{h.value=null,!A&&(!f.value.length&&!u.value.length||(await Ee(),!A&&h.value==null&&(f.value.length||u.value.length)&&(h.value=E.setTimeout("task-poll",s,ye()))))};e?s():h.value=E.setTimeout("task-poll",s,ye())}async function Ee(){if(O.value||!f.value.length&&!u.value.length){!f.value.length&&!u.value.length&&ie();return}O.value=!0;try{let e=!1,s=!1;const t=Array.from(new Set([...f.value,...u.value]));if(t.length){const n=await os(t,{force:u.value.length>0});for(const l of n.items||[]){const o=l.task;if(!o?.id)continue;const d=l.items?.[0];if(d&&(w.value={...w.value,[o.id]:d},xe(d)),(o.status==="SUCCESS"||o.status==="FAILED")&&(Y(o.id),e=!0,o.status==="SUCCESS"?Re(o.id):Q(o.id)),d&&o.status==="SUCCESS"&&u.value.includes(o.id)){const y=(d.fileStatus||"").toUpperCase();(d.fileReady||y==="SUCCESS"||y==="FAILED")&&(Q(o.id),e=!0,s=!0)}}n.missingTaskIds?.length&&(n.missingTaskIds.forEach(l=>{Y(l),Q(l)}),s=!0,e=!0)}e&&await H(),s&&await L(),Ue()}finally{O.value=!1}}function xe(e){if(!e.taskId&&!e.resultId)return;const s=g.value.findIndex(t=>e.resultId!=null&&t.resultId===e.resultId||e.taskId!=null&&t.taskId===e.taskId);s>=0?g.value[s]={...g.value[s],...e}:g.value=[e,...g.value]}function Ue(){if(!u.value.length)return;const e=[];for(const s of u.value){const t=g.value.find(o=>o.taskId===s);if(!t){e.push(s);continue}const n=(t.taskStatus||"").toUpperCase(),l=(t.fileStatus||"").toUpperCase();n==="FAILED"||t.fileReady||l==="SUCCESS"||l==="FAILED"||n==="SUCCESS"&&e.push(s)}u.value=e,S(),u.value.length&&m()}function De(){const e=g.value.filter(s=>s.taskId!=null&&N(s)).map(s=>s.taskId);u.value=Array.from(new Set(e)),S(),u.value.length&&m()}function $e(){const e=g.value.filter(s=>s.taskId!=null&&k(s)==="RUNNING").map(s=>s.taskId);e.length&&(f.value=Array.from(new Set([...f.value,...e])),S(),m())}async function H(){x.value=await ss()}async function L(e={}){const s=Date.now();if(!(!e.force&&g.value.length&&s-ae{g.value=Me(g.value,t.items||[]),ae=Date.now()}).finally(()=>{R=null}),R)}function Me(e,s){const t=new Map,n=new Map;for(const l of e||[])l.taskId!=null&&t.set(l.taskId,l),l.resultId!=null&&n.set(l.resultId,l);return(s||[]).map(l=>{const o=l.resultId!=null?n.get(l.resultId):l.taskId!=null?t.get(l.taskId):void 0;return ze(o,l)})}function ze(e,s){return!e||!((k(e)==="RUNNING"||N(e)||U(e))&&!U(s)&&!ee(s))?s:{...s,fileJobId:s.fileJobId??e.fileJobId,fileStatus:s.fileStatus||e.fileStatus,fileError:s.fileError||e.fileError,fileReady:s.fileReady||e.fileReady,fileProgressPercent:X(s.fileProgressPercent)?s.fileProgressPercent:e.fileProgressPercent,fileProgressCurrent:X(s.fileProgressCurrent)?s.fileProgressCurrent:e.fileProgressCurrent,fileProgressTotal:X(s.fileProgressTotal)?s.fileProgressTotal:e.fileProgressTotal,fileProgressMessage:(s.fileProgressMessage||"").trim()?s.fileProgressMessage:e.fileProgressMessage}}function X(e){const s=Number(e);return Number.isFinite(s)&&s>0}function ce(e){const s=k(e);return s==="RUNNING"?"执行中":s==="PENDING"?"已解析待推送":N(e)?"结果生成中":Z(e)?"结果生成失败":s==="SUCCESS"||e.success?"已完成":s==="FAILED"?"失败":"等待中"}function Ge(e){const s=k(e);return s==="RUNNING"||N(e)?"running":Z(e)||s==="FAILED"?"failed":s==="SUCCESS"||e.success?"success":"pending"}function N(e){const s=k(e),t=(e.fileStatus||"").toUpperCase();return!(s!=="SUCCESS"||ee(e)||t==="FAILED"||t==="SUCCESS")}function k(e){return(e.taskStatus||"").toUpperCase()}function Z(e){return(e.fileStatus||"").toUpperCase()==="FAILED"}function K(e){if(Z(e))return e.fileError||"结果文件生成失败,请稍后重试或检查后端日志";if(U(e)||!N(e))return"";const s=(e.fileStatus||"").toUpperCase();return s==="RUNNING"?"任务已完成,正在等待 Coze 回流并组装结果文件,下载按钮稍后出现":s==="PENDING"?"任务已完成,结果文件已入队,正在等待后端开始处理":"任务已完成,正在生成结果文件,下载按钮稍后出现"}function ee(e){return!!(e.resultId&&(e.fileReady||e.downloadUrl))}function J(e){const s=Number(e.fileProgressPercent||0);return Number.isFinite(s)&&s>0?Math.max(0,Math.min(100,Math.round(s))):ve(e)?pe(e)==="PENDING"?6:10:0}function de(e,s){const t=(e.fileProgressMessage||"").trim();return t?Be(t):s}function Be(e){const s=e.match(/^Coze submitted (\d+) batches, completed (\d+), waiting for results$/);if(s)return`已提交 Coze ${s[1]} 批,已完成 ${s[2]} 批,等待结果回流`;const t=e.match(/^Coze completed (\d+) batches, waiting for remaining Python data$/);return t?`Coze 已完成 ${t[1]} 批,等待 Python 继续回传数据`:{"Receiving Python data, waiting for 50 rows before Coze":"正在接收 Python 数据,累计 50 条后提交 Coze","Submitting Coze":"正在提交 Coze","Coze submitted, waiting for result":"Coze 已提交,等待结果回流","Waiting for Python upload":"等待 Python 继续回传数据","Coze results ready, assembling xlsx":"Coze 结果已回流,正在组装 xlsx","Assembling xlsx":"正在组装 xlsx","Uploading result file":"正在上传结果文件","Result file generated":"结果文件已生成"}[e]||e}function U(e){return(k(e)==="RUNNING"||N(e))&&(fe(e)||ve(e))}function fe(e){return He(e)||Number(e.fileProgressPercent||0)>0||!!(e.fileProgressMessage||"").trim()}function He(e){return(e.fileProgressTotal||0)>0}function pe(e){return(e.fileStatus||"").toUpperCase()}function Le(e){const s=pe(e);return e.fileJobId!=null||s==="PENDING"||s==="RUNNING"}function ve(e){return Le(e)&&!fe(e)}async function Ke(e){if(!e.resultId)return;const s=e.downloadUrl||as(e.resultId),t=e.resultFilename||`${e.sourceFilename||"appearance-patent"}.xlsx`,n=await ns(s,t,`appearance-patent:${e.resultId}`);n.success?p.success(`已保存: ${n.path||t}`):n.error&&n.error!=="用户取消"&&p.error(n.error)}async function ge(e){try{e.taskStatus==="RUNNING"&&e.taskId?(await ls(e.taskId),Y(e.taskId)):e.resultId&&await rs(e.resultId),await H(),await L({force:!0}),p.success("已删除")}catch(s){p.error(s instanceof Error?s.message:"删除失败")}}return qe(async()=>{Ce(),await Promise.all([H().catch(()=>{}),L().catch(()=>{})]),$e(),De(),(f.value.length||u.value.length)&&m()}),je(()=>{A=!0,ie(),E.clearScope()}),(e,s)=>(i(),c("div",ps,[Oe(Ve,{active:"appearance-patent"}),a("div",vs,[a("aside",gs,[s[3]||(s[3]=a("div",{class:"section-title"},"上传文件",-1)),a("div",hs,[s[1]||(s[1]=a("div",{class:"hint"},"选择 Excel 后点击解析,后端会提取 id、ASIN、国家,并保留整数 id 与子数据的第一条。",-1)),a("div",{class:"btns"},[a("button",{type:"button",class:"opt-btn",onClick:Se},"选择 Excel"),a("button",{type:"button",class:"opt-btn",onClick:be},"选择文件夹")]),a("div",ys,[D.value.length?(i(!0),c(se,{key:1},te(D.value,t=>(i(),c("span",{key:t},r(t),1))),128)):(i(),c("span",ks,"暂未选择文件"))])]),a("div",Is,[s[2]||(s[2]=a("div",{class:"section-title"},"新增条件要求",-1)),We(a("textarea",{"onUpdate:modelValue":s[0]||(s[0]=t=>$.value=t),class:"prompt-input",rows:"8",placeholder:"可选,留空则不向 Coze 传 prompt 字段;填写后将作为 prompt 透传"},null,512),[[Ye,$.value]])]),a("div",_s,[a("button",{type:"button",class:"btn-run",disabled:M.value||!F.value.length,onClick:me},r(M.value?"解析中...":"解析并创建任务"),9,Ps),a("button",{type:"button",class:"btn-run btn-queue",disabled:z.value||!P.value?.taskId,onClick:Ne},r(z.value?"推送中...":"推送到 Python 队列"),9,ws)]),s[4]||(s[4]=a("p",{class:"loading-msg"},"Python 回传字段按 asin、国家、url、标题提交;后端接收分片后累计 50 条调用 Coze,当前任务区展示 Coze 回流和结果组装进度。",-1)),b.value?(i(),c("div",Cs,[a("div",null,"任务 ID:"+r(b.value.taskId),1),a("div",null,"总行数:"+r(b.value.totalRows)+",有效:"+r(b.value.acceptedRows)+",分组:"+r(b.value.groupCount||0)+",过滤:"+r(b.value.droppedRows),1)])):_("",!0),C.value?(i(),c("pre",Ss,r(C.value),1)):_("",!0)]),a("section",bs,[s[12]||(s[12]=a("div",{class:"panel-header"},"外观专利检测",-1)),a("div",ms,[a("div",Ns,[a("div",Fs,[s[5]||(s[5]=a("span",{class:"summary-label"},"运行中任务",-1)),a("strong",null,r(x.value.pendingTaskCount),1)]),a("div",Ts,[s[6]||(s[6]=a("span",{class:"summary-label"},"已结束任务",-1)),a("strong",null,r(x.value.processedTaskCount),1)]),a("div",Rs,[s[7]||(s[7]=a("span",{class:"summary-label"},"成功任务",-1)),a("strong",null,r(x.value.successTaskCount),1)]),a("div",As,[s[8]||(s[8]=a("span",{class:"summary-label"},"失败任务",-1)),a("strong",null,r(x.value.failedTaskCount),1)])]),s[11]||(s[11]=a("div",{class:"subsection-title"},"匹配任务",-1)),a("div",Es,[s[9]||(s[9]=a("div",{class:"result-list-header"},[a("span",null,"当前任务")],-1)),ne.value.length?(i(),c("ul",Us,[(i(!0),c(se,null,te(ne.value,t=>(i(),c("li",{key:`cur-${t.taskId}`,class:"task-item"},[a("div",Ds,[a("span",$s,r(t.sourceFilename||"外观专利检测"),1),a("div",Ms,"任务 ID:"+r(t.taskId),1),a("div",zs,"开始时间:"+r(B(t.startedAt||t.createdAt)),1),a("div",Gs,"结束时间:"+r(B(t.finishedAt)),1),a("div",Bs,"行数:"+r(t.rowCount??"-"),1),K(t)?(i(),c("div",Hs,r(K(t)),1)):_("",!0),U(t)?(i(),c("div",Ls,[a("div",Ks,[a("span",null,r(de(t,"处理中")),1),a("span",null,r(J(t))+"%",1)]),a("div",Js,[a("div",{class:"file-progress-bar",style:he({width:`${J(t)}%`})},null,4)])])):_("",!0)]),a("div",qs,[a("span",js,r(ce(t)),1),a("button",{type:"button",class:"btn-delete",onClick:n=>ge(t)},"删除",8,Os)])]))),128))])):(i(),c("div",xs,"暂无当前任务"))]),a("div",Vs,[s[10]||(s[10]=a("div",{class:"result-list-header"},[a("span",null,"历史记录")],-1)),le.value.length?(i(),c("ul",Ys,[(i(!0),c(se,null,te(le.value,t=>(i(),c("li",{key:`his-${t.resultId}-${t.taskId}`,class:"task-item"},[a("div",Qs,[a("span",Xs,r(t.sourceFilename||"外观专利检测"),1),a("div",Zs,"任务 ID:"+r(t.taskId??"-"),1),a("div",et,"开始时间:"+r(B(t.startedAt||t.createdAt)),1),a("div",st,"结束时间:"+r(B(t.finishedAt)),1),t.resultFilename?(i(),c("div",tt,r(t.resultFilename||"下载结果"),1)):_("",!0),K(t)?(i(),c("div",at,r(K(t)),1)):_("",!0),U(t)?(i(),c("div",nt,[a("div",lt,[a("span",null,r(de(t,"结果生成中")),1),a("span",null,r(J(t))+"%",1)]),a("div",rt,[a("div",{class:"file-progress-bar",style:he({width:`${J(t)}%`})},null,4)])])):_("",!0),t.error?(i(),c("div",ot,"错误:"+r(t.error),1)):_("",!0)]),a("div",ut,[a("span",{class:Qe(["status",Ge(t)])},r(ce(t)),3),ee(t)?(i(),c("button",{key:0,type:"button",class:"download",onClick:n=>Ke(t)},"下载",8,it)):_("",!0),t.resultId?(i(),c("button",{key:1,type:"button",class:"btn-delete",onClick:n=>ge(t)},"删除",8,ct)):_("",!0)])]))),128))])):(i(),c("div",Ws,"暂无历史记录"))])])])])]))}}),pt=us(ft,[["__scopeId","data-v-95ba428f"]]);is(pt).use(cs).mount("#app"); +import{d as qe,o as je,M as Oe,a as i,c,b as Ve,B as We,e as a,F as se,r as te,t as r,w as Ye,H as Qe,f as _,O as ke,n as Xe,g as v,h as j,i as O,E as p,W as Ze,X as es,Y as ss,V as Ie,Z as ts,$ as as,a0 as ns,s as ls,a1 as rs,a2 as os,a3 as _e,a4 as us,_ as is,q as cs,u as ds}from"./java-modules-WSWRTEaR.js";import{e as fs}from"./brand-5GpfAyHD.js";import{c as ps}from"./categorized-timers-JPA-olTr.js";const vs={class:"page-shell module-page"},gs={class:"main-content"},hs={class:"left-panel"},ys={class:"upload-zone"},ks={class:"selected-files"},Is={key:0},_s={class:"prompt-card"},Ps={class:"run-row"},ws=["disabled"],Cs=["disabled"],Ss={key:0,class:"parse-card"},bs={key:1,class:"queue-payload"},ms={class:"right-panel"},Fs={class:"task-list-wrap"},Ns={class:"clean-result-summary"},Ts={class:"summary-card"},Rs={class:"summary-card"},As={class:"summary-card"},Es={class:"summary-card"},xs={class:"result-list-wrap"},Us={key:0,class:"empty-tasks"},Ds={key:1,class:"task-list clean-result-list"},$s={class:"left"},Ms={class:"id"},zs={class:"files"},Gs={class:"files"},Ls={class:"files"},Bs={class:"files"},Hs={key:0,class:"files result-hint"},Ks={key:1,class:"file-progress"},Js={class:"file-progress-meta"},qs={class:"file-progress-track"},js={class:"task-right"},Os={class:"status running"},Vs=["onClick"],Ws={class:"result-list-wrap"},Ys={key:0,class:"empty-tasks"},Qs={key:1,class:"task-list clean-result-list"},Xs={class:"left"},Zs={class:"id"},et={class:"files"},st={class:"files"},tt={class:"files"},at={key:0,class:"files"},nt={key:1,class:"files result-hint"},lt={key:2,class:"file-progress"},rt={class:"file-progress-meta"},ot={class:"file-progress-track"},ut={key:3,class:"files"},it={class:"task-right"},ct=["onClick"],dt=["onClick"],ft=3e3,pt=qe({__name:"BrandAppearancePatentTab",setup(gt){const D=v([]),N=v([]),P=v(null),T=v(null),$=v(""),M=v(!1),z=v(!1),C=v(""),f=v([]),u=v([]),h=v(null),V=v(!1);let R=null,ae=0,A=!1;const E=ps("appearance-patent"),x=v({pendingTaskCount:0,processedTaskCount:0,successTaskCount:0,failedTaskCount:0}),g=v([]),w=v({}),m=j(()=>P.value||T.value),W=j(()=>{const e=P.value;return!e?.taskId||g.value.some(t=>t.taskId===e.taskId)?null:{taskId:e.taskId,sourceFilename:e.sourceFilename,rowCount:e.acceptedRows,taskStatus:"PENDING",success:!1}}),ne=j(()=>{const e=new Set([...f.value,...u.value]),s=g.value.filter(n=>{const l=k(n);return n.taskId!=null&&(e.has(n.taskId)||l==="RUNNING"||l==="PENDING")}).map(n=>re(n));for(const[n,l]of Object.entries(w.value)){const o=Number(n);!Number.isFinite(o)||o<=0||!e.has(o)&&k(l)!=="RUNNING"&&k(l)!=="PENDING"||s.some(d=>d.taskId===o)||s.push(re(l))}return W.value?s.some(n=>n.taskId===W.value?.taskId)?s:[W.value,...s]:s}),le=j(()=>g.value.filter(e=>{const s=k(e);return s!=="RUNNING"&&s!=="PENDING"}));function re(e){if(e.taskId==null)return e;const s=w.value[e.taskId];return s?{...e,...s,sourceFilename:s.sourceFilename||e.sourceFilename,resultFilename:s.resultFilename||e.resultFilename,rowCount:s.rowCount??e.rowCount,createdAt:s.createdAt||e.createdAt}:e}function Pe(){return $.value.trim()}function we(){return $.value.trim()}function G(){return _e("appearance-patent").trim()}function oe(){return _e("appearance-patent-token").trim()}function Ce(){return typeof window<"u"&&window.localStorage.getItem("uid")||"0"}function Y(){return`appearance-patent:tasks:${Ce()}`}function Se(e){return{taskId:e.taskId,totalRows:e.totalRows,acceptedRows:e.acceptedRows,groupCount:e.groupCount,droppedRows:e.droppedRows}}function S(){if(typeof window>"u")return;const e=Array.from(new Set([...f.value,...u.value]));e.length?window.localStorage.setItem(Y(),JSON.stringify(e)):window.localStorage.removeItem(Y())}function be(){if(!(typeof window>"u"))try{const e=window.localStorage.getItem(Y()),s=e?JSON.parse(e):[];f.value=Array.isArray(s)?s.filter(t=>typeof t=="number"&&t>0):[]}catch{f.value=[]}}function L(e){if(!e)return"-";const s=new Date(e);if(Number.isNaN(s.getTime()))return e;const t=s.getFullYear(),n=String(s.getMonth()+1).padStart(2,"0"),l=String(s.getDate()).padStart(2,"0"),o=String(s.getHours()).padStart(2,"0"),d=String(s.getMinutes()).padStart(2,"0"),y=String(s.getSeconds()).padStart(2,"0");return`${t}-${n}-${l} ${o}:${d}:${y}`}async function ue(e){const s=O();if(!s?.upload_file_to_java)throw new Error("当前桌面端未提供文件上传能力");const t=[];for(const n of e){const l=typeof n=="string"?n:n.absolutePath,o=typeof n=="string"?void 0:n.relativePath,d=await s.upload_file_to_java(l,o);if(!d?.success||!d.data)throw new Error(d?.error||d?.message||`上传失败:${l}`);t.push(d.data)}return t}async function me(){const e=O();if(!e?.select_brand_xlsx_files||!e.upload_file_to_java){p.warning("当前环境不支持文件选择或上传");return}const s=await e.select_brand_xlsx_files();if(!s?.length)return;const t=await ue(s);N.value=t,D.value=t.map(n=>n.relativePath||n.originalFilename||n.fileKey),P.value=null,T.value=null,C.value=""}async function Fe(){const e=O();if(!e?.select_brand_folder){p.warning("当前环境不支持文件夹选择,请在本地客户端中打开");return}try{const s=await e.select_brand_folder();if(!s)return;const t=await fs(s);if(!t.success||!t.items?.length){p.warning(t.error||"该文件夹下没有可用的 xlsx 文件");return}const n=await ue(t.items);N.value=n,D.value=t.items.map(l=>l.relativePath||l.absolutePath),P.value=null,T.value=null,C.value="",p.success(`已选择文件夹内 ${t.items.length} 个 Excel 文件`)}catch(s){p.error(s instanceof Error?s.message:"选择失败")}}async function Ne(){if(!N.value.length){p.warning("请先选择 Excel");return}if(!G()){p.warning("请先在左上角设置中填写外观专利密钥");return}M.value=!0;try{const e=N.value.map(t=>({fileKey:t.fileKey,originalFilename:t.originalFilename,relativePath:t.relativePath})),s=await Ze(e,Pe(),G(),oe());P.value=s,T.value=null,C.value="",p.success(`解析完成,共 ${s.groupCount||0} 组 / ${s.acceptedRows} 条`)}catch(e){p.error(e instanceof Error?e.message:"解析失败")}finally{M.value=!1}}async function Te(){const e=O(),s=P.value,t=s?.taskId;if(!t){p.warning("请先解析文件");return}if(!e?.enqueue_json){p.error("当前环境未启用 pywebview enqueue_json");return}if(!G()){p.warning("请先在左上角设置中填写外观专利密钥");return}z.value=!0;try{const l=(await es(t)).groups||[],o={type:"appearance-patent-run",ts:Date.now(),data:{taskId:t,prompt:we(),api_key:G(),patent_token:oe(),sourceFileCount:s.sourceFileCount||0,totalRows:s.totalRows||0,acceptedRows:s.acceptedRows||0,groupCount:s.groupCount||0,groups:l.map(y=>({sourceFileKey:y.sourceFileKey||"",sourceFilename:y.sourceFilename||"",groupKey:y.groupKey||"",baseId:y.baseId||"",displayId:y.displayId||"",items:(y.items||[]).map(I=>({sourceFileKey:I.sourceFileKey||"",sourceFilename:I.sourceFilename||"",rowToken:I.rowToken||"",groupKey:I.groupKey||"",id:I.displayId,asin:I.asin,country:I.country,sku:I.sku||"",url:I.url||"",title:I.title||""}))}))}};C.value=`任务 ${t} 已准备推送,共 ${l.length} 组`,await ss(t);const d=await e.enqueue_json(o);if(!d?.success){p.error(d?.error||"推送失败");return}T.value=Se(s),Ae(t),Re(),await B(),await H({force:!0}),p.success("已推送到 Python 队列")}finally{z.value=!1}}function Re(){P.value=null,C.value=""}function Ae(e){f.value.includes(e)||(f.value=[...f.value,e],S()),F()}function Q(e){if(f.value=f.value.filter(s=>s!==e),!u.value.includes(e)){const s={...w.value};delete s[e],w.value=s}S()}function Ee(e){u.value.includes(e)||(u.value=[...u.value,e],S()),F()}function X(e){if(u.value=u.value.filter(s=>s!==e),!f.value.includes(e)){const s={...w.value};delete s[e],w.value=s}S()}function F(){A||h.value==null&&xe(!0)}function ie(){h.value!=null&&(E.clearTimer("task-poll",h.value),h.value=null)}function xe(e=!1){if(A)return;if(h.value!=null){if(!e)return;E.clearTimer("task-poll",h.value),h.value=null}const s=async()=>{h.value=null,!A&&(!f.value.length&&!u.value.length||(await Ue(),!A&&h.value==null&&(f.value.length||u.value.length)&&(h.value=E.setTimeout("task-poll",s,Ie()))))};e?s():h.value=E.setTimeout("task-poll",s,Ie())}async function Ue(){if(V.value||!f.value.length&&!u.value.length){!f.value.length&&!u.value.length&&ie();return}V.value=!0;try{let e=!1,s=!1;const t=Array.from(new Set([...f.value,...u.value]));if(t.length){const n=await us(t,{force:u.value.length>0});for(const l of n.items||[]){const o=l.task;if(!o?.id)continue;const d=l.items?.[0];if(d&&(w.value={...w.value,[o.id]:d},De(d)),(o.status==="SUCCESS"||o.status==="FAILED")&&(Q(o.id),e=!0,d&&b(d)?Ee(o.id):X(o.id)),d&&(o.status==="SUCCESS"||o.status==="FAILED")&&u.value.includes(o.id)){const y=(d.fileStatus||"").toUpperCase();(d.fileReady||y==="SUCCESS"||y==="FAILED")&&(X(o.id),e=!0,s=!0)}}n.missingTaskIds?.length&&(n.missingTaskIds.forEach(l=>{Q(l),X(l)}),s=!0,e=!0)}e&&await B(),s&&await H(),$e()}finally{V.value=!1}}function De(e){if(!e.taskId&&!e.resultId)return;const s=g.value.findIndex(t=>e.resultId!=null&&t.resultId===e.resultId||e.taskId!=null&&t.taskId===e.taskId);s>=0?g.value[s]={...g.value[s],...e}:g.value=[e,...g.value]}function $e(){if(!u.value.length)return;const e=[];for(const s of u.value){const t=g.value.find(o=>o.taskId===s);if(!t){e.push(s);continue}const n=(t.taskStatus||"").toUpperCase(),l=(t.fileStatus||"").toUpperCase();t.fileReady||l==="SUCCESS"||l==="FAILED"||(n==="SUCCESS"||n==="FAILED")&&e.push(s)}u.value=e,S(),u.value.length&&F()}function Me(){const e=g.value.filter(s=>s.taskId!=null&&b(s)).map(s=>s.taskId);u.value=Array.from(new Set(e)),S(),u.value.length&&F()}function ze(){const e=g.value.filter(s=>s.taskId!=null&&k(s)==="RUNNING").map(s=>s.taskId);e.length&&(f.value=Array.from(new Set([...f.value,...e])),S(),F())}async function B(){x.value=await ts()}async function H(e={}){const s=Date.now();if(!(!e.force&&g.value.length&&s-ae{g.value=Ge(g.value,t.items||[]),ae=Date.now()}).finally(()=>{R=null}),R)}function Ge(e,s){const t=new Map,n=new Map;for(const l of e||[])l.taskId!=null&&t.set(l.taskId,l),l.resultId!=null&&n.set(l.resultId,l);return(s||[]).map(l=>{const o=l.resultId!=null?n.get(l.resultId):l.taskId!=null?t.get(l.taskId):void 0;return Le(o,l)})}function Le(e,s){return!e||!((k(e)==="RUNNING"||b(e)||U(e))&&!U(s)&&!J(s))?s:{...s,fileJobId:s.fileJobId??e.fileJobId,fileStatus:s.fileStatus||e.fileStatus,fileError:s.fileError||e.fileError,fileReady:s.fileReady||e.fileReady,fileProgressPercent:Z(s.fileProgressPercent)?s.fileProgressPercent:e.fileProgressPercent,fileProgressCurrent:Z(s.fileProgressCurrent)?s.fileProgressCurrent:e.fileProgressCurrent,fileProgressTotal:Z(s.fileProgressTotal)?s.fileProgressTotal:e.fileProgressTotal,fileProgressMessage:(s.fileProgressMessage||"").trim()?s.fileProgressMessage:e.fileProgressMessage}}function Z(e){const s=Number(e);return Number.isFinite(s)&&s>0}function ce(e){const s=k(e);return s==="RUNNING"?"执行中":s==="PENDING"?"已解析待推送":b(e)?"结果生成中":ee(e)?"结果生成失败":de(e)||s==="SUCCESS"||e.success?"已完成":s==="FAILED"?"失败":"等待中"}function Be(e){const s=k(e);return s==="RUNNING"||b(e)?"running":de(e)?"success":ee(e)||s==="FAILED"?"failed":s==="SUCCESS"||e.success?"success":"pending"}function b(e){const s=k(e),t=(e.fileStatus||"").toUpperCase();return s!=="SUCCESS"&&s!=="FAILED"||J(e)||t==="FAILED"||t==="SUCCESS"?!1:s==="SUCCESS"||ge(e)}function k(e){return(e.taskStatus||"").toUpperCase()}function ee(e){return(e.fileStatus||"").toUpperCase()==="FAILED"}function K(e){if(ee(e))return e.fileError||"结果文件生成失败,请稍后重试或检查后端日志";if(U(e)||!b(e))return"";const s=(e.fileStatus||"").toUpperCase();return s==="RUNNING"?"任务已完成,正在等待 Coze 回流并组装结果文件,下载按钮稍后出现":s==="PENDING"?"任务已完成,结果文件已入队,正在等待后端开始处理":"任务已完成,正在生成结果文件,下载按钮稍后出现"}function J(e){return!!(e.resultId&&(e.fileReady||e.downloadUrl))}function de(e){return J(e)}function q(e){const s=Number(e.fileProgressPercent||0);return Number.isFinite(s)&&s>0?Math.max(0,Math.min(100,Math.round(s))):he(e)?ve(e)==="PENDING"?6:10:0}function fe(e,s){const t=(e.fileProgressMessage||"").trim();return t?He(t):s}function He(e){const s=e.match(/^Coze submitted (\d+) batches, completed (\d+), waiting for results$/);if(s)return`已提交 Coze ${s[1]} 批,已完成 ${s[2]} 批,等待结果回流`;const t=e.match(/^Coze completed (\d+) batches, waiting for remaining Python data$/);return t?`Coze 已完成 ${t[1]} 批,等待 Python 继续回传数据`:{"Receiving Python data, waiting for 50 rows before Coze":"正在接收 Python 数据,累计 50 条后提交 Coze","Submitting Coze":"正在提交 Coze","Coze submitted, waiting for result":"Coze 已提交,等待结果回流","Waiting for Python upload":"等待 Python 继续回传数据","Coze results ready, assembling xlsx":"Coze 结果已回流,正在组装 xlsx","Assembling xlsx":"正在组装 xlsx","Uploading result file":"正在上传结果文件","Result file generated":"结果文件已生成"}[e]||e}function U(e){return(k(e)==="RUNNING"||b(e))&&(pe(e)||he(e))}function pe(e){return Ke(e)||Number(e.fileProgressPercent||0)>0||!!(e.fileProgressMessage||"").trim()}function Ke(e){return(e.fileProgressTotal||0)>0}function ve(e){return(e.fileStatus||"").toUpperCase()}function ge(e){const s=ve(e);return e.fileJobId!=null||s==="PENDING"||s==="RUNNING"}function he(e){return ge(e)&&!pe(e)}async function Je(e){if(!e.resultId)return;const s=e.downloadUrl||ns(e.resultId),t=e.resultFilename||`${e.sourceFilename||"appearance-patent"}.xlsx`,n=await ls(s,t,`appearance-patent:${e.resultId}`);n.success?p.success(`已保存: ${n.path||t}`):n.error&&n.error!=="用户取消"&&p.error(n.error)}async function ye(e){try{e.taskStatus==="RUNNING"&&e.taskId?(await rs(e.taskId),Q(e.taskId)):e.resultId&&await os(e.resultId),await B(),await H({force:!0}),p.success("已删除")}catch(s){p.error(s instanceof Error?s.message:"删除失败")}}return je(async()=>{be(),await Promise.all([B().catch(()=>{}),H().catch(()=>{})]),ze(),Me(),(f.value.length||u.value.length)&&F()}),Oe(()=>{A=!0,ie(),E.clearScope()}),(e,s)=>(i(),c("div",vs,[Ve(We,{active:"appearance-patent"}),a("div",gs,[a("aside",hs,[s[3]||(s[3]=a("div",{class:"section-title"},"上传文件",-1)),a("div",ys,[s[1]||(s[1]=a("div",{class:"hint"},"选择 Excel 后点击解析,后端会提取 id、ASIN、国家,并保留整数 id 与子数据的第一条。",-1)),a("div",{class:"btns"},[a("button",{type:"button",class:"opt-btn",onClick:me},"选择 Excel"),a("button",{type:"button",class:"opt-btn",onClick:Fe},"选择文件夹")]),a("div",ks,[D.value.length?(i(!0),c(se,{key:1},te(D.value,t=>(i(),c("span",{key:t},r(t),1))),128)):(i(),c("span",Is,"暂未选择文件"))])]),a("div",_s,[s[2]||(s[2]=a("div",{class:"section-title"},"新增条件要求",-1)),Ye(a("textarea",{"onUpdate:modelValue":s[0]||(s[0]=t=>$.value=t),class:"prompt-input",rows:"8",placeholder:"可选,留空则不向 Coze 传 prompt 字段;填写后将作为 prompt 透传"},null,512),[[Qe,$.value]])]),a("div",Ps,[a("button",{type:"button",class:"btn-run",disabled:M.value||!N.value.length,onClick:Ne},r(M.value?"解析中...":"解析并创建任务"),9,ws),a("button",{type:"button",class:"btn-run btn-queue",disabled:z.value||!P.value?.taskId,onClick:Te},r(z.value?"推送中...":"推送到 Python 队列"),9,Cs)]),s[4]||(s[4]=a("p",{class:"loading-msg"},"Python 回传字段按 asin、国家、url、标题提交;后端接收分片后累计 50 条调用 Coze,当前任务区展示 Coze 回流和结果组装进度。",-1)),m.value?(i(),c("div",Ss,[a("div",null,"任务 ID:"+r(m.value.taskId),1),a("div",null,"总行数:"+r(m.value.totalRows)+",有效:"+r(m.value.acceptedRows)+",分组:"+r(m.value.groupCount||0)+",过滤:"+r(m.value.droppedRows),1)])):_("",!0),C.value?(i(),c("pre",bs,r(C.value),1)):_("",!0)]),a("section",ms,[s[12]||(s[12]=a("div",{class:"panel-header"},"外观专利检测",-1)),a("div",Fs,[a("div",Ns,[a("div",Ts,[s[5]||(s[5]=a("span",{class:"summary-label"},"运行中任务",-1)),a("strong",null,r(x.value.pendingTaskCount),1)]),a("div",Rs,[s[6]||(s[6]=a("span",{class:"summary-label"},"已结束任务",-1)),a("strong",null,r(x.value.processedTaskCount),1)]),a("div",As,[s[7]||(s[7]=a("span",{class:"summary-label"},"成功任务",-1)),a("strong",null,r(x.value.successTaskCount),1)]),a("div",Es,[s[8]||(s[8]=a("span",{class:"summary-label"},"失败任务",-1)),a("strong",null,r(x.value.failedTaskCount),1)])]),s[11]||(s[11]=a("div",{class:"subsection-title"},"匹配任务",-1)),a("div",xs,[s[9]||(s[9]=a("div",{class:"result-list-header"},[a("span",null,"当前任务")],-1)),ne.value.length?(i(),c("ul",Ds,[(i(!0),c(se,null,te(ne.value,t=>(i(),c("li",{key:`cur-${t.taskId}`,class:"task-item"},[a("div",$s,[a("span",Ms,r(t.sourceFilename||"外观专利检测"),1),a("div",zs,"任务 ID:"+r(t.taskId),1),a("div",Gs,"开始时间:"+r(L(t.startedAt||t.createdAt)),1),a("div",Ls,"结束时间:"+r(L(t.finishedAt)),1),a("div",Bs,"行数:"+r(t.rowCount??"-"),1),K(t)?(i(),c("div",Hs,r(K(t)),1)):_("",!0),U(t)?(i(),c("div",Ks,[a("div",Js,[a("span",null,r(fe(t,"处理中")),1),a("span",null,r(q(t))+"%",1)]),a("div",qs,[a("div",{class:"file-progress-bar",style:ke({width:`${q(t)}%`})},null,4)])])):_("",!0)]),a("div",js,[a("span",Os,r(ce(t)),1),a("button",{type:"button",class:"btn-delete",onClick:n=>ye(t)},"删除",8,Vs)])]))),128))])):(i(),c("div",Us,"暂无当前任务"))]),a("div",Ws,[s[10]||(s[10]=a("div",{class:"result-list-header"},[a("span",null,"历史记录")],-1)),le.value.length?(i(),c("ul",Qs,[(i(!0),c(se,null,te(le.value,t=>(i(),c("li",{key:`his-${t.resultId}-${t.taskId}`,class:"task-item"},[a("div",Xs,[a("span",Zs,r(t.sourceFilename||"外观专利检测"),1),a("div",et,"任务 ID:"+r(t.taskId??"-"),1),a("div",st,"开始时间:"+r(L(t.startedAt||t.createdAt)),1),a("div",tt,"结束时间:"+r(L(t.finishedAt)),1),t.resultFilename?(i(),c("div",at,r(t.resultFilename||"下载结果"),1)):_("",!0),K(t)?(i(),c("div",nt,r(K(t)),1)):_("",!0),U(t)?(i(),c("div",lt,[a("div",rt,[a("span",null,r(fe(t,"结果生成中")),1),a("span",null,r(q(t))+"%",1)]),a("div",ot,[a("div",{class:"file-progress-bar",style:ke({width:`${q(t)}%`})},null,4)])])):_("",!0),t.error?(i(),c("div",ut,"错误:"+r(t.error),1)):_("",!0)]),a("div",it,[a("span",{class:Xe(["status",Be(t)])},r(ce(t)),3),J(t)?(i(),c("button",{key:0,type:"button",class:"download",onClick:n=>Je(t)},"下载",8,ct)):_("",!0),t.resultId?(i(),c("button",{key:1,type:"button",class:"btn-delete",onClick:n=>ye(t)},"删除",8,dt)):_("",!0)])]))),128))])):(i(),c("div",Ys,"暂无历史记录"))])])])])]))}}),vt=is(pt,[["__scopeId","data-v-aad32577"]]);cs(vt).use(ds).mount("#app"); diff --git a/app/config.py b/app/config.py index 86a8d2f..182b87e 100644 --- a/app/config.py +++ b/app/config.py @@ -22,7 +22,7 @@ proxy_url = os.getenv("proxy_url") proxy_mode = int(os.getenv("proxy_mode",1)) client_name=os.getenv("client_name") + ".exe" -JAVA_API_BASE = os.getenv("java_api_base", "http://api.aishufu.top:18080/").rstrip("/") +JAVA_API_BASE = os.getenv("java_api_base", "http://api.aishufu.top:18080/").rstrip("/") # 紫鸟浏览器配置 from urllib.parse import unquote @@ -32,7 +32,7 @@ ZN_USERNAME = unquote(ZN_USERNAME) ZN_PASSWORD = os.getenv("zn_password", "#20zsg25") # 删除品牌API配置 -DELETE_BRAND_API_BASE = os.getenv("DELETE_BRAND_API_BASE", JAVA_API_BASE).rstrip("/") +DELETE_BRAND_API_BASE = os.getenv("DELETE_BRAND_API_BASE", JAVA_API_BASE).rstrip("/") cache_path = "./user_data" @@ -52,7 +52,7 @@ os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret -debug = True +debug = False version = "1.0.13" APP_UPDATE_URL = f"{_base_url}/api/version/latest" # 检测更新接口地址,返回 { "file_url": "...", "version": "x.x.x" } os.environ['APP_VERSION'] = version diff --git a/backend-java/pom.xml b/backend-java/pom.xml index c7edf68..ea9047b 100644 --- a/backend-java/pom.xml +++ b/backend-java/pom.xml @@ -171,6 +171,13 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + -XX:+EnableDynamicAgentLoading -Xshare:off + + diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/TaskOperationLockConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/TaskOperationLockConfig.java index 66fd751..0c47319 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/TaskOperationLockConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/TaskOperationLockConfig.java @@ -64,6 +64,9 @@ public class TaskOperationLockConfig implements WebMvcConfigurer { return true; } String uri = request.getRequestURI(); + if ("POST".equals(request.getMethod()) && TASK_RESULT_PATH.matcher(uri == null ? "" : uri).matches()) { + return true; + } Matcher matcher = TASK_PATH.matcher(uri == null ? "" : uri); if (!matcher.matches()) { return true; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java index 5248765..b79124c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java @@ -587,9 +587,11 @@ public class AppearancePatentCozeClient { } if (isNoTitleRisk(result)) { row.setTitleRisk(NO_INFRINGEMENT); - } else { + } else if (shouldCheckBrand(result)) { BrandCheckClient.BrandCheckBatchResult brandCheck = brandCheckClient.checkTitleText(result.title(), "Terms"); row.setTitleRisk(buildTitleRisk(result.title(), brandCheck)); + } else { + row.setTitleRisk(""); } row.setAppearanceRisk(result.appearance()); row.setPatentRisk(result.patent()); @@ -603,8 +605,13 @@ public class AppearancePatentCozeClient { private boolean isNoTitleRisk(CozeResult result) { return result != null - && "无".equals(normalize(result.title())) - && "无".equals(normalize(result.titleReason())); + && "无".equals(normalize(result.title())); + } + + private boolean shouldCheckBrand(CozeResult result) { + return result != null + && !normalize(result.title()).isBlank() + && !normalize(result.appearance()).isBlank(); } String buildTitleRisk(String cozeTitle, BrandCheckClient.BrandCheckBatchResult brandCheck) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java index b59ea53..80deb27 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java @@ -83,7 +83,7 @@ public class AppearancePatentResultRowDto { private String patentReason; @JsonAlias({"score", "Score", "评分"}) - @Schema(description = "Coze 回流的评分(外观维度),透传到最终 xlsx 第一张 sheet 的“评分”列。", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Coze 回流的评分(外观维度),仅保留回流值,不写入最终 xlsx。", accessMode = Schema.AccessMode.READ_ONLY) private String score; /** diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java index 5684eaa..87e7c27 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java @@ -3,6 +3,9 @@ package com.nanri.aiimage.modules.appearancepatent.model.vo; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.util.LinkedHashMap; +import java.util.Map; + @Data @Schema(description = "外观专利检测解析出的单行数据") public class AppearancePatentParsedRowVo { @@ -44,4 +47,7 @@ public class AppearancePatentParsedRowVo { @Schema(description = "商品标题。", example = "Women Floral Dress Summer Casual") private String title; + + @Schema(description = "该 Excel 行的原始列值映射。最终生成 xlsx 时可从这里读取卖家名称、品牌等扩展字段。", example = "{\"id\":\"2_1\",\"asin\":\"B0CJ8SNXXV\",\"国家\":\"英国\"}") + private Map values = new LinkedHashMap<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index ef0e97b..53f8269 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -125,12 +125,13 @@ public class AppearancePatentTaskService { "id", "asin", "国家", + "卖家名称", + "品牌", "价格", "标题维度(商标)", "外观维度(外观设计专利)", "结论", - "状态", - "评分" + "状态" ); private final LocalFileStorageService localFileStorageService; @@ -420,13 +421,7 @@ public class AppearancePatentTaskService { } public void submitResult(Long taskId, AppearancePatentSubmitResultRequest request) { - TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId, TASK_LOCK_WAIT_MILLIS); - if (lockHandle == null) { - throw new BusinessException(40902, "任务正在处理上一批结果,请稍后重试"); - } - try (lockHandle) { - submitResultLocked(taskId, request); - } + submitResultLocked(taskId, request); } private void submitResultLocked(Long taskId, AppearancePatentSubmitResultRequest request) { @@ -1328,7 +1323,8 @@ public class AppearancePatentTaskService { } } } - boolean failed = finalError != null && !finalError.isBlank(); + boolean failedByConclusion = hasInfringingPersistedConclusion(task.getId()); + boolean failed = finalError != null && !finalError.isBlank() || failedByConclusion; boolean waitingForAssemble = !failed && assembleWorkbook && !shouldAssembleSynchronously(); task.setStatus(waitingForAssemble ? STATUS_RUNNING : (failed ? STATUS_FAILED : STATUS_SUCCESS)); task.setSuccessFileCount(failed ? 0 : 1); @@ -2407,7 +2403,8 @@ public class AppearancePatentTaskService { saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "正在上传结果文件"); fileResultMapper.updateById(result); boolean taskAlreadyFailed = STATUS_FAILED.equals(task.getStatus()) - || (task.getErrorMessage() != null && !task.getErrorMessage().isBlank()); + || (task.getErrorMessage() != null && !task.getErrorMessage().isBlank()) + || hasInfringingPersistedConclusion(task.getId()); String existingError = task.getErrorMessage(); task.setStatus(taskAlreadyFailed ? STATUS_FAILED : STATUS_SUCCESS); task.setSuccessFileCount(taskAlreadyFailed ? 0 : 1); @@ -3300,9 +3297,9 @@ public class AppearancePatentTaskService { headerStyle.setFont(font); List resultHeaders = new ArrayList<>(RESULT_HEADERS); - resultHeaders.add(4, "标题"); - resultHeaders.add(5, "图片链接"); - resultHeaders.add(6, "sku"); + resultHeaders.add(6, "标题"); + resultHeaders.add(7, "图片链接"); + resultHeaders.add(8, "sku"); Row header = sheet.createRow(0); for (int i = 0; i < resultHeaders.size(); i++) { Cell cell = header.createCell(i); @@ -3314,24 +3311,21 @@ public class AppearancePatentTaskService { List rowsToWrite = receivedRows == null ? List.of() : receivedRows; for (AppearancePatentParsedRowVo parsedRow : rowsToWrite) { AppearancePatentResultRowDto resultRow = findResultRow(parsedRow, resultMap); - String missingReason = ""; - if (resultRow == null) { - missingReason = hasPromptFields(parsedRow) ? "未匹配到检测结果" : "未送检:缺少标题或图片"; - } Row row = sheet.createRow(rowIndex++); int col = 0; row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId())); row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), "")); row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), "")); + row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "卖家名称", "卖家名", "卖家", "店铺名称", "店铺名", "seller name", "seller_name", "seller-name", "sellername", "store name", "shop name")); + row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "品牌", "brand")); row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getPrice(), "")); row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getTitle(), "") : firstNonBlank(resultRow.getTitle(), parsedRow.getTitle())); row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl())); row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getSku(), "") : firstNonBlank(resultRow.getSku(), parsedRow.getSku())); - row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getTitleRisk())); - row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getAppearanceRisk())); - row.createCell(col++).setCellValue(resultRow == null ? "未送检" : userFacingConclusion(resultRow)); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow)); - row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getScore(), "")); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getTitleRisk())); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getAppearanceRisk())); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingConclusion(resultRow)); + row.createCell(col).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow)); } writeReasonSheet(workbook, headerStyle, rowsToWrite, resultMap); workbook.write(fos); @@ -3519,6 +3513,7 @@ public class AppearancePatentTaskService { vo.setSku(skuCol >= 0 ? cell(row, skuCol, formatter) : ""); vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : ""); vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : ""); + vo.setValues(readRowValues(row, headers, formatter)); parsedRows.add(new ParsedAppearanceRow(vo, statusCol >= 0 ? cell(row, statusCol, formatter) : "")); } List allRows = parsedRows.stream() @@ -3590,6 +3585,14 @@ public class AppearancePatentTaskService { return headers; } + private Map readRowValues(Row row, List headers, DataFormatter formatter) { + Map values = new LinkedHashMap<>(); + for (int i = 0; i < headers.size(); i++) { + values.put(headers.get(i), cell(row, i, formatter)); + } + return values; + } + private boolean isAppearanceResultWorkbook(List headers) { return hasHeader(headers, "状态") && (hasHeader(headers, "结论") @@ -3790,7 +3793,8 @@ public class AppearancePatentTaskService { vo.setDownloadUrl(null); attachFileJobState(vo, row, job); vo.setTaskStatus(task == null ? null : task.getStatus()); - vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1); + vo.setSuccess(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank() + || row.getSuccess() != null && row.getSuccess() == 1); vo.setError(row.getErrorMessage()); vo.setRowCount(row.getRowCount()); vo.setCreatedAt(fmt(row.getCreatedAt())); @@ -4143,6 +4147,51 @@ public class AppearancePatentTaskService { return normalize(id) + "::" + normalize(asin).toUpperCase(Locale.ROOT) + "::" + normalize(country); } + private String readValueByHeader(AppearancePatentParsedRowVo row, String... candidates) { + if (row == null || row.getValues() == null || row.getValues().isEmpty() || candidates == null) { + return ""; + } + for (Map.Entry entry : row.getValues().entrySet()) { + String header = normalize(entry.getKey()).toLowerCase(Locale.ROOT); + for (String candidate : candidates) { + String expected = normalize(candidate).toLowerCase(Locale.ROOT); + if (!expected.isBlank() && header.contains(expected)) { + return entry.getValue() == null ? "" : entry.getValue(); + } + } + } + return ""; + } + + private boolean hasInfringingPersistedConclusion(Long taskId) { + if (taskId == null) { + return false; + } + return loadPersistedResultRows(taskId).values().stream() + .map(AppearancePatentResultRowDto::getConclusion) + .anyMatch(this::isInfringingConclusion); + } + + private boolean isInfringingConclusion(String value) { + String normalized = normalize(value); + return normalized.contains("侵权") + && !isNoInfringementConclusion(normalized); + } + + private boolean isNoInfringementConclusion(String value) { + String normalized = normalize(value); + if (normalized.isBlank()) { + return false; + } + return normalized.contains("无侵权") + || normalized.contains("未侵权") + || normalized.contains("不侵权") + || normalized.contains("未发现") && normalized.contains("侵权") + || normalized.contains("未见") && normalized.contains("侵权") + || normalized.contains("不存在") && normalized.contains("侵权") + || normalized.contains("无明显") && normalized.contains("侵权"); + } + private String userFacingCozeCellValue(AppearancePatentResultRowDto row, String value) { String normalizedValue = normalize(value); if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) { @@ -4180,11 +4229,13 @@ public class AppearancePatentTaskService { if (row == null) { return ""; } - String original = firstNonBlank(row.getStatus(), ""); - if (!row.isFailureSyntheticStatus()) { - return original; - } - return firstNonBlank(row.getError(), ""); + String conclusion = userFacingConclusion(row); + return resolveResultStatus(conclusion); + } + + static String resolveResultStatus(String conclusion) { + String normalized = conclusion == null ? "" : conclusion.trim(); + return normalized.isBlank() ? "\u5931\u8d25" : "\u6210\u529f"; } private boolean isTechnicalCozeFailure(String value) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java index f3b334c..1bbcbae 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java @@ -3,12 +3,14 @@ package com.nanri.aiimage.modules.collectdata.controller; import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataCountryPreferenceSaveRequest; import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataParseRequest; +import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSubmitResultRequest; import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataTaskBatchRequest; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataCountryPreferenceVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataDashboardVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataHistoryVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataItemsPageVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataParseVo; +import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataSubmitResultVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskBatchVo; import com.nanri.aiimage.modules.collectdata.service.CollectDataService; import io.swagger.v3.oas.annotations.Operation; @@ -65,6 +67,15 @@ public class CollectDataController { return ApiResponse.success(service.getItemsPage(taskId, userId, page, pageSize)); } + @PostMapping("/tasks/{taskId}/result") + @Operation(summary = "Python 分片回传采集结果", description = "接收任意条数的 Python 回传行;后端按 ASIN 筛库、调用品牌检测并保存最终结果。") + public ApiResponse submitResult( + @Parameter(description = "采集任务 ID", required = true, example = "9001") + @PathVariable Long taskId, + @Valid @RequestBody CollectDataSubmitResultRequest request) { + return ApiResponse.success(service.submitResult(taskId, request)); + } + @GetMapping("/dashboard") @Operation(summary = "查询采集任务总览") public ApiResponse dashboard( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitResultRequest.java new file mode 100644 index 0000000..71cdabc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitResultRequest.java @@ -0,0 +1,41 @@ +package com.nanri.aiimage.modules.collectdata.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "采集数据 Python 分片回传请求") +public class CollectDataSubmitResultRequest { + + @JsonProperty("submission_id") + @JsonAlias("submissionId") + @Schema(description = "分片提交批次标识;同一任务可固定一个 submission_id", example = "collect-11877") + private String submissionId; + + @JsonProperty("chunk_index") + @JsonAlias("chunkIndex") + @Schema(description = "分片序号,从 1 开始;未传时后端按当前任务顺序递增", example = "1") + private Integer chunkIndex; + + @JsonProperty("chunk_total") + @JsonAlias("chunkTotal") + @Schema(description = "总分片数;未知可不传,done=true 时以后端已收数量兜底", example = "10") + private Integer chunkTotal; + + @Schema(description = "是否为最后一次回传", example = "false") + private Boolean done; + + @Schema(description = "Python 端错误信息;传入时任务会标记失败") + private String error; + + @JsonAlias({"rows", "data", "items"}) + @Schema(description = "本分片回传数据行") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitRowDto.java new file mode 100644 index 0000000..ac99732 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitRowDto.java @@ -0,0 +1,32 @@ +package com.nanri.aiimage.modules.collectdata.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "采集数据 Python 回传行") +public class CollectDataSubmitRowDto { + + @JsonAlias({"品牌", "brand_name"}) + @Schema(description = "品牌", example = "Nike") + private String brand; + + @JsonAlias({"ASIN", "asin"}) + @Schema(description = "ASIN", example = "B0XXXXXXX") + private String asin; + + @JsonAlias({"价格", "price"}) + @Schema(description = "价格", example = "19.99") + private String price; + + @JsonProperty("seller_name") + @JsonAlias({"卖家名称", "sellerName", "seller"}) + @Schema(description = "卖家名称", example = "Demo Seller") + private String sellerName; + + @JsonAlias({"关键词", "key_word"}) + @Schema(description = "关键词", example = "phone case") + private String keyword; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/vo/CollectDataHistoryItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/vo/CollectDataHistoryItemVo.java index 0f1f344..860ed94 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/vo/CollectDataHistoryItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/vo/CollectDataHistoryItemVo.java @@ -37,6 +37,18 @@ public class CollectDataHistoryItemVo { @Schema(description = "落库行数") private Integer rowCount; + @Schema(description = "被数据去重总数据过滤的行数") + private Integer dedupeFilteredCount; + + @Schema(description = "被不符合 ASIN 数据库过滤的行数") + private Integer invalidFilteredCount; + + @Schema(description = "品牌检测不符合的行数") + private Integer brandRejectedCount; + + @Schema(description = "最终结果行数") + private Integer finalRowCount; + @Schema(description = "记录创建时间") private String createdAt; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/vo/CollectDataResultRowVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/vo/CollectDataResultRowVo.java new file mode 100644 index 0000000..9d2d932 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/vo/CollectDataResultRowVo.java @@ -0,0 +1,12 @@ +package com.nanri.aiimage.modules.collectdata.model.vo; + +import lombok.Data; + +@Data +public class CollectDataResultRowVo { + private String brand; + private String asin; + private String price; + private String sellerName; + private String keyword; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/vo/CollectDataSubmitResultVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/vo/CollectDataSubmitResultVo.java new file mode 100644 index 0000000..51056b9 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/vo/CollectDataSubmitResultVo.java @@ -0,0 +1,22 @@ +package com.nanri.aiimage.modules.collectdata.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "采集数据回传处理结果") +public class CollectDataSubmitResultVo { + private Long taskId; + private Long resultId; + private Integer chunkIndex; + private Integer chunkTotal; + private Boolean done; + private Integer receivedRows; + private Integer currentChunkRows; + private Integer dedupeFilteredCount; + private Integer invalidFilteredCount; + private Integer brandRejectedCount; + private Integer brandQueryFailedCount; + private Integer finalRowCount; + private String taskStatus; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataExcelAssemblyService.java new file mode 100644 index 0000000..8bbb044 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataExcelAssemblyService.java @@ -0,0 +1,61 @@ +package com.nanri.aiimage.modules.collectdata.service; + +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.List; + +@Service +@Slf4j +public class CollectDataExcelAssemblyService { + + private static final String[] HEADER = {"品牌", "ASIN", "价格", "卖家名称", "关键词"}; + + public void writeWorkbook(File outputXlsx, List items) { + SXSSFWorkbook workbook = new SXSSFWorkbook(200); + workbook.setCompressTempFiles(true); + try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) { + Sheet sheet = workbook.createSheet("采集数据结果"); + Row headerRow = sheet.createRow(0); + for (int i = 0; i < HEADER.length; i++) { + headerRow.createCell(i).setCellValue(HEADER[i]); + sheet.setColumnWidth(i, (i == 3 ? 24 : 18) * 256); + } + int rowIndex = 1; + if (items != null) { + for (CollectDataResultRowVo item : items) { + if (item == null) { + continue; + } + Row row = sheet.createRow(rowIndex++); + row.createCell(0).setCellValue(safe(item.getBrand())); + row.createCell(1).setCellValue(safe(item.getAsin())); + row.createCell(2).setCellValue(safe(item.getPrice())); + row.createCell(3).setCellValue(safe(item.getSellerName())); + row.createCell(4).setCellValue(safe(item.getKeyword())); + } + } + workbook.write(outputStream); + } catch (Exception ex) { + log.warn("[collect-data] write workbook failed: {}", ex.getMessage()); + throw new BusinessException("生成采集数据 Excel 失败: " + ex.getMessage()); + } finally { + try { + workbook.close(); + } catch (Exception ignored) { + } + workbook.dispose(); + } + } + + private String safe(String value) { + return value == null ? "" : value; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java index df4cb67..2d606d2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java @@ -1,17 +1,23 @@ package com.nanri.aiimage.modules.collectdata.service; import cn.hutool.core.util.IdUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.crypto.digest.DigestUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.brand.client.BrandCheckClient; import com.nanri.aiimage.modules.collectdata.mapper.CollectDataCountryPrefMapper; import com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper; import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataCountryPreferenceSaveRequest; import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataFiltersDto; import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataParseRequest; import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSourceFileDto; +import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSubmitResultRequest; +import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSubmitRowDto; import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataCountryPrefEntity; import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataCountryPreferenceVo; @@ -21,14 +27,30 @@ import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataHistoryVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataItemVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataItemsPageVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataParseVo; +import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo; +import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataSubmitResultVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskBatchVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskDetailVo; import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskSummaryVo; +import com.nanri.aiimage.modules.dedupe.mapper.DedupeTotalDataMapper; +import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper; +import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper; +import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper; +import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import com.nanri.aiimage.modules.task.service.TaskDistributedLockService; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; @@ -40,18 +62,23 @@ import org.apache.poi.ss.usermodel.WorkbookFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.dao.DuplicateKeyException; import java.io.File; import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.regex.Pattern; @Service @@ -74,6 +101,9 @@ public class CollectDataService { private static final String DEFAULT_TASK_TYPE = "collect-data"; private static final int ITEM_INSERT_BATCH_SIZE = 500; + private static final int BRAND_CHECK_BATCH_SIZE = 10; + private static final long TASK_LOCK_WAIT_MILLIS = 5000L; + private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final List KEYWORD_HEADER_ALIASES = List.of("关键词", "keyword", "key word"); private static final List STATUS_HEADER_ALIASES = List.of("状态", "status"); @@ -86,6 +116,17 @@ public class CollectDataService { private final FileResultMapper fileResultMapper; private final CollectDataItemMapper collectDataItemMapper; private final CollectDataCountryPrefMapper collectDataCountryPrefMapper; + private final DedupeTotalDataMapper dedupeTotalDataMapper; + private final InvalidAsinDataMapper invalidAsinDataMapper; + private final TaskChunkMapper taskChunkMapper; + private final TaskScopeStateMapper taskScopeStateMapper; + private final TaskResultItemMapper taskResultItemMapper; + private final TaskDistributedLockService taskDistributedLockService; + private final TaskFileJobService taskFileJobService; + private final TransientPayloadStorageService transientPayloadStorageService; + private final BrandCheckClient brandCheckClient; + private final CollectDataExcelAssemblyService excelAssemblyService; + private final OssStorageService ossStorageService; private final ObjectMapper objectMapper; private final TransactionTemplate transactionTemplate; @@ -343,6 +384,649 @@ public class CollectDataService { return vo; } + public CollectDataSubmitResultVo submitResult(Long taskId, CollectDataSubmitResultRequest request) { + if (request == null) { + throw new BusinessException("request is empty"); + } + ensureRustfsPayloadStorageEnabled(); + try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + if (STATUS_SUCCESS.equals(task.getStatus()) || STATUS_FAILED.equals(task.getStatus())) { + throw new BusinessException(40901, "任务已结束,拒绝重复提交"); + } + if (!STATUS_RUNNING.equals(task.getStatus())) { + task.setStatus(STATUS_RUNNING); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(null); + fileTaskMapper.updateById(task); + } + + FileResultEntity result = ensureTaskResult(task); + String scopeKey = firstNonBlank(request.getSubmissionId(), "task:" + taskId); + String scopeHash = DigestUtil.sha256Hex(scopeKey); + int chunkIndex = request.getChunkIndex() == null || request.getChunkIndex() <= 0 + ? nextChunkIndex(taskId, scopeHash) + : request.getChunkIndex(); + int chunkTotal = request.getChunkTotal() == null || request.getChunkTotal() <= 0 + ? Math.max(chunkIndex, 1) + : request.getChunkTotal(); + + TaskChunkEntity existing = taskChunkMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash) + .eq(TaskChunkEntity::getChunkIndex, chunkIndex) + .last("limit 1")); + if (existing != null) { + log.info("[collect-data] duplicate result chunk ignored taskId={} scope={} chunk={}", + taskId, scopeKey, chunkIndex); + return buildSubmitVo(task, result, chunkIndex, chunkTotal, request, 0); + } + + List rows = normalizeSubmitRows(request.getItems()); + CollectDataStats stats = loadStats(task); + stats.receivedRows += rows.size(); + stats.currentChunkRows = rows.size(); + + List candidates = filterByExistingAsin(rows, stats); + List accepted = filterByBrandCheck(candidates, stats); + for (CollectDataResultRowVo row : accepted) { + upsertResultItem(task.getId(), result.getId(), scopeKey, row); + } + + String payloadJson = writeJson(rows, "采集结果序列化失败"); + String storedPayload = transientPayloadStorageService.storeChunkPayloadVersioned( + MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson); + requireRustfsPayload(storedPayload, "采集结果分片必须写入 RustFS"); + persistChunk(taskId, scopeKey, scopeHash, chunkIndex, chunkTotal, storedPayload, payloadJson); + persistScope(taskId, scopeKey, scopeHash, chunkTotal, request); + + stats.finalRowCount = countFinalRows(taskId); + persistStats(task, stats); + + if (request.getError() != null && !request.getError().isBlank()) { + markTaskFailed(task, result, request.getError(), stats); + } else if (Boolean.TRUE.equals(request.getDone())) { + enqueueFinalWorkbook(task, result, stats); + } else { + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + } + + log.info("[collect-data] submit result taskId={} chunk={}/{} rows={} accepted={} dedupeFiltered={} invalidFiltered={} brandRejected={} finalRows={} done={}", + taskId, chunkIndex, chunkTotal, rows.size(), accepted.size(), stats.dedupeFilteredCount, + stats.invalidFilteredCount, stats.brandRejectedCount, stats.finalRowCount, request.getDone()); + return buildSubmitVo(fileTaskMapper.selectById(taskId), result, chunkIndex, chunkTotal, request, rows.size()); + } + } + + private FileResultEntity ensureTaskResult(FileTaskEntity task) { + FileResultEntity result = fileResultMapper.selectOne(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, task.getId()) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .orderByAsc(FileResultEntity::getId) + .last("limit 1")); + if (result != null) { + return result; + } + result = new FileResultEntity(); + result.setTaskId(task.getId()); + result.setModuleType(MODULE_TYPE); + result.setSourceFilename(""); + result.setRowCount(0); + result.setUserId(task.getUserId()); + result.setCreatedAt(LocalDateTime.now()); + fileResultMapper.insert(result); + return result; + } + + private List normalizeSubmitRows(List rawRows) { + List rows = new ArrayList<>(); + if (rawRows == null) { + return rows; + } + for (CollectDataSubmitRowDto raw : rawRows) { + if (raw == null) { + continue; + } + String asin = normalizeAsin(raw.getAsin()); + if (asin.isBlank()) { + continue; + } + CollectDataResultRowVo row = new CollectDataResultRowVo(); + row.setBrand(normalize(raw.getBrand())); + row.setAsin(asin); + row.setPrice(normalize(raw.getPrice())); + row.setSellerName(normalize(raw.getSellerName())); + row.setKeyword(normalize(raw.getKeyword())); + rows.add(row); + } + return rows; + } + + private List filterByExistingAsin(List rows, CollectDataStats stats) { + if (rows == null || rows.isEmpty()) { + return List.of(); + } + List asins = rows.stream() + .map(CollectDataResultRowVo::getAsin) + .filter(value -> value != null && !value.isBlank()) + .distinct() + .toList(); + Set dedupeValues = new HashSet<>(); + if (!asins.isEmpty()) { + List existingDedupeValues = dedupeTotalDataMapper.selectExistingDataValues(asins); + if (existingDedupeValues != null) { + dedupeValues.addAll(existingDedupeValues.stream() + .map(this::normalizeAsin) + .toList()); + } + } + Set invalidValues = new HashSet<>(); + if (!asins.isEmpty()) { + List invalidRows = invalidAsinDataMapper.selectList(new LambdaQueryWrapper() + .in(InvalidAsinDataEntity::getDataValue, asins)); + if (invalidRows != null) { + for (InvalidAsinDataEntity row : invalidRows) { + invalidValues.add(normalizeAsin(row.getDataValue())); + } + } + } + + List out = new ArrayList<>(); + for (CollectDataResultRowVo row : rows) { + String asin = row.getAsin(); + if (dedupeValues.contains(asin)) { + stats.dedupeFilteredCount++; + continue; + } + if (invalidValues.contains(asin)) { + stats.invalidFilteredCount++; + continue; + } + out.add(row); + } + return out; + } + + private List filterByBrandCheck(List rows, CollectDataStats stats) { + if (rows == null || rows.isEmpty()) { + return List.of(); + } + List accepted = new ArrayList<>(); + for (int start = 0; start < rows.size(); start += BRAND_CHECK_BATCH_SIZE) { + int end = Math.min(start + BRAND_CHECK_BATCH_SIZE, rows.size()); + List batch = rows.subList(start, end); + List brands = batch.stream() + .map(CollectDataResultRowVo::getBrand) + .filter(value -> value != null && !value.isBlank()) + .distinct() + .toList(); + BrandCheckClient.BrandCheckBatchResult check = brandCheckClient.checkAll(brands, "Terms"); + Set failedBrands = normalizeObjectSet(check == null ? null : check.faildData()); + Set queryFailedBrands = normalizeObjectSet(check == null ? null : check.queryFaildData()); + for (CollectDataResultRowVo row : batch) { + String brand = normalizeBrand(row.getBrand()); + if (brand.isBlank()) { + stats.brandRejectedCount++; + insertInvalidAsin(row); + continue; + } + if (failedBrands.contains(brand)) { + stats.brandRejectedCount++; + insertInvalidAsin(row); + continue; + } + if (queryFailedBrands.contains(brand)) { + stats.brandQueryFailedCount++; + insertInvalidAsin(row); + continue; + } + accepted.add(row); + } + } + return accepted; + } + + private Set normalizeObjectSet(List values) { + Set out = new HashSet<>(); + if (values == null) { + return out; + } + for (Object value : values) { + String normalized = normalizeBrand(extractBrandValue(value)); + if (!normalized.isBlank()) { + out.add(normalized); + } + } + return out; + } + + private String extractBrandValue(Object value) { + if (value == null) { + return ""; + } + if (value instanceof Map map) { + for (String key : List.of("brand", "brandName", "brand_name", "name", "value", "data_value")) { + Object candidate = map.get(key); + if (candidate != null && !String.valueOf(candidate).isBlank()) { + return String.valueOf(candidate); + } + } + } + return String.valueOf(value); + } + + private void insertInvalidAsin(CollectDataResultRowVo row) { + if (row == null || row.getAsin() == null || row.getAsin().isBlank()) { + return; + } + InvalidAsinDataEntity entity = new InvalidAsinDataEntity(); + entity.setDataValue(row.getAsin()); + entity.setBrand(row.getBrand()); + try { + invalidAsinDataMapper.insert(entity); + } catch (DuplicateKeyException ignored) { + } + } + + private void upsertResultItem(Long taskId, Long resultId, String scopeKey, CollectDataResultRowVo row) { + String itemKey = "asin:" + row.getAsin(); + String scopeHash = hash(scopeKey); + String payloadJson = writeJson(row, "采集结果明细序列化失败"); + String payloadHash = hash(payloadJson); + TaskResultItemEntity existing = taskResultItemMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, MODULE_TYPE) + .eq(TaskResultItemEntity::getScopeHash, scopeHash) + .eq(TaskResultItemEntity::getItemKey, itemKey) + .last("limit 1")); + if (existing != null && Objects.equals(existing.getPayloadHash(), payloadHash)) { + return; + } + String storedPayload = transientPayloadStorageService.storeResultItemPayload( + MODULE_TYPE, taskId, scopeHash, itemKey, payloadJson); + requireRustfsPayload(storedPayload, "采集结果明细必须写入 RustFS"); + LocalDateTime now = LocalDateTime.now(); + if (existing == null) { + TaskResultItemEntity entity = new TaskResultItemEntity(); + entity.setTaskId(taskId); + entity.setModuleType(MODULE_TYPE); + entity.setResultId(resultId); + entity.setScopeKey(scopeKey); + entity.setScopeHash(scopeHash); + entity.setItemKey(itemKey); + entity.setAsin(row.getAsin()); + entity.setStatus("ACCEPTED"); + entity.setPayloadJson(storedPayload); + entity.setPayloadHash(payloadHash); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskResultItemMapper.insert(entity); + return; + } catch (DuplicateKeyException ignored) { + existing = taskResultItemMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, MODULE_TYPE) + .eq(TaskResultItemEntity::getScopeHash, scopeHash) + .eq(TaskResultItemEntity::getItemKey, itemKey) + .last("limit 1")); + } + } + if (existing == null) { + throw new BusinessException("保存采集结果明细失败"); + } + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(existing.getPayloadJson(), storedPayload); + taskResultItemMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskResultItemEntity::getId, existing.getId()) + .set(TaskResultItemEntity::getResultId, resultId) + .set(TaskResultItemEntity::getScopeKey, scopeKey) + .set(TaskResultItemEntity::getAsin, row.getAsin()) + .set(TaskResultItemEntity::getStatus, "ACCEPTED") + .set(TaskResultItemEntity::getPayloadJson, storedPayload) + .set(TaskResultItemEntity::getPayloadHash, payloadHash) + .set(TaskResultItemEntity::getUpdatedAt, now)); + } + + private void persistChunk(Long taskId, + String scopeKey, + String scopeHash, + int chunkIndex, + int chunkTotal, + String storedPayload, + String payloadJson) { + TaskChunkEntity chunk = new TaskChunkEntity(); + chunk.setTaskId(taskId); + chunk.setModuleType(MODULE_TYPE); + chunk.setScopeKey(scopeKey); + chunk.setScopeHash(scopeHash); + chunk.setChunkIndex(chunkIndex); + chunk.setChunkTotal(chunkTotal); + chunk.setPayloadJson(storedPayload); + chunk.setPayloadHash(DigestUtil.sha256Hex(payloadJson)); + chunk.setCreatedAt(LocalDateTime.now()); + chunk.setUpdatedAt(LocalDateTime.now()); + try { + taskChunkMapper.insert(chunk); + } catch (DuplicateKeyException ex) { + transientPayloadStorageService.deletePayloadIfPresent(storedPayload); + log.info("[collect-data] duplicate chunk inserted concurrently taskId={} scope={} chunk={}", + taskId, scopeKey, chunkIndex); + } + } + + private void persistScope(Long taskId, String scopeKey, String scopeHash, int chunkTotal, CollectDataSubmitResultRequest request) { + TaskScopeStateEntity scope = taskScopeStateMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .eq(TaskScopeStateEntity::getScopeHash, scopeHash) + .last("limit 1")); + if (scope == null) { + scope = new TaskScopeStateEntity(); + scope.setTaskId(taskId); + scope.setModuleType(MODULE_TYPE); + scope.setScopeKey(scopeKey); + scope.setScopeHash(scopeHash); + scope.setCreatedAt(LocalDateTime.now()); + } + scope.setChunkTotal(chunkTotal); + scope.setReceivedChunkCount(countReceivedChunks(taskId, scopeHash)); + scope.setLastChunkAt(LocalDateTime.now()); + scope.setLastError(request.getError()); + scope.setCompleted(Boolean.TRUE.equals(request.getDone()) ? 1 : 0); + scope.setStateJson("{\"phase\":\"RECEIVED\"}"); + scope.setUpdatedAt(LocalDateTime.now()); + if (scope.getId() == null) { + taskScopeStateMapper.insert(scope); + } else { + taskScopeStateMapper.updateById(scope); + } + } + + private void enqueueFinalWorkbook(FileTaskEntity task, FileResultEntity result, CollectDataStats stats) { + String filename = buildResultFilename(task); + result.setResultFilename(filename); + result.setResultFileUrl(null); + result.setResultFileSize(0L); + result.setResultContentType(CONTENT_TYPE_XLSX); + result.setRowCount(stats.finalRowCount); + result.setErrorMessage(null); + fileResultMapper.updateById(result); + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, result.getId(), "task:" + task.getId()); + } + + private void markTaskFailed(FileTaskEntity task, FileResultEntity result, String error, CollectDataStats stats) { + result.setSuccess(0); + result.setErrorMessage(error); + result.setRowCount(stats.finalRowCount); + fileResultMapper.updateById(result); + task.setStatus(STATUS_FAILED); + task.setErrorMessage(error); + task.setFailedFileCount(1); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + persistStats(task, stats); + fileTaskMapper.updateById(task); + } + + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null) { + throw new BusinessException("结果文件任务参数不完整"); + } + FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId()); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + FileResultEntity result = fileResultMapper.selectById(job.getResultId()); + if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { + throw new BusinessException("结果记录不存在"); + } + List rows = loadFinalRows(task.getId()); + File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "collect-data-result", String.valueOf(task.getId()))); + String filename = buildResultFilename(task); + File xlsx = FileUtil.file(workRoot, filename); + try { + excelAssemblyService.writeWorkbook(xlsx, rows); + String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); + result.setResultFilename(filename); + result.setResultFileUrl(objectKey); + result.setResultFileSize(xlsx.length()); + result.setResultContentType(CONTENT_TYPE_XLSX); + result.setRowCount(rows.size()); + result.setSuccess(1); + result.setErrorMessage(null); + fileResultMapper.updateById(result); + + CollectDataStats stats = loadStats(task); + stats.finalRowCount = rows.size(); + persistStats(task, stats); + task.setStatus(STATUS_SUCCESS); + task.setSuccessFileCount(1); + task.setFailedFileCount(0); + task.setErrorMessage(null); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + } finally { + FileUtil.del(xlsx); + } + } + + private List loadFinalRows(Long taskId) { + List rows = taskResultItemMapper.selectList(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, MODULE_TYPE) + .orderByAsc(TaskResultItemEntity::getId)); + List out = new ArrayList<>(); + if (rows == null) { + return out; + } + for (TaskResultItemEntity row : rows) { + try { + String payloadJson = transientPayloadStorageService.resolvePayload(row.getPayloadJson(), "read collect data result item failed"); + CollectDataResultRowVo value = objectMapper.readValue(payloadJson, CollectDataResultRowVo.class); + if (value != null) { + out.add(value); + } + } catch (Exception ex) { + throw new BusinessException("读取采集结果明细失败"); + } + } + return out; + } + + private int countFinalRows(Long taskId) { + if (taskId == null || taskId <= 0) { + return 0; + } + Long count = taskResultItemMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)); + return count == null ? 0 : count.intValue(); + } + + private void ensureRustfsPayloadStorageEnabled() { + if (!transientPayloadStorageService.isSharedWriteEnabled()) { + throw new BusinessException("RustFS 未配置,采集结果回传暂不可接收"); + } + } + + private void requireRustfsPayload(String storedPayload, String message) { + String pointer = transientPayloadStorageService.extractPointer(storedPayload); + if (pointer != null && pointer.startsWith("rustfs:")) { + return; + } + transientPayloadStorageService.deletePayloadIfPresent(storedPayload); + throw new BusinessException(message); + } + + private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) { + TaskDistributedLockService.LockHandle lockHandle = taskDistributedLockService.acquire(MODULE_TYPE, taskId, TASK_LOCK_WAIT_MILLIS); + if (lockHandle == null) { + throw new BusinessException(40901, "任务正在处理,请稍后再试"); + } + return lockHandle; + } + + private int nextChunkIndex(Long taskId, String scopeHash) { + TaskChunkEntity latest = taskChunkMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash) + .orderByDesc(TaskChunkEntity::getChunkIndex) + .last("limit 1")); + return latest == null || latest.getChunkIndex() == null ? 1 : latest.getChunkIndex() + 1; + } + + private int countReceivedChunks(Long taskId, String scopeHash) { + Long count = taskChunkMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash)); + return count == null ? 0 : count.intValue(); + } + + private CollectDataSubmitResultVo buildSubmitVo(FileTaskEntity task, + FileResultEntity result, + int chunkIndex, + int chunkTotal, + CollectDataSubmitResultRequest request, + int currentChunkRows) { + CollectDataStats stats = loadStats(task); + stats.finalRowCount = countFinalRows(task == null ? null : task.getId()); + CollectDataSubmitResultVo vo = new CollectDataSubmitResultVo(); + vo.setTaskId(task == null ? null : task.getId()); + vo.setResultId(result == null ? null : result.getId()); + vo.setChunkIndex(chunkIndex); + vo.setChunkTotal(chunkTotal); + vo.setDone(request != null && Boolean.TRUE.equals(request.getDone())); + vo.setReceivedRows(stats.receivedRows); + vo.setCurrentChunkRows(currentChunkRows); + vo.setDedupeFilteredCount(stats.dedupeFilteredCount); + vo.setInvalidFilteredCount(stats.invalidFilteredCount); + vo.setBrandRejectedCount(stats.brandRejectedCount); + vo.setBrandQueryFailedCount(stats.brandQueryFailedCount); + vo.setFinalRowCount(stats.finalRowCount); + vo.setTaskStatus(task == null ? null : task.getStatus()); + return vo; + } + + private CollectDataStats loadStats(FileTaskEntity task) { + CollectDataStats stats = new CollectDataStats(); + if (task == null || task.getResultJson() == null || task.getResultJson().isBlank()) { + return stats; + } + try { + JsonNode root = objectMapper.readTree(task.getResultJson()); + stats.receivedRows = root.path("receivedRows").asInt(0); + stats.currentChunkRows = root.path("currentChunkRows").asInt(0); + stats.dedupeFilteredCount = root.path("dedupeFilteredCount").asInt(0); + stats.invalidFilteredCount = root.path("invalidFilteredCount").asInt(0); + stats.brandRejectedCount = root.path("brandRejectedCount").asInt(0); + stats.brandQueryFailedCount = root.path("brandQueryFailedCount").asInt(0); + stats.finalRowCount = root.path("finalRowCount").asInt(0); + } catch (Exception ex) { + log.warn("[collect-data] parse result stats failed taskId={} err={}", task.getId(), ex.getMessage()); + } + return stats; + } + + private void persistStats(FileTaskEntity task, CollectDataStats stats) { + if (task == null || stats == null) { + return; + } + try { + Map payload = new LinkedHashMap<>(); + payload.put("receivedRows", stats.receivedRows); + payload.put("currentChunkRows", stats.currentChunkRows); + payload.put("dedupeFilteredCount", stats.dedupeFilteredCount); + payload.put("invalidFilteredCount", stats.invalidFilteredCount); + payload.put("brandRejectedCount", stats.brandRejectedCount); + payload.put("brandQueryFailedCount", stats.brandQueryFailedCount); + payload.put("finalRowCount", stats.finalRowCount); + task.setResultJson(objectMapper.writeValueAsString(payload)); + } catch (Exception ex) { + throw new BusinessException("保存采集统计失败"); + } + } + + private String writeJson(Object value, String message) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException(message); + } + } + + private String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception ex) { + throw new IllegalStateException("failed to hash collect data payload", ex); + } + } + + private String normalizeAsin(String value) { + return normalize(value).toUpperCase(Locale.ROOT); + } + + private String normalizeBrand(String value) { + return normalize(value).toLowerCase(Locale.ROOT); + } + + private String buildResultFilename(FileTaskEntity task) { + String base = task == null || task.getTaskNo() == null || task.getTaskNo().isBlank() + ? "collect-data-" + (task == null ? "result" : task.getId()) + : task.getTaskNo(); + return base.replaceAll("[\\\\/:*?\"<>|]", "_") + "-result.xlsx"; + } + + private void deleteTransientTaskPayloads(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .select(TaskChunkEntity::getPayloadJson) + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + if (chunks != null) { + for (TaskChunkEntity chunk : chunks) { + transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson()); + } + } + List items = taskResultItemMapper.selectList(new LambdaQueryWrapper() + .select(TaskResultItemEntity::getPayloadJson) + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)); + if (items != null) { + for (TaskResultItemEntity item : items) { + transientPayloadStorageService.deletePayloadIfPresent(item.getPayloadJson()); + } + } + } + + private static class CollectDataStats { + private int receivedRows; + private int currentChunkRows; + private int dedupeFilteredCount; + private int invalidFilteredCount; + private int brandRejectedCount; + private int brandQueryFailedCount; + private int finalRowCount; + } + @Transactional public void deleteTask(Long taskId, Long userId) { FileTaskEntity task = requireTask(taskId, userId); @@ -351,6 +1035,17 @@ public class CollectDataService { fileResultMapper.delete(new LambdaQueryWrapper() .eq(FileResultEntity::getTaskId, task.getId()) .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + deleteTransientTaskPayloads(task.getId()); + taskScopeStateMapper.delete(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, task.getId()) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + taskChunkMapper.delete(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, task.getId()) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + taskResultItemMapper.delete(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, task.getId()) + .eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)); + taskFileJobService.deleteTaskJobs(task.getId(), MODULE_TYPE); fileTaskMapper.deleteById(task.getId()); } @@ -359,6 +1054,21 @@ public class CollectDataService { if (row == null || !MODULE_TYPE.equals(row.getModuleType()) || !Objects.equals(userId, row.getUserId())) { throw new BusinessException("记录不存在"); } + List resultItems = taskResultItemMapper.selectList(new LambdaQueryWrapper() + .select(TaskResultItemEntity::getPayloadJson) + .eq(TaskResultItemEntity::getTaskId, row.getTaskId()) + .eq(TaskResultItemEntity::getModuleType, MODULE_TYPE) + .eq(TaskResultItemEntity::getResultId, row.getId())); + if (resultItems != null) { + for (TaskResultItemEntity item : resultItems) { + transientPayloadStorageService.deletePayloadIfPresent(item.getPayloadJson()); + } + } + taskResultItemMapper.delete(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, row.getTaskId()) + .eq(TaskResultItemEntity::getModuleType, MODULE_TYPE) + .eq(TaskResultItemEntity::getResultId, row.getId())); + taskFileJobService.deleteResultJobs(row.getTaskId(), MODULE_TYPE, row.getId()); fileResultMapper.deleteById(resultId); } @@ -503,21 +1213,41 @@ public class CollectDataService { vo.setTaskId(row.getTaskId()); vo.setSourceFilename(row.getSourceFilename()); vo.setResultFilename(row.getResultFilename()); + vo.setDownloadUrl(buildFreshDownloadUrl(row)); vo.setRowCount(row.getRowCount()); vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1); vo.setError(row.getErrorMessage()); vo.setCreatedAt(formatTime(row.getCreatedAt())); if (task != null) { + CollectDataStats stats = loadStats(task); vo.setTaskNo(task.getTaskNo()); vo.setTaskStatus(task.getStatus()); vo.setStartedAt(formatTime(task.getCreatedAt())); vo.setFinishedAt(formatTime(task.getFinishedAt())); vo.setTaskType(extractTaskTypeFromRequest(task)); vo.setFilters(extractFiltersFromRequest(task)); + vo.setDedupeFilteredCount(stats.dedupeFilteredCount); + vo.setInvalidFilteredCount(stats.invalidFilteredCount); + vo.setBrandRejectedCount(stats.brandRejectedCount); + vo.setFinalRowCount(stats.finalRowCount); } return vo; } + private String buildFreshDownloadUrl(FileResultEntity row) { + String resultFileUrl = row == null ? null : row.getResultFileUrl(); + if (resultFileUrl == null || resultFileUrl.isBlank()) { + return null; + } + try { + return ossStorageService.generateFreshDownloadUrl(resultFileUrl); + } catch (Exception ex) { + log.warn("[collect-data] generate fresh download url failed resultId={} err={}", + row.getId(), ex.getMessage()); + return null; + } + } + private CollectDataTaskSummaryVo toTaskSummary(FileTaskEntity task) { CollectDataTaskSummaryVo vo = new CollectDataTaskSummaryVo(); vo.setId(task.getId()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java index f950335..83568dc 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java @@ -35,6 +35,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -291,7 +292,7 @@ public class ConvertRunService { long batchId = System.currentTimeMillis(); int[] rowIndex = new int[]{1}; boolean[] headerRead = new boolean[]{false}; - Map[] headerMapHolder = new Map[]{null}; + AtomicReference> headerMapHolder = new AtomicReference<>(); ExcelStreamReader.readFirstSheet(inputFile, new ExcelStreamReader.SheetRowHandler() { @Override @@ -310,7 +311,7 @@ public class ConvertRunService { throw new BusinessException("Missing required columns: " + String.join(", ", missing)); } headerRead[0] = true; - headerMapHolder[0] = resolvedHeaderMap; + headerMapHolder.set(resolvedHeaderMap); for (String line : preambleLines) { writeLineToAll(writers, line); } @@ -322,7 +323,7 @@ public class ConvertRunService { @Override public void onRow(String sheetName, Integer sheetNo, int excelRowIndex, Map headerMap, Map rowMap) throws Exception { - Map resolvedHeaderMap = headerMapHolder[0]; + Map resolvedHeaderMap = headerMapHolder.get(); if (resolvedHeaderMap == null) { throw new BusinessException("Excel header row is empty."); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java index 39bd653..5fc6c8a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java @@ -117,7 +117,7 @@ public class DeleteBrandTaskCacheService { org.springframework.data.redis.serializer.RedisSerializer serializer = stringRedisTemplate.getStringSerializer(); for (Long taskId : missingTaskIds) { - connection.hGetAll(serializer.serialize(buildProgressKey(taskId))); + connection.hashCommands().hGetAll(serializer.serialize(buildProgressKey(taskId))); } return null; }); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index dc57773..fd60a86 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -50,6 +50,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; @Service @RequiredArgsConstructor @@ -792,7 +793,7 @@ public class PriceTrackTaskService { private Map>> parseWorkbookAsinRows(File file, List countryCodes) { Map>> out = new LinkedHashMap<>(); - Map[] headerIndexHolder = new Map[]{Map.of()}; + AtomicReference> headerIndexHolder = new AtomicReference<>(Map.of()); String[] countryCodeHolder = new String[]{null}; int[] sheetCount = new int[]{0}; try { @@ -801,7 +802,7 @@ public class PriceTrackTaskService { public void onHeader(String sheetName, Integer sheetNo, Map headerMap) { sheetCount[0]++; countryCodeHolder[0] = resolveCountryCode(sheetName, countryCodes, false); - headerIndexHolder[0] = buildHeaderIndex(headerMap); + headerIndexHolder.set(buildHeaderIndex(headerMap)); } @Override @@ -813,10 +814,11 @@ public class PriceTrackTaskService { if (countryCode == null) { return; } - if (headerIndexHolder[0].isEmpty()) { + Map headerIndex = headerIndexHolder.get(); + if (headerIndex.isEmpty()) { return; } - Map item = extractAsinRow(rowMap, headerIndexHolder[0]); + Map item = extractAsinRow(rowMap, headerIndex); if (!item.isEmpty()) { out.computeIfAbsent(countryCode, key -> new ArrayList<>()).add(item); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java index 23181af..89cd8c6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java @@ -1264,25 +1264,33 @@ public class SimilarAsinCozeClient { private Object cozePrice(Object value) { if (value == null) { - return ""; + return BigDecimal.ZERO; } if (value instanceof BigDecimal decimal) { - return decimal.stripTrailingZeros(); + return normalizeCozeDecimal(decimal); } if (value instanceof Number number) { - return new BigDecimal(number.toString()).stripTrailingZeros(); + return normalizeCozeDecimal(new BigDecimal(number.toString())); } String text = safeText(String.valueOf(value)); if (text.isBlank()) { - return ""; + return BigDecimal.ZERO; } try { - return new BigDecimal(text).stripTrailingZeros(); + return normalizeCozeDecimal(new BigDecimal(text)); } catch (NumberFormatException ex) { return text; } } + private BigDecimal normalizeCozeDecimal(BigDecimal value) { + BigDecimal normalized = value.stripTrailingZeros(); + if (normalized.scale() < 0) { + return normalized.setScale(0); + } + return normalized; + } + private boolean isBlankPrice(Object value) { return value == null || value instanceof String text && text.isBlank(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 2a248cf..a1c5a80 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -606,13 +606,7 @@ public class SimilarAsinTaskService { } public void submitResult(Long taskId, SimilarAsinSubmitResultRequest request) { - TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId, TASK_LOCK_WAIT_MILLIS); - if (lockHandle == null) { - throw new BusinessException(40902, "任务正在处理上一批结果,请稍后重试"); - } - try (lockHandle) { - submitResultLocked(taskId, request); - } + submitResultLocked(taskId, request); } private void submitResultLocked(Long taskId, SimilarAsinSubmitResultRequest request) { @@ -4532,12 +4526,20 @@ public class SimilarAsinTaskService { row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), "")); row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "卖家名称", "卖家名", "卖家", "店铺名称", "店铺名", "seller name", "seller_name", "seller-name", "sellername", "store name", "shop name")); row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "品牌", "brand")); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getIsStock())); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getSimilarity())); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getIsConform())); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getReason())); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getCategory())); - row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getStatus(), "")); + String isStock = resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getIsStock()); + String similarity = resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getSimilarity()); + String isConform = resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getIsConform()); + String reason = resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getReason()); + String category = resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getCategory()); + String status = resultRow == null || isSilentOutputFailure(resultRow) + ? "" + : resolveResultStatus(resultRow, isStock, similarity, isConform, reason, category); + row.createCell(col++).setCellValue(isStock); + row.createCell(col++).setCellValue(similarity); + row.createCell(col++).setCellValue(isConform); + row.createCell(col++).setCellValue(reason); + row.createCell(col++).setCellValue(category); + row.createCell(col).setCellValue(status); if (resultRow != null) { // 行高固定到 IMAGE_ROW_HEIGHT_POINTS(Excel 上限 409pt): row.setHeightInPoints(SimilarAsinImageEmbedder.IMAGE_ROW_HEIGHT_POINTS); @@ -4733,6 +4735,39 @@ public class SimilarAsinTaskService { || hasUsableCozeField(row.getStatus()); } + static String resolveResultStatus(SimilarAsinResultRowDto row, String... visibleResultValues) { + if (row == null) { + return "\u5931\u8d25"; + } + if (hasText(row.getMainUrl()) || hasText(row.getPuzzleImg1()) || hasText(row.getPuzzleImg2())) { + return "\u6210\u529f"; + } + String rawStatus = row.getStatus() == null ? "" : row.getStatus().trim(); + if (!rawStatus.isBlank()) { + return isFailedStatus(rawStatus) ? "\u5931\u8d25" : "\u6210\u529f"; + } + if (visibleResultValues != null) { + for (String value : visibleResultValues) { + if (hasText(value)) { + return "\u6210\u529f"; + } + } + } + return hasText(row.getConclusion()) ? "\u6210\u529f" : "\u5931\u8d25"; + } + + private static boolean isFailedStatus(String status) { + String normalized = status == null ? "" : status.trim().toLowerCase(Locale.ROOT); + return normalized.contains("fail") + || normalized.contains("error") + || normalized.contains("\u5931\u8d25") + || normalized.contains("\u9519\u8bef"); + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isBlank(); + } + private boolean isExportableResultRow(SimilarAsinResultRowDto row) { if (row == null) { return false; @@ -4902,7 +4937,7 @@ public class SimilarAsinTaskService { .eq(FileTaskEntity::getUserId, userId) .eq(FileTaskEntity::getStatus, STATUS_RUNNING) .inSql(FileTaskEntity::getId, - "select distinct task_id from file_result " + + "select distinct task_id from biz_file_result " + "where module_type = '" + MODULE_TYPE + "' " + "and user_id = " + userId + " " + "and result_file_url is not null and result_file_url <> ''")); @@ -5665,6 +5700,9 @@ public class SimilarAsinTaskService { private String userFacingCozeCellValue(SimilarAsinResultRowDto row, String value) { String normalizedValue = normalize(value); + if (isSilentOutputFailure(row) || isSilentOutputFailure(normalizedValue)) { + return ""; + } if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) { return value; } @@ -5690,6 +5728,19 @@ public class SimilarAsinTaskService { return firstNonBlank(row.getConclusion(), ""); } + private boolean isSilentOutputFailure(SimilarAsinResultRowDto row) { + return row != null + && (isSilentOutputFailure(row.getError()) + || isSilentOutputFailure(row.getReason()) + || isSilentOutputFailure(row.getConclusion())); + } + + private boolean isSilentOutputFailure(String value) { + String normalized = normalize(value).toLowerCase(Locale.ROOT); + return normalized.contains("required field missing") + && normalized.contains("skip coze"); + } + private boolean isTechnicalCozeFailure(String value) { String normalized = normalize(value).toLowerCase(Locale.ROOT); if (normalized.isBlank()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java index aa1cde9..443119c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -221,7 +222,7 @@ public class SplitRunService { results.add(new SplitChunkResult(outputFile, chunkSizes.get(i))); } - Map[] headerMapHolder = new Map[]{null}; + AtomicReference> headerMapHolder = new AtomicReference<>(); int[] chunkIndex = new int[]{0}; ExcelStreamReader.readFirstSheet(inputFile, new ExcelStreamReader.SheetRowHandler() { @Override @@ -233,7 +234,7 @@ public class SplitRunService { if (!missing.isEmpty()) { throw new BusinessException("Missing required columns: " + String.join(", ", missing)); } - headerMapHolder[0] = resolvedHeaderMap; + headerMapHolder.set(resolvedHeaderMap); } @Override @@ -241,7 +242,7 @@ public class SplitRunService { if (chunkIndex[0] >= writers.size()) { return; } - Map resolvedHeaderMap = headerMapHolder[0]; + Map resolvedHeaderMap = headerMapHolder.get(); if (resolvedHeaderMap == null) { throw new BusinessException("Excel header row is empty."); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java index ede6019..8f0f25a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java @@ -26,7 +26,7 @@ public class TaskHeartbeatController { @PostMapping("/{taskId}/heartbeat") @Operation( summary = "Task heartbeat", - description = "Python calls this during execution. The backend resolves biz_file_task or brand task by taskId. user_id is optional.") + description = "Python calls this during execution. The backend resolves biz_file_task or brand task by taskId only.") public ApiResponse heartbeat( @Parameter(description = "Task ID", required = true, example = "200") @PathVariable Long taskId, diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskHeartbeatRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskHeartbeatRequest.java index 2adc8cb..b7c0a90 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskHeartbeatRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskHeartbeatRequest.java @@ -1,17 +1,14 @@ package com.nanri.aiimage.modules.task.model.dto; -import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data +@JsonIgnoreProperties(ignoreUnknown = true) @Schema(description = "Task heartbeat request") public class TaskHeartbeatRequest { - @JsonProperty("user_id") - @Schema(description = "Optional task owner user ID. Python workers can omit it.", example = "453") - private Long userId; - @Schema(description = "Optional phase label", example = "crawling") private String phase; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java index b722ad3..ebe815f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskHeartbeatService.java @@ -18,6 +18,7 @@ import com.nanri.aiimage.modules.task.model.dto.TaskHeartbeatRequest; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.model.vo.TaskHeartbeatVo; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -26,6 +27,7 @@ import java.util.Map; @Service @RequiredArgsConstructor +@Slf4j public class TaskHeartbeatService { private static final String STATUS_RUNNING = "RUNNING"; @@ -55,24 +57,18 @@ public class TaskHeartbeatService { public TaskHeartbeatVo heartbeat(Long taskId, TaskHeartbeatRequest request) { if (taskId == null || taskId <= 0) { + log.warn("[task-heartbeat] ignored invalid heartbeat taskId={}", taskId); return TaskHeartbeatVo.notAlive(null, null, "invalid taskId"); } - Long userId = request == null ? null : request.getUserId(); LambdaQueryWrapper fileQuery = new LambdaQueryWrapper() .eq(FileTaskEntity::getId, taskId) .last("limit 1"); - if (userId != null && userId > 0) { - fileQuery.eq(FileTaskEntity::getUserId, userId); - } FileTaskEntity fileTask = fileTaskMapper.selectOne(fileQuery); LambdaQueryWrapper brandQuery = new LambdaQueryWrapper() .eq(BrandCrawlTaskEntity::getId, taskId) .last("limit 1"); - if (userId != null && userId > 0) { - brandQuery.eq(BrandCrawlTaskEntity::getUserId, userId); - } BrandCrawlTaskEntity brandTask = brandCrawlTaskMapper.selectOne(brandQuery); TaskHeartbeatVo fileResult = touchFileTaskIfRunning(fileTask, request); @@ -93,6 +89,7 @@ public class TaskHeartbeatService { if (brandResult != null) { return brandResult; } + log.warn("[task-heartbeat] task not found taskId={} fileFound=false brandFound=false", taskId); return TaskHeartbeatVo.notAlive(null, null, "task not found"); } @@ -103,16 +100,20 @@ public class TaskHeartbeatService { String moduleType = task.getModuleType() == null ? "" : task.getModuleType(); String status = task.getStatus(); if (!STATUS_RUNNING.equals(status)) { + log.warn("[task-heartbeat] file task is not running taskId={} actualUserId={} moduleType={} status={}", + task.getId(), task.getUserId(), moduleType, status); return TaskHeartbeatVo.notAlive(moduleType, status, "task is not running"); } LocalDateTime now = LocalDateTime.now(); int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper() .eq(FileTaskEntity::getId, task.getId()) - .eq(FileTaskEntity::getUserId, task.getUserId()) .eq(FileTaskEntity::getStatus, STATUS_RUNNING) .set(FileTaskEntity::getUpdatedAt, now)); if (updated <= 0) { FileTaskEntity latest = fileTaskMapper.selectById(task.getId()); + log.warn("[task-heartbeat] file task heartbeat update missed taskId={} actualUserId={} moduleType={} status={} latestStatus={}", + task.getId(), task.getUserId(), moduleType, status, + latest == null ? null : latest.getStatus()); return TaskHeartbeatVo.notAlive(moduleType, latest == null ? status : latest.getStatus(), "task is not running"); } touchModuleHeartbeat(moduleType, task.getId(), request); @@ -127,15 +128,19 @@ public class TaskHeartbeatService { } String status = task.getStatus(); if (!BRAND_STATUS_RUNNING.equals(status)) { + log.warn("[task-heartbeat] brand task is not running taskId={} actualUserId={} status={}", + task.getId(), task.getUserId(), status); return TaskHeartbeatVo.notAlive(MODULE_BRAND, status, "task is not running"); } int updated = brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper() .eq(BrandCrawlTaskEntity::getId, task.getId()) - .eq(BrandCrawlTaskEntity::getUserId, task.getUserId()) .eq(BrandCrawlTaskEntity::getStatus, BRAND_STATUS_RUNNING) .set(BrandCrawlTaskEntity::getUpdatedAt, LocalDateTime.now())); if (updated <= 0) { BrandCrawlTaskEntity latest = brandCrawlTaskMapper.selectById(task.getId()); + log.warn("[task-heartbeat] brand task heartbeat update missed taskId={} actualUserId={} status={} latestStatus={}", + task.getId(), task.getUserId(), status, + latest == null ? null : latest.getStatus()); return TaskHeartbeatVo.notAlive(MODULE_BRAND, latest == null ? status : latest.getStatus(), "task is not running"); } brandTaskProgressCacheService.touchHeartbeat( @@ -208,4 +213,5 @@ public class TaskHeartbeatService { values.put(key, String.valueOf(value)); } } + } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java index 40fa053..9905d40 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java @@ -4,6 +4,7 @@ import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; import com.nanri.aiimage.config.InstanceMetadata; import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; @@ -45,6 +46,7 @@ public class TaskResultFileJobWorker { private final SimilarAsinTaskService similarAsinTaskService; private final DeleteBrandRunService deleteBrandRunService; private final BrandTaskService brandTaskService; + private final CollectDataService collectDataService; @Autowired @Qualifier("cozeTaskExecutor") private TaskExecutor cozeTaskExecutor; @@ -274,6 +276,10 @@ public class TaskResultFileJobWorker { brandTaskService.processResultFileJob(job); return true; } + if ("COLLECT_DATA".equals(moduleType)) { + collectDataService.processResultFileJob(job); + return true; + } throw new IllegalArgumentException("unsupported result file job module: " + moduleType); } @@ -297,6 +303,7 @@ public class TaskResultFileJobWorker { } if ("DELETE_BRAND".equals(moduleType)) { deleteBrandRunService.cleanupResultFileJob(job); + return; } } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java index 2ec59aa..775b96c 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java @@ -14,6 +14,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class AppearancePatentCozeClientTest { @@ -40,7 +41,7 @@ class AppearancePatentCozeClientTest { } @Test - void skipBrandCheckWhenCozeTitleAndReasonAreNone() throws Exception { + void skipBrandCheckWhenCozeTitleIsNone() throws Exception { BrandCheckClient brandCheckClient = mock(BrandCheckClient.class); AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient( new AppearancePatentProperties(), @@ -61,4 +62,73 @@ class AppearancePatentCozeClientTest { assertThat(merged.get(0).getConclusion()).isEqualTo("无侵权"); verify(brandCheckClient, never()).checkTitleText(anyString(), anyString()); } + + @Test + void callBrandCheckOnlyWhenTitleAndAppearanceArePresent() throws Exception { + BrandCheckClient brandCheckClient = mock(BrandCheckClient.class); + when(brandCheckClient.checkTitleText("阿凡达", "Terms")) + .thenReturn(new BrandCheckClient.BrandCheckBatchResult(List.of("阿凡达"), List.of(), List.of())); + AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient( + new AppearancePatentProperties(), + new ObjectMapper(), + null, + brandCheckClient + ); + AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); + row.setId("1"); + String dataText = """ + {"data":[{"row_id":"1","title":"阿凡达","appearance":"无侵权","result":"无侵权"}]} + """; + + List merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).getTitleRisk()).isEqualTo("无侵权"); + verify(brandCheckClient).checkTitleText("阿凡达", "Terms"); + } + + @Test + void leaveTitleRiskBlankWhenAppearanceIsMissing() throws Exception { + BrandCheckClient brandCheckClient = mock(BrandCheckClient.class); + AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient( + new AppearancePatentProperties(), + new ObjectMapper(), + null, + brandCheckClient + ); + AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); + row.setId("1"); + String dataText = """ + {"data":[{"row_id":"1","title":"阿凡达","result":""}]} + """; + + List merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).getTitleRisk()).isEmpty(); + verify(brandCheckClient, never()).checkTitleText(anyString(), anyString()); + } + + @Test + void leaveTitleRiskBlankWhenTitleAndAppearanceAreMissing() throws Exception { + BrandCheckClient brandCheckClient = mock(BrandCheckClient.class); + AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient( + new AppearancePatentProperties(), + new ObjectMapper(), + null, + brandCheckClient + ); + AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); + row.setId("1"); + String dataText = """ + {"data":[{"row_id":"1","result":""}]} + """; + + List merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).getTitleRisk()).isEmpty(); + assertThat(merged.get(0).getAppearanceRisk()).isNullOrEmpty(); + verify(brandCheckClient, never()).checkTitleText(anyString(), anyString()); + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java new file mode 100644 index 0000000..4ca45a9 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java @@ -0,0 +1,16 @@ +package com.nanri.aiimage.modules.appearancepatent.service; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class AppearancePatentTaskServiceTest { + + @Test + void resultStatusFailsOnlyWhenConclusionIsEmpty() { + assertEquals("\u5931\u8d25", AppearancePatentTaskService.resolveResultStatus(null)); + assertEquals("\u5931\u8d25", AppearancePatentTaskService.resolveResultStatus(" ")); + assertEquals("\u6210\u529f", AppearancePatentTaskService.resolveResultStatus("\u4fb5\u6743")); + assertEquals("\u6210\u529f", AppearancePatentTaskService.resolveResultStatus("\u65e0\u4fb5\u6743")); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java index 0e0a80a..0464bf7 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class SimilarAsinCozeClientTest { @@ -83,4 +84,100 @@ class SimilarAsinCozeClientTest { assertEquals("https://cbu01.alicdn.com/img/ibank/legacy-a.jpg", alibaba.getFirst().get("url")); assertEquals(0, new BigDecimal("8.5").compareTo((BigDecimal) alibaba.getFirst().get("price"))); } + + @Test + @SuppressWarnings("unchecked") + void buildParametersConvertsBlankAlibabaPricesToZero() throws Exception { + List rows = objectMapper.readValue(""" + [ + { + "asin": "B0BLANK123", + "url": "https://m.media-amazon.com/images/I/main.jpg", + "alibaba": [ + {"url": "https://cbu01.alicdn.com/img/ibank/blank.jpg", "price": ""}, + {"url": "https://cbu01.alicdn.com/img/ibank/null.jpg", "price": null} + ] + } + ] + """, new TypeReference<>() { + }); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + + Method method = SimilarAsinCozeClient.class.getDeclaredMethod( + "buildParameters", List.class, String.class, String.class, boolean.class); + method.setAccessible(true); + Map parameters = (Map) method.invoke(client, rows, "", "", true); + + List> items = (List>) parameters.get("items"); + List> alibaba = (List>) items.getFirst().get("alibaba"); + String json = objectMapper.writeValueAsString(parameters); + + assertEquals(2, alibaba.size()); + assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.get(0).get("price"))); + assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.get(1).get("price"))); + assertTrue(json.contains("\"price\":0")); + assertFalse(json.contains("\"price\":\"\"")); + } + + @Test + @SuppressWarnings("unchecked") + void buildParametersConvertsBlankFallbackRowPriceToZero() throws Exception { + SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); + row.setAsin("B0BLANKROW"); + row.setUrl("https://m.media-amazon.com/images/I/main.jpg"); + row.setUrls(List.of("https://cbu01.alicdn.com/img/ibank/fallback.jpg")); + row.setPrice(""); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + + Method method = SimilarAsinCozeClient.class.getDeclaredMethod( + "buildParameters", List.class, String.class, String.class, boolean.class); + method.setAccessible(true); + Map parameters = (Map) method.invoke(client, List.of(row), "", "", true); + + List> items = (List>) parameters.get("items"); + List> alibaba = (List>) items.getFirst().get("alibaba"); + String json = objectMapper.writeValueAsString(parameters); + + assertEquals(1, alibaba.size()); + assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.getFirst().get("price"))); + assertTrue(json.contains("\"price\":0")); + assertFalse(json.contains("\"price\":\"\"")); + } + + @Test + @SuppressWarnings("unchecked") + void buildParametersSerializesWholeNumberPricesWithoutScientificNotation() throws Exception { + List rows = objectMapper.readValue(""" + [ + { + "asin": "B0SCI123", + "url": "https://m.media-amazon.com/images/I/main.jpg", + "alibaba": [ + {"url": "https://cbu01.alicdn.com/img/ibank/ten.jpg", "price": 10.0}, + {"url": "https://cbu01.alicdn.com/img/ibank/eighty.jpg", "price": 80.0}, + {"url": "https://cbu01.alicdn.com/img/ibank/fraction.jpg", "price": 7.5800} + ] + } + ] + """, new TypeReference<>() { + }); + SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null); + + Method method = SimilarAsinCozeClient.class.getDeclaredMethod( + "buildParameters", List.class, String.class, String.class, boolean.class); + method.setAccessible(true); + Map parameters = (Map) method.invoke(client, rows, "", "", true); + + List> items = (List>) parameters.get("items"); + List> alibaba = (List>) items.getFirst().get("alibaba"); + String json = objectMapper.writeValueAsString(parameters); + + assertEquals("10", ((BigDecimal) alibaba.get(0).get("price")).toPlainString()); + assertEquals("80", ((BigDecimal) alibaba.get(1).get("price")).toPlainString()); + assertEquals("7.58", ((BigDecimal) alibaba.get(2).get("price")).toPlainString()); + assertFalse(json.contains("1E+1")); + assertFalse(json.contains("8E+1")); + assertTrue(json.contains("\"price\":10")); + assertTrue(json.contains("\"price\":80")); + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceTest.java new file mode 100644 index 0000000..3edd55e --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceTest.java @@ -0,0 +1,33 @@ +package com.nanri.aiimage.modules.similarasin.service; + +import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SimilarAsinTaskServiceTest { + + @Test + void resultStatusUsesReturnedCozeDataAndImages() { + assertEquals("\u5931\u8d25", SimilarAsinTaskService.resolveResultStatus(null)); + + SimilarAsinResultRowDto empty = new SimilarAsinResultRowDto(); + assertEquals("\u5931\u8d25", SimilarAsinTaskService.resolveResultStatus(empty)); + + SimilarAsinResultRowDto withImage = new SimilarAsinResultRowDto(); + withImage.setStatus("FAILED"); + withImage.setMainUrl("https://example.com/main.jpg"); + assertEquals("\u6210\u529f", SimilarAsinTaskService.resolveResultStatus(withImage)); + + SimilarAsinResultRowDto withCozeStatus = new SimilarAsinResultRowDto(); + withCozeStatus.setStatus("success"); + assertEquals("\u6210\u529f", SimilarAsinTaskService.resolveResultStatus(withCozeStatus)); + + SimilarAsinResultRowDto failedStatus = new SimilarAsinResultRowDto(); + failedStatus.setStatus("FAILED"); + assertEquals("\u5931\u8d25", SimilarAsinTaskService.resolveResultStatus(failedStatus)); + + SimilarAsinResultRowDto withVisibleResultData = new SimilarAsinResultRowDto(); + assertEquals("\u6210\u529f", SimilarAsinTaskService.resolveResultStatus(withVisibleResultData, "", "80%", "", "", "")); + } +} diff --git a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue index 496ee8a..71677b0 100644 --- a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue @@ -594,10 +594,10 @@ async function refreshTaskProgress() { if (task.status === 'SUCCESS' || task.status === 'FAILED') { removePollingTask(task.id) shouldRefreshDashboard = true - if (task.status === 'SUCCESS') addPendingFileTask(task.id) + if (item && isResultPreparing(item)) addPendingFileTask(task.id) else removePendingFileTask(task.id) } - if (item && task.status === 'SUCCESS' && pendingFileTaskIds.value.includes(task.id)) { + if (item && (task.status === 'SUCCESS' || task.status === 'FAILED') && pendingFileTaskIds.value.includes(task.id)) { const fileStatus = (item.fileStatus || '').toUpperCase() if (item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') { removePendingFileTask(task.id) @@ -651,10 +651,10 @@ function settlePendingFileTasks() { } const taskStatus = (item.taskStatus || '').toUpperCase() const fileStatus = (item.fileStatus || '').toUpperCase() - if (taskStatus === 'FAILED' || item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') { + if (item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') { continue } - if (taskStatus === 'SUCCESS') { + if (taskStatus === 'SUCCESS' || taskStatus === 'FAILED') { remaining.push(taskId) } } @@ -767,6 +767,7 @@ function statusText(item: AppearancePatentHistoryItem) { if (status === 'PENDING') return '已解析待推送' if (isResultPreparing(item)) return '结果生成中' if (isResultBuildFailed(item)) return '结果生成失败' + if (hasDownloadableResult(item)) return '已完成' if (status === 'SUCCESS' || item.success) return '已完成' if (status === 'FAILED') return '失败' return '等待中' @@ -775,6 +776,7 @@ function statusText(item: AppearancePatentHistoryItem) { function statusClass(item: AppearancePatentHistoryItem) { const status = normalizeTaskStatus(item) if (status === 'RUNNING' || isResultPreparing(item)) return 'running' + if (hasDownloadableResult(item)) return 'success' if (isResultBuildFailed(item) || status === 'FAILED') return 'failed' if (status === 'SUCCESS' || item.success) return 'success' return 'pending' @@ -783,10 +785,10 @@ function statusClass(item: AppearancePatentHistoryItem) { function isResultPreparing(item: AppearancePatentHistoryItem) { const status = normalizeTaskStatus(item) const fileStatus = (item.fileStatus || '').toUpperCase() - if (status !== 'SUCCESS') return false + if (status !== 'SUCCESS' && status !== 'FAILED') return false if (canDownload(item)) return false if (fileStatus === 'FAILED' || fileStatus === 'SUCCESS') return false - return true + return status === 'SUCCESS' || hasActiveFileJob(item) } function normalizeTaskStatus(item: AppearancePatentHistoryItem) { @@ -819,6 +821,10 @@ function canDownload(item: AppearancePatentHistoryItem) { return Boolean(item.resultId && (item.fileReady || item.downloadUrl)) } +function hasDownloadableResult(item: AppearancePatentHistoryItem) { + return canDownload(item) +} + function fileProgressPercent(item: AppearancePatentHistoryItem) { const percent = Number(item.fileProgressPercent || 0) if (Number.isFinite(percent) && percent > 0) { diff --git a/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue b/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue index 3d3ec6b..49d3386 100644 --- a/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandCollectDataTab.vue @@ -149,6 +149,8 @@
任务 ID:{{ item.taskId }}
开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}
行数:{{ item.rowCount ?? '-' }}
+
总数据去重过滤:{{ item.dedupeFilteredCount ?? 0 }}
+
不符合 ASIN 过滤:{{ item.invalidFilteredCount ?? 0 }}
{{ statusText(item) }} @@ -170,6 +172,8 @@
任务 ID:{{ item.taskId ?? '-' }}
开始时间:{{ formatDateTime(item.startedAt || item.createdAt) }}
结束时间:{{ formatDateTime(item.finishedAt) }}
+
总数据去重过滤:{{ item.dedupeFilteredCount ?? 0 }}
+
不符合 ASIN 过滤:{{ item.invalidFilteredCount ?? 0 }}
{{ item.resultFilename }}
错误:{{ item.error }}
diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index 1eaf901..ff120b9 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -2283,6 +2283,10 @@ export interface CollectDataHistoryItem { success?: boolean; error?: string; rowCount?: number; + dedupeFilteredCount?: number; + invalidFilteredCount?: number; + brandRejectedCount?: number; + finalRowCount?: number; createdAt?: string; startedAt?: string; finishedAt?: string; diff --git a/user_data/api_logs/api_2026_05_07.log b/user_data/api_logs/api_2026_05_07.log deleted file mode 100644 index e69de29..0000000 diff --git a/xlsx/1-1.xlsx b/xlsx/1-1.xlsx deleted file mode 100644 index 18bff1c..0000000 Binary files a/xlsx/1-1.xlsx and /dev/null differ diff --git a/xlsx/1.xlsx b/xlsx/1.xlsx deleted file mode 100644 index a6e124d..0000000 Binary files a/xlsx/1.xlsx and /dev/null differ