新增内容,新增拉起软件层
This commit is contained in:
2
app/.env
2
app/.env
@@ -11,7 +11,7 @@ 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://47.111.163.154:18080/
|
||||
java_api_base=http://api.aishufu.top:18080/
|
||||
# java_api_base=http://127.0.0.1:18080/
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{bN as r}from"./java-modules-WSWRTEaR.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};
|
||||
import{b6 as r}from"./java-modules-B9jblDzU.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};
|
||||
1
app/assets/collect-data-Cb4Clz6L.css
Normal file
1
app/assets/collect-data-Cb4Clz6L.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
app/assets/image-video-uN8PfarH.css
Normal file
1
app/assets/image-video-uN8PfarH.css
Normal file
@@ -0,0 +1 @@
|
||||
.image-video-page[data-v-e41486d3]{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:#333;font-family:Microsoft YaHei,PingFang SC,Hiragino Sans GB,sans-serif;background:linear-gradient(#ffffff80,#ffffff80),url(/static/bg.jpg) center/cover no-repeat,#eef3f7}.page-header[data-v-e41486d3]{position:absolute;top:0;left:0;right:0;height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:#ffffff80}.header-title[data-v-e41486d3]{color:#333;font-size:18px;font-weight:600}.back-link[data-v-e41486d3]{color:#555;font-size:14px;text-decoration:none}.back-link[data-v-e41486d3]:hover{color:#667eea}.entrances[data-v-e41486d3]{display:flex;gap:32px;flex-wrap:wrap;justify-content:center}.entrance-btn[data-v-e41486d3]{width:160px;height:100px;display:flex;align-items:center;justify-content:center;color:#333;background:#c5c1c1;border:4px solid #b8d4e3;border-radius:12px;cursor:pointer;font:inherit;font-size:18px;font-weight:600;text-decoration:none;transition:all .2s}.entrance-btn[data-v-e41486d3]:hover:not(:disabled){background:#f8fbfd;box-shadow:0 4px 12px #00000014}.entrance-btn[data-v-e41486d3]:disabled{cursor:wait;opacity:.72}.toast[data-v-e41486d3]{position:fixed;bottom:40px;left:50%;padding:12px 24px;max-width:min(420px,calc(100vw - 48px));color:#fff;background:#000000bf;border-radius:8px;font-size:14px;text-align:center;opacity:0;pointer-events:none;transform:translate(-50%);transition:opacity .3s}.toast.show[data-v-e41486d3]{opacity:1}.toast.error[data-v-e41486d3]{background:#b02a37eb}@media(max-width:640px){.image-video-page[data-v-e41486d3]{padding:88px 24px 40px}.entrances[data-v-e41486d3]{width:100%;gap:18px}.entrance-btn[data-v-e41486d3]{width:100%;max-width:220px}}
|
||||
1
app/assets/image-video.js
Normal file
1
app/assets/image-video.js
Normal file
@@ -0,0 +1 @@
|
||||
import{d as p,a as m,c as f,e as a,t as _,n as v,g as l,i as b,_ as g,j as y,k as h}from"./pywebview-B8lwamDf.js";const w={class:"image-video-page"},k={class:"entrances","aria-label":"图生视频入口"},C=["disabled"],T=p({__name:"ImageVideoPage",setup(I){const o=l(!1),n=l(""),i=l("normal");let r;function s(t,e="normal"){n.value=t,i.value=e,r&&window.clearTimeout(r),r=window.setTimeout(()=>{n.value=""},2200)}function u(t){s(`${t}暂未开通,敬请期待`)}async function c(){const t=b();if(!t?.launch_yaoayanui){s("当前环境不支持拉起桌面端","error");return}o.value=!0;try{const e=await t.launch_yaoayanui();if(e?.success){s("已发送启动请求");return}s(e?.error||"启动失败","error")}catch(e){s(e instanceof Error?e.message:String(e),"error")}finally{o.value=!1}}return(t,e)=>(m(),f("div",w,[e[2]||(e[2]=a("header",{class:"page-header"},[a("span",{class:"header-title"},"数富AI"),a("a",{class:"back-link",href:"/home"},"返回首页")],-1)),a("main",k,[a("button",{type:"button",class:"entrance-btn",disabled:o.value,onClick:c}," 数字人 ",8,C),a("button",{type:"button",class:"entrance-btn",onClick:e[0]||(e[0]=d=>u("带货视频"))}," 带货视频 "),a("button",{type:"button",class:"entrance-btn",onClick:e[1]||(e[1]=d=>u("混剪"))}," 混剪 ")]),a("div",{class:v(["toast",{show:!!n.value,error:i.value==="error"}])},_(n.value),3)]))}}),B=g(T,[["__scopeId","data-v-e41486d3"]]);y(B).use(h).mount("#app");
|
||||
File diff suppressed because one or more lines are too long
6
app/assets/java-modules-B9jblDzU.js
Normal file
6
app/assets/java-modules-B9jblDzU.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/java-modules-CXcfGmAJ.css
Normal file
1
app/assets/java-modules-CXcfGmAJ.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
50
app/assets/pywebview-B8lwamDf.js
Normal file
50
app/assets/pywebview-B8lwamDf.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/pywebview-xvHHTGU_.css
Normal file
1
app/assets/pywebview-xvHHTGU_.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
app/assets/similar-asin-CJxBjrIf.css
Normal file
1
app/assets/similar-asin-CJxBjrIf.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
app/assets/zh-cn-CqyuNHRW.js
Normal file
1
app/assets/zh-cn-CqyuNHRW.js
Normal file
File diff suppressed because one or more lines are too long
70
app/main.py
70
app/main.py
@@ -419,6 +419,74 @@ class WindowAPI:
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def launch_yaoayanui(self):
|
||||
candidates = []
|
||||
env_path = os.getenv('YAOAYANUI_EXE', '').strip()
|
||||
if env_path:
|
||||
candidates.append(env_path)
|
||||
|
||||
candidates.extend([
|
||||
os.path.join(BASE_DIR, 'resources', 'yaoayanui', 'tauri-app.exe'),
|
||||
os.path.abspath(os.path.join(
|
||||
BASE_DIR,
|
||||
'..',
|
||||
'..',
|
||||
'yaoyaoAI',
|
||||
'yaoayanui',
|
||||
'src-tauri',
|
||||
'target',
|
||||
'release',
|
||||
'tauri-app.exe',
|
||||
)),
|
||||
os.path.abspath(os.path.join(
|
||||
BASE_DIR,
|
||||
'..',
|
||||
'..',
|
||||
'yaoyaoAI',
|
||||
'yaoayanui',
|
||||
'src-tauri',
|
||||
'target',
|
||||
'release',
|
||||
'bundle',
|
||||
'nsis',
|
||||
'aiclient_0.1.15_x64-setup.exe',
|
||||
)),
|
||||
])
|
||||
|
||||
exe_path = ''
|
||||
for candidate in candidates:
|
||||
if candidate and os.path.isfile(candidate):
|
||||
exe_path = os.path.abspath(candidate)
|
||||
break
|
||||
|
||||
if not exe_path:
|
||||
return {
|
||||
'success': False,
|
||||
'error': '未找到 yaoayanui 可执行文件,请设置 YAOAYANUI_EXE',
|
||||
}
|
||||
|
||||
try:
|
||||
if sys.platform.startswith('win'):
|
||||
subprocess.Popen(
|
||||
[exe_path],
|
||||
cwd=os.path.dirname(exe_path),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
else:
|
||||
subprocess.Popen(
|
||||
[exe_path],
|
||||
cwd=os.path.dirname(exe_path),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return {'success': True, 'path': exe_path}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def enqueue_json(self, data):
|
||||
"""保存任务到队列"""
|
||||
try:
|
||||
@@ -538,7 +606,7 @@ def main():
|
||||
|
||||
api = WindowAPI(window)
|
||||
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder,
|
||||
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new,api.save_file_from_url_with_progress,api.get_device_id)
|
||||
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.launch_yaoayanui,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new,api.save_file_from_url_with_progress,api.get_device_id)
|
||||
webview.start(
|
||||
debug=True,
|
||||
storage_path=cache_path,
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>格式转换 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-WSWRTEaR.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-B8lwamDf.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DE-sObfq.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-xvHHTGU_.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-CXcfGmAJ.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/convert-6oxZNMye.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据去重 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-WSWRTEaR.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-B8lwamDf.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DE-sObfq.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-xvHHTGU_.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-CXcfGmAJ.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/dedupe-DpfPYQDv.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>删除品牌 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-WSWRTEaR.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-B8lwamDf.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DE-sObfq.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/categorized-timers-JPA-olTr.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-xvHHTGU_.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-CXcfGmAJ.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DZ3x5hOX.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据拆分 - 数富AI</title>
|
||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-WSWRTEaR.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-B8lwamDf.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/brand-DE-sObfq.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-xvHHTGU_.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-CXcfGmAJ.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/split-CR1PtDQE.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>首页 - 数富AI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||
background: linear-gradient(rgba(255,255,255,0.5), rgba(255,255,255,0.5)),
|
||||
url("/static/bg.jpg") center/cover no-repeat;;
|
||||
background: linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)),
|
||||
url("/static/bg.jpg") center/cover no-repeat;
|
||||
;
|
||||
/*background-image: url("/static/bg.jpg");*/
|
||||
|
||||
min-height: 100vh;
|
||||
@@ -19,6 +26,7 @@
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -29,20 +37,40 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: rgba(255,255,255,0.5);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.header-title { font-size: 18px; font-weight: 600; color: #333; }
|
||||
.header-right { display: flex; align-items: center; gap: 16px; position: relative; }
|
||||
.header-right a, .header-right span {
|
||||
font-size: 14px; color: #555; text-decoration: none;
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.header-right a:hover { color: #667eea; }
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-right a,
|
||||
.header-right span {
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header-right a:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.entrances {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.entrance-btn {
|
||||
width: 160px;
|
||||
height: 100px;
|
||||
@@ -59,21 +87,27 @@
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.entrance-btn:hover {
|
||||
background: #f8fbfd;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.entrance-btn.wb { cursor: pointer; }
|
||||
|
||||
.entrance-btn.wb {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.entrance-btn.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.75);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
@@ -82,19 +116,25 @@
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.update-section {
|
||||
margin-top: 48px;
|
||||
padding: 20px 24px;
|
||||
background: rgba(255,255,255,0.85);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.update-section-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
@@ -103,16 +143,19 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.update-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.update-version-text {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.btn-check-update {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
@@ -122,15 +165,18 @@
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-check-update:hover {
|
||||
background: #5a6fd6;
|
||||
}
|
||||
|
||||
.update-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.btn-download-update {
|
||||
margin-top: 8px;
|
||||
padding: 8px 14px;
|
||||
@@ -141,15 +187,17 @@
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-download-update:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.header-update-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(102,126,234,0.12);
|
||||
background: rgba(102, 126, 234, 0.12);
|
||||
color: #4c5bd4;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -158,22 +206,25 @@
|
||||
font-size: 15px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-update-btn:hover {
|
||||
background: rgba(102,126,234,0.2);
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.header-update-panel {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
right: 0;
|
||||
width: 280px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255,255,255,0.98);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-update-title {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
@@ -184,12 +235,13 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body data-user-id="{{ user_id or '' }}">
|
||||
<header class="header">
|
||||
<span class="header-title">数富AI</span>
|
||||
<div class="header-right">
|
||||
{% if is_admin %}
|
||||
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
|
||||
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
|
||||
{% endif %}
|
||||
<span>{{ username }}</span>
|
||||
<button type="button" class="header-update-btn" id="homeUpdateToggle" title="检测更新">↻</button>
|
||||
@@ -202,7 +254,8 @@
|
||||
</div>
|
||||
<div class="update-hint" id="homeUpdateHint"></div>
|
||||
<div style="margin-top: 4px;">
|
||||
<button type="button" class="btn-download-update" id="homeBtnDownloadUpdate" style="display: none;">立即更新</button>
|
||||
<button type="button" class="btn-download-update" id="homeBtnDownloadUpdate"
|
||||
style="display: none;">立即更新</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,7 +263,7 @@
|
||||
|
||||
<div class="entrances" id="entrances">
|
||||
<a class="entrance-btn" href="/brand" data-column-key="brand">亚马逊</a>
|
||||
<a class="entrance-btn disabled" href="javascript:;" data-msg="暂未开通,敬请期待" data-column-key="wb">wildberries</a>
|
||||
<a class="entrance-btn" href="/new_web_source/image-video.html" data-column-key="wb">图生视频</a>
|
||||
<a class="entrance-btn image" href="/image" data-column-key="image">图片</a>
|
||||
</div>
|
||||
|
||||
@@ -221,44 +274,44 @@
|
||||
return 'app_column_permissions:' + String(uid || '');
|
||||
}
|
||||
// 权限校验:根据 Java column-permissions 接口按 column_key 显示入口
|
||||
(function() {
|
||||
localStorage.setItem("uid",{{ user_id }})
|
||||
(function () {
|
||||
localStorage.setItem("uid", {{ user_id }});
|
||||
var uid = document.body.getAttribute('data-user-id');
|
||||
if (!uid) return;
|
||||
var cacheKey = getAppPermissionCacheKey(uid);
|
||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
||||
var token = '';
|
||||
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
|
||||
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) { }
|
||||
if (!JAVA_API_BASE || !token) return;
|
||||
var apiUrl = JAVA_API_BASE + '/api/admin/permission-users/' + uid + '/column-permissions?menuType=app';
|
||||
fetch(apiUrl, {
|
||||
credentials: 'include',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res || !res.success || !Array.isArray(res.data)) return;
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(res.data));
|
||||
} catch (e) {}
|
||||
var allowedKeys = res.data.map(function(item) { return (item.columnKey || item.column_key || '').toLowerCase(); });
|
||||
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function(btn) {
|
||||
} catch (e) { }
|
||||
var allowedKeys = res.data.map(function (item) { return (item.columnKey || item.column_key || '').toLowerCase(); });
|
||||
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function (btn) {
|
||||
var key = (btn.getAttribute('data-column-key') || '').toLowerCase();
|
||||
btn.style.display = allowedKeys.indexOf(key) >= 0 ? '' : 'none';
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
.catch(function () { });
|
||||
})();
|
||||
|
||||
document.querySelectorAll('.entrance-btn.disabled').forEach(function(btn) {
|
||||
btn.onclick = function(e) {
|
||||
document.querySelectorAll('.entrance-btn.disabled').forEach(function (btn) {
|
||||
btn.onclick = function (e) {
|
||||
e.preventDefault();
|
||||
var msg = btn.getAttribute('data-msg') || '暂未开通,敬请期待';
|
||||
var t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(function() { t.classList.remove('show'); }, 2000);
|
||||
setTimeout(function () { t.classList.remove('show'); }, 2000);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -267,7 +320,7 @@
|
||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
||||
var token = '';
|
||||
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
|
||||
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) { }
|
||||
|
||||
function cleanLocal() {
|
||||
try {
|
||||
@@ -304,7 +357,7 @@
|
||||
}
|
||||
|
||||
// 软件更新(与 index 设置中逻辑一致)
|
||||
(function() {
|
||||
(function () {
|
||||
const versionText = document.getElementById('homeVersionText');
|
||||
const updateHint = document.getElementById('homeUpdateHint');
|
||||
const btnDownload = document.getElementById('homeBtnDownloadUpdate');
|
||||
@@ -316,25 +369,25 @@
|
||||
versionText.textContent = '当前版本: v' + currentVersion;
|
||||
|
||||
// 打开/关闭浮层
|
||||
toggleBtn.addEventListener('click', function(e) {
|
||||
toggleBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
const isVisible = updatePanel.style.display === 'block';
|
||||
updatePanel.style.display = isVisible ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// 点击外部关闭浮层
|
||||
document.addEventListener('click', function() {
|
||||
document.addEventListener('click', function () {
|
||||
updatePanel.style.display = 'none';
|
||||
});
|
||||
updatePanel.addEventListener('click', function(e) {
|
||||
updatePanel.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.getElementById('homeBtnCheckUpdate').onclick = async function() {
|
||||
document.getElementById('homeBtnCheckUpdate').onclick = async function () {
|
||||
updateHint.textContent = '正在检测更新...';
|
||||
btnDownload.style.display = 'none';
|
||||
try {
|
||||
const resp = await fetch('/api/version', { credentials: 'same-origin' }).catch(function() { return null; });
|
||||
const resp = await fetch('/api/version', { credentials: 'same-origin' }).catch(function () { return null; });
|
||||
if (resp && resp.ok) {
|
||||
const data = await resp.json();
|
||||
currentVersion = (data.version || currentVersion).replace(/^v/i, '');
|
||||
@@ -343,7 +396,7 @@
|
||||
updateHint.textContent = '发现新版本 v' + data.latest_version + (data.desc ? ':' + data.desc : '');
|
||||
btnDownload.style.display = 'inline-block';
|
||||
btnDownload.textContent = '立即更新';
|
||||
btnDownload.onclick = async function() {
|
||||
btnDownload.onclick = async function () {
|
||||
if (!confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return;
|
||||
if (!data.file_url) {
|
||||
updateHint.textContent = '暂无下载地址,请关注官方渠道。';
|
||||
@@ -358,7 +411,7 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_url: data.file_url })
|
||||
});
|
||||
const result = await updateResp.json().catch(function() { return {}; });
|
||||
const result = await updateResp.json().catch(function () { return {}; });
|
||||
if (result.success) {
|
||||
updateHint.textContent = '更新已启动,程序即将退出...';
|
||||
} else {
|
||||
@@ -384,4 +437,5 @@
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -24,6 +24,7 @@ AIIMAGE_SERVER_PORT=18080
|
||||
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=/app/data/tmp
|
||||
AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true
|
||||
AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876
|
||||
AIIMAGE_TRANSIENT_STORAGE_ENDPOINT=http://121.196.149.225:9000
|
||||
```
|
||||
|
||||
Recommended startup command:
|
||||
|
||||
@@ -83,6 +83,7 @@ public class SimilarAsinProperties {
|
||||
* 出现淘汰过频影响命中率时可上调到 512MB;2GB 堆约束下不建议超过 768MB。
|
||||
*/
|
||||
private long imageCacheMaxBytes = 256L * 1024L * 1024L;
|
||||
private boolean imageDbCacheEnabled = false;
|
||||
|
||||
/**
|
||||
* 是否在 Coze 请求 parameters 中附带 api_key 字段。
|
||||
|
||||
@@ -12,4 +12,6 @@ public class TaskImageCacheCleanupProperties {
|
||||
private int retentionDays = 3;
|
||||
private int batchSize = 5000;
|
||||
private int maxBatchesPerRun = 200;
|
||||
private long maxBytes = 2L * 1024L * 1024L * 1024L;
|
||||
private long targetBytes = 1L * 1024L * 1024L * 1024L;
|
||||
}
|
||||
|
||||
@@ -17,4 +17,25 @@ public class TransientStorageProperties {
|
||||
private int writeTimeoutSeconds = 60;
|
||||
private int uploadMaxRetries = 3;
|
||||
private int readMaxRetries = 3;
|
||||
private int deleteMaxRetries = 3;
|
||||
private int maxConcurrentUploads = 16;
|
||||
private int maxConcurrentReads = 32;
|
||||
private int maxConcurrentDeletes = 8;
|
||||
private long acquirePermitTimeoutMillis = 2000;
|
||||
private long baseRetryDelayMillis = 500;
|
||||
private long maxRetryDelayMillis = 5000;
|
||||
private long retryJitterMillis = 250;
|
||||
private long failureWindowSeconds = 60;
|
||||
private int failureWindowThreshold = 20;
|
||||
private long failureCooldownMillis = 10000;
|
||||
private int connectionPoolMaxIdle = 0;
|
||||
private long connectionPoolKeepAliveMillis = 1;
|
||||
private long warnPayloadBytes = 5L * 1024 * 1024;
|
||||
private long maxPayloadBytes = 50L * 1024 * 1024;
|
||||
private long maxStoredPayloadBytes = 50L * 1024 * 1024;
|
||||
private boolean fallbackToLocalOnOversize = true;
|
||||
private boolean deleteRetryEnabled = true;
|
||||
private String deleteRetryCron = "0 */5 * * * *";
|
||||
private int deleteRetryQueueCapacity = 10000;
|
||||
private int deleteRetryBatchSize = 200;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ public class AppearancePatentCozeClient {
|
||||
private static final String INFRINGEMENT = "侵权";
|
||||
private static final String NO_INFRINGEMENT = "无侵权";
|
||||
private static final String BRAND_QUERY_FAILED = "商标查询失败";
|
||||
private static final String COZE_ASYNC_POLL_TIMEOUT_MESSAGE = "Coze 异步工作流轮询超时";
|
||||
|
||||
private final AppearancePatentProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -691,6 +692,18 @@ public class AppearancePatentCozeClient {
|
||||
}
|
||||
|
||||
private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) {
|
||||
if (isCozeAsyncPollTimeout(failureMessage)) {
|
||||
row.setError(failureMessage);
|
||||
if (row.getStatus() == null || row.getStatus().isBlank()) {
|
||||
row.setStatus("FAILED");
|
||||
row.setFailureSyntheticStatus(true);
|
||||
}
|
||||
row.setTitleRisk(null);
|
||||
row.setAppearanceRisk(null);
|
||||
row.setPatentRisk(null);
|
||||
row.setConclusion(null);
|
||||
return row;
|
||||
}
|
||||
String reviewMessage = failureMessage == null || failureMessage.isBlank()
|
||||
? "Coze 检测失败"
|
||||
: "Coze 检测失败:" + failureMessage;
|
||||
@@ -718,6 +731,12 @@ public class AppearancePatentCozeClient {
|
||||
return row;
|
||||
}
|
||||
|
||||
private boolean isCozeAsyncPollTimeout(String failureMessage) {
|
||||
String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT);
|
||||
return normalized.contains(COZE_ASYNC_POLL_TIMEOUT_MESSAGE.toLowerCase(Locale.ROOT))
|
||||
|| normalized.contains("coze async workflow poll timeout");
|
||||
}
|
||||
|
||||
private boolean shouldSplitBatch(List<AppearancePatentResultRowDto> rows, Exception ex) {
|
||||
return rows != null && rows.size() > 1 && isRetryableBatchFailure(ex);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ public class AppearancePatentTaskService {
|
||||
private static final String STATUS_RUNNING = "RUNNING";
|
||||
private static final String STATUS_SUCCESS = "SUCCESS";
|
||||
private static final String STATUS_FAILED = "FAILED";
|
||||
private static final String COZE_ASYNC_POLL_TIMEOUT_MESSAGE = "Coze 异步工作流轮询超时";
|
||||
private static final String COZE_EMPTY_RESULT_MESSAGE = "Coze returned empty result rows";
|
||||
private static final String COZE_STATUS_SUBMITTED = "SUBMITTED";
|
||||
private static final String COZE_STATUS_RUNNING = "RUNNING";
|
||||
@@ -4193,6 +4194,9 @@ public class AppearancePatentTaskService {
|
||||
}
|
||||
|
||||
private String userFacingCozeCellValue(AppearancePatentResultRowDto row, String value) {
|
||||
if (row != null && isCozeAsyncPollTimeout(row.getError())) {
|
||||
return "";
|
||||
}
|
||||
String normalizedValue = normalize(value);
|
||||
if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {
|
||||
return value;
|
||||
@@ -4211,6 +4215,9 @@ public class AppearancePatentTaskService {
|
||||
if (row == null) {
|
||||
return "";
|
||||
}
|
||||
if (isCozeAsyncPollTimeout(row.getError())) {
|
||||
return "";
|
||||
}
|
||||
String conclusion = normalize(row.getConclusion());
|
||||
if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) {
|
||||
return row.getConclusion();
|
||||
@@ -4225,6 +4232,12 @@ public class AppearancePatentTaskService {
|
||||
return firstNonBlank(row.getConclusion(), "");
|
||||
}
|
||||
|
||||
private boolean isCozeAsyncPollTimeout(String value) {
|
||||
String normalized = normalize(value).toLowerCase(Locale.ROOT);
|
||||
return normalized.contains(COZE_ASYNC_POLL_TIMEOUT_MESSAGE.toLowerCase(Locale.ROOT))
|
||||
|| normalized.contains("coze async workflow poll timeout");
|
||||
}
|
||||
|
||||
private String userFacingStatus(AppearancePatentResultRowDto row) {
|
||||
if (row == null) {
|
||||
return "";
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.nanri.aiimage.modules.file.service.object;
|
||||
|
||||
import com.nanri.aiimage.config.TransientStorageProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class RustfsDeleteRetryService {
|
||||
|
||||
private final TransientStorageProperties properties;
|
||||
private final RustfsObjectStorageService rustfsObjectStorageService;
|
||||
private final ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
|
||||
private final ConcurrentHashMap<String, RetryItem> pending = new ConcurrentHashMap<>();
|
||||
private final AtomicInteger queuedCount = new AtomicInteger();
|
||||
|
||||
public void enqueue(String objectKey, Throwable cause) {
|
||||
if (!properties.isDeleteRetryEnabled() || objectKey == null || objectKey.isBlank()) {
|
||||
return;
|
||||
}
|
||||
RetryItem item = pending.computeIfAbsent(objectKey, RetryItem::new);
|
||||
item.lastError = cause == null ? null : cause.getMessage();
|
||||
item.lastFailedAt = LocalDateTime.now();
|
||||
enqueueItem(item, false);
|
||||
}
|
||||
|
||||
@Scheduled(cron = "${aiimage.transient-storage.delete-retry-cron:0 */5 * * * *}")
|
||||
public void retryPendingDeletes() {
|
||||
if (!properties.isDeleteRetryEnabled()) {
|
||||
return;
|
||||
}
|
||||
int batchSize = Math.max(1, properties.getDeleteRetryBatchSize());
|
||||
int processed = 0;
|
||||
int success = 0;
|
||||
int failed = 0;
|
||||
while (processed < batchSize) {
|
||||
String objectKey = nextQueuedObjectKey(batchSize - processed);
|
||||
if (objectKey == null) {
|
||||
break;
|
||||
}
|
||||
RetryItem item = pending.get(objectKey);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
item.queued = false;
|
||||
processed++;
|
||||
try {
|
||||
rustfsObjectStorageService.deleteObjectFromRetry(objectKey);
|
||||
pending.remove(objectKey);
|
||||
success++;
|
||||
} catch (Exception ex) {
|
||||
failed++;
|
||||
int failures = item.failures.incrementAndGet();
|
||||
item.lastError = ex.getMessage();
|
||||
item.lastFailedAt = LocalDateTime.now();
|
||||
log.warn("[rustfs] delete retry failed objectKey={} failures={} err={}", objectKey, failures, ex.getMessage());
|
||||
enqueueItem(item, true);
|
||||
}
|
||||
}
|
||||
if (processed > 0) {
|
||||
log.info("[rustfs] delete retry batch completed processed={} success={} failed={} pending={} queued={}",
|
||||
processed, success, failed, pending.size(), queuedCount.get());
|
||||
}
|
||||
}
|
||||
|
||||
int pendingCount() {
|
||||
return pending.size();
|
||||
}
|
||||
|
||||
private String nextQueuedObjectKey(int refillLimit) {
|
||||
String objectKey = queue.poll();
|
||||
if (objectKey != null) {
|
||||
decrementQueuedCount();
|
||||
refillQueue(refillLimit);
|
||||
return objectKey;
|
||||
}
|
||||
if (refillQueue(refillLimit) <= 0) {
|
||||
return null;
|
||||
}
|
||||
objectKey = queue.poll();
|
||||
if (objectKey != null) {
|
||||
decrementQueuedCount();
|
||||
refillQueue(refillLimit);
|
||||
}
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
private int refillQueue(int maxItems) {
|
||||
if (maxItems <= 0) {
|
||||
return 0;
|
||||
}
|
||||
int capacity = Math.max(1, properties.getDeleteRetryQueueCapacity());
|
||||
if (queuedCount.get() >= capacity) {
|
||||
return 0;
|
||||
}
|
||||
int added = 0;
|
||||
for (RetryItem item : pending.values()) {
|
||||
if (added >= maxItems || queuedCount.get() >= capacity) {
|
||||
break;
|
||||
}
|
||||
if (enqueueItem(item, true)) {
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
private void decrementQueuedCount() {
|
||||
queuedCount.updateAndGet(value -> Math.max(0, value - 1));
|
||||
}
|
||||
|
||||
private boolean enqueueItem(RetryItem item, boolean requeue) {
|
||||
synchronized (item) {
|
||||
if (item.queued) {
|
||||
return false;
|
||||
}
|
||||
int capacity = Math.max(1, properties.getDeleteRetryQueueCapacity());
|
||||
if (queuedCount.get() >= capacity) {
|
||||
log.error("[rustfs] delete retry queue full, deferred objectKey={} requeue={} pending={} queued={} capacity={} lastError={}",
|
||||
item.objectKey, requeue, pending.size(), queuedCount.get(), capacity, item.lastError);
|
||||
return false;
|
||||
}
|
||||
item.queued = true;
|
||||
queue.offer(item.objectKey);
|
||||
queuedCount.incrementAndGet();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static class RetryItem {
|
||||
private final String objectKey;
|
||||
private final AtomicInteger failures = new AtomicInteger();
|
||||
private volatile boolean queued;
|
||||
private volatile String lastError;
|
||||
private volatile LocalDateTime lastFailedAt;
|
||||
|
||||
private RetryItem(String objectKey) {
|
||||
this.objectKey = objectKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,71 @@
|
||||
package com.nanri.aiimage.modules.file.service.object;
|
||||
|
||||
import com.nanri.aiimage.config.TransientStorageProperties;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import io.minio.GetObjectArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import io.minio.StatObjectArgs;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.ConnectionPool;
|
||||
import okhttp3.OkHttpClient;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class RustfsObjectStorageService {
|
||||
|
||||
private static final String OP_UPLOAD = "upload";
|
||||
private static final String OP_READ = "read";
|
||||
private static final String OP_DELETE = "delete";
|
||||
private static final String OP_STAT = "stat";
|
||||
|
||||
private final TransientStorageProperties properties;
|
||||
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
|
||||
private final ObjectProvider<RustfsDeleteRetryService> deleteRetryServiceProvider;
|
||||
private final Supplier<MinioClient> minioClientSupplier;
|
||||
private final Semaphore uploadSemaphore;
|
||||
private final Semaphore readSemaphore;
|
||||
private final Semaphore deleteSemaphore;
|
||||
private final AtomicInteger windowFailureCount = new AtomicInteger();
|
||||
private volatile long failureWindowStartedAtMillis;
|
||||
private volatile long circuitOpenUntilMillis;
|
||||
private volatile OkHttpClient httpClient;
|
||||
|
||||
@Autowired
|
||||
public RustfsObjectStorageService(TransientStorageProperties properties,
|
||||
ObjectProvider<MeterRegistry> meterRegistryProvider,
|
||||
ObjectProvider<RustfsDeleteRetryService> deleteRetryServiceProvider) {
|
||||
this(properties, meterRegistryProvider, deleteRetryServiceProvider, null);
|
||||
}
|
||||
|
||||
RustfsObjectStorageService(TransientStorageProperties properties,
|
||||
ObjectProvider<MeterRegistry> meterRegistryProvider,
|
||||
ObjectProvider<RustfsDeleteRetryService> deleteRetryServiceProvider,
|
||||
Supplier<MinioClient> minioClientSupplier) {
|
||||
this.properties = properties;
|
||||
this.meterRegistryProvider = meterRegistryProvider;
|
||||
this.deleteRetryServiceProvider = deleteRetryServiceProvider;
|
||||
this.minioClientSupplier = minioClientSupplier;
|
||||
this.uploadSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentUploads()));
|
||||
this.readSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentReads()));
|
||||
this.deleteSemaphore = new Semaphore(Math.max(1, properties.getMaxConcurrentDeletes()));
|
||||
}
|
||||
|
||||
public boolean isConfigured() {
|
||||
return notBlank(properties.getEndpoint())
|
||||
&& notBlank(properties.getBucket())
|
||||
@@ -39,10 +81,10 @@ public class RustfsObjectStorageService {
|
||||
if (!isConfigured()) {
|
||||
throw new IllegalStateException("transient storage is not configured");
|
||||
}
|
||||
rejectIfCircuitOpen(OP_UPLOAD, objectKey);
|
||||
byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
|
||||
Exception last = null;
|
||||
int maxRetries = Math.max(1, properties.getUploadMaxRetries());
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
recordPayloadBytes(bytes.length);
|
||||
return executeWithRetry(OP_UPLOAD, objectKey, bytes.length, Math.max(1, properties.getUploadMaxRetries()), uploadSemaphore, () -> {
|
||||
try (ByteArrayInputStream stream = new ByteArrayInputStream(bytes)) {
|
||||
buildClient().putObject(PutObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
@@ -54,56 +96,117 @@ public class RustfsObjectStorageService {
|
||||
verifyObjectVisible(objectKey);
|
||||
}
|
||||
return objectKey;
|
||||
} catch (Exception ex) {
|
||||
last = ex;
|
||||
if (attempt < maxRetries) {
|
||||
log.warn("[rustfs] upload failed, retrying objectKey={} attempt={}/{}", objectKey, attempt, maxRetries, ex);
|
||||
sleepQuietly(500L * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("failed to upload payload to transient storage", last);
|
||||
});
|
||||
}
|
||||
|
||||
public String readObjectAsString(String objectKey) {
|
||||
if (!isConfigured()) {
|
||||
throw new IllegalStateException("transient storage is not configured");
|
||||
}
|
||||
Exception last = null;
|
||||
int maxRetries = Math.max(1, properties.getReadMaxRetries());
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
rejectIfCircuitOpen(OP_READ, objectKey);
|
||||
return executeWithRetry(OP_READ, objectKey, 0, Math.max(1, properties.getReadMaxRetries()), readSemaphore, () -> {
|
||||
try (var stream = buildClient().getObject(GetObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(objectKey)
|
||||
.build())) {
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (Exception ex) {
|
||||
last = ex;
|
||||
if (attempt < maxRetries) {
|
||||
// 与 uploadText 对齐的线性退避,避免 rustfs 短暂抖动直接打成 read coze batch failed。
|
||||
log.warn("[rustfs] read failed, retrying objectKey={} attempt={}/{} err={}", objectKey, attempt, maxRetries, ex.getMessage());
|
||||
sleepQuietly(500L * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("failed to read payload from transient storage", last);
|
||||
});
|
||||
}
|
||||
|
||||
public void deleteObject(String objectKey) {
|
||||
deleteObject(objectKey, true);
|
||||
}
|
||||
|
||||
void deleteObjectFromRetry(String objectKey) {
|
||||
deleteObject(objectKey, false);
|
||||
}
|
||||
|
||||
private void deleteObject(String objectKey, boolean enqueueRetryOnFailure) {
|
||||
if (!isConfigured() || !notBlank(objectKey)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
buildClient().removeObject(RemoveObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(objectKey)
|
||||
.build());
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("failed to delete payload from transient storage", ex);
|
||||
executeWithRetry(OP_DELETE, objectKey, 0, Math.max(1, properties.getDeleteMaxRetries()), deleteSemaphore, () -> {
|
||||
buildClient().removeObject(RemoveObjectArgs.builder()
|
||||
.bucket(properties.getBucket())
|
||||
.object(objectKey)
|
||||
.build());
|
||||
return null;
|
||||
});
|
||||
} catch (RuntimeException ex) {
|
||||
if (enqueueRetryOnFailure && properties.isDeleteRetryEnabled()) {
|
||||
RustfsDeleteRetryService retryService = deleteRetryServiceProvider == null ? null : deleteRetryServiceProvider.getIfAvailable();
|
||||
if (retryService != null) {
|
||||
retryService.enqueue(objectKey, ex);
|
||||
}
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T executeWithRetry(String operation,
|
||||
String objectKey,
|
||||
long bytes,
|
||||
int maxRetries,
|
||||
Semaphore semaphore,
|
||||
CheckedSupplier<T> supplier) {
|
||||
acquirePermit(operation, objectKey, semaphore);
|
||||
long startedAt = System.nanoTime();
|
||||
Exception last = null;
|
||||
try {
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
T result = supplier.get();
|
||||
resetFailureWindow();
|
||||
recordOperation(operation, "success", elapsedNanos(startedAt));
|
||||
log.debug("[rustfs] operation success operation={} objectKey={} attempt={}/{} durationMs={} bytes={}",
|
||||
operation, objectKey, attempt, maxRetries, elapsedMillis(startedAt), bytes);
|
||||
return result;
|
||||
} catch (Exception ex) {
|
||||
last = ex;
|
||||
evictIdleConnections();
|
||||
recordOperation(operation, attempt < maxRetries ? "retry" : "failure", elapsedNanos(startedAt));
|
||||
recordFailure(operation, objectKey, ex);
|
||||
if (attempt < maxRetries) {
|
||||
long delayMillis = retryDelayMillis(attempt);
|
||||
log.warn("[rustfs] operation failed, retrying operation={} objectKey={} attempt={}/{} delayMs={} err={}",
|
||||
operation, objectKey, attempt, maxRetries, delayMillis, ex.getMessage());
|
||||
sleepQuietly(delayMillis, "retrying rustfs " + operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
semaphore.release();
|
||||
}
|
||||
throw new IllegalStateException("failed to " + operation + " payload in transient storage", last);
|
||||
}
|
||||
|
||||
private void acquirePermit(String operation, String objectKey, Semaphore semaphore) {
|
||||
try {
|
||||
boolean acquired;
|
||||
long timeoutMillis = Math.max(0L, properties.getAcquirePermitTimeoutMillis());
|
||||
if (timeoutMillis == 0L) {
|
||||
acquired = semaphore.tryAcquire();
|
||||
} else {
|
||||
acquired = semaphore.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
if (!acquired) {
|
||||
recordOperation(operation, "rejected", 0L);
|
||||
throw new IllegalStateException("rustfs " + operation + " concurrency limit reached: " + objectKey);
|
||||
}
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
recordOperation(operation, "rejected", 0L);
|
||||
throw new IllegalStateException("interrupted while acquiring rustfs " + operation + " permit", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private MinioClient buildClient() {
|
||||
if (minioClientSupplier != null) {
|
||||
return minioClientSupplier.get();
|
||||
}
|
||||
return MinioClient.builder()
|
||||
.endpoint(properties.getEndpoint())
|
||||
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
|
||||
@@ -112,7 +215,7 @@ public class RustfsObjectStorageService {
|
||||
.build();
|
||||
}
|
||||
|
||||
private OkHttpClient getHttpClient() {
|
||||
OkHttpClient getHttpClient() {
|
||||
OkHttpClient current = httpClient;
|
||||
if (current != null) {
|
||||
return current;
|
||||
@@ -123,13 +226,26 @@ public class RustfsObjectStorageService {
|
||||
.connectTimeout(Math.max(1, properties.getConnectTimeoutSeconds()), TimeUnit.SECONDS)
|
||||
.readTimeout(Math.max(1, properties.getReadTimeoutSeconds()), TimeUnit.SECONDS)
|
||||
.writeTimeout(Math.max(1, properties.getWriteTimeoutSeconds()), TimeUnit.SECONDS)
|
||||
.connectionPool(new ConnectionPool(
|
||||
Math.max(0, properties.getConnectionPoolMaxIdle()),
|
||||
Math.max(1L, properties.getConnectionPoolKeepAliveMillis()),
|
||||
TimeUnit.MILLISECONDS))
|
||||
.retryOnConnectionFailure(true)
|
||||
.build();
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
}
|
||||
|
||||
private void evictIdleConnections() {
|
||||
OkHttpClient current = httpClient;
|
||||
if (current != null) {
|
||||
current.connectionPool().evictAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyObjectVisible(String objectKey) {
|
||||
long startedAt = System.nanoTime();
|
||||
RuntimeException last = null;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
@@ -137,11 +253,14 @@ public class RustfsObjectStorageService {
|
||||
.bucket(properties.getBucket())
|
||||
.object(objectKey)
|
||||
.build());
|
||||
recordOperation(OP_STAT, "success", elapsedNanos(startedAt));
|
||||
return;
|
||||
} catch (Exception ex) {
|
||||
last = new IllegalStateException("transient payload is not visible after upload: " + objectKey, ex);
|
||||
evictIdleConnections();
|
||||
recordOperation(OP_STAT, attempt < 3 ? "retry" : "failure", elapsedNanos(startedAt));
|
||||
if (attempt < 3) {
|
||||
sleepQuietly(100L * attempt);
|
||||
sleepQuietly(100L * attempt, "verifying transient payload upload");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,16 +269,117 @@ public class RustfsObjectStorageService {
|
||||
: last;
|
||||
}
|
||||
|
||||
private void sleepQuietly(long millis) {
|
||||
private void rejectIfCircuitOpen(String operation, String objectKey) {
|
||||
long openUntil = circuitOpenUntilMillis;
|
||||
if (openUntil <= 0L) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now < openUntil) {
|
||||
recordOperation(operation, "rejected", 0L);
|
||||
throw new IllegalStateException("rustfs failure cooldown active operation=" + operation + " objectKey=" + objectKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void recordFailure(String operation, String objectKey, Exception ex) {
|
||||
long now = System.currentTimeMillis();
|
||||
long windowMillis = Math.max(1L, properties.getFailureWindowSeconds()) * 1000L;
|
||||
if (failureWindowStartedAtMillis <= 0L || now - failureWindowStartedAtMillis > windowMillis) {
|
||||
failureWindowStartedAtMillis = now;
|
||||
windowFailureCount.set(0);
|
||||
}
|
||||
int failures = windowFailureCount.incrementAndGet();
|
||||
int threshold = Math.max(1, properties.getFailureWindowThreshold());
|
||||
if ((OP_UPLOAD.equals(operation) || OP_READ.equals(operation)) && failures >= threshold) {
|
||||
long cooldownMillis = Math.max(0L, properties.getFailureCooldownMillis());
|
||||
circuitOpenUntilMillis = now + cooldownMillis;
|
||||
windowFailureCount.set(0);
|
||||
failureWindowStartedAtMillis = now;
|
||||
log.error("[rustfs] failure window threshold reached, cooldown enabled operation={} objectKey={} failures={} threshold={} cooldownMs={} err={}",
|
||||
operation, objectKey, failures, threshold, cooldownMillis, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void resetFailureWindow() {
|
||||
windowFailureCount.set(0);
|
||||
failureWindowStartedAtMillis = 0L;
|
||||
circuitOpenUntilMillis = 0L;
|
||||
}
|
||||
|
||||
private long retryDelayMillis(int attempt) {
|
||||
long baseDelay = Math.max(0L, properties.getBaseRetryDelayMillis());
|
||||
long maxDelay = Math.max(baseDelay, properties.getMaxRetryDelayMillis());
|
||||
long delay = Math.min(maxDelay, baseDelay * Math.max(1, attempt));
|
||||
long jitter = Math.max(0L, properties.getRetryJitterMillis());
|
||||
if (jitter > 0L) {
|
||||
delay += ThreadLocalRandom.current().nextLong(jitter + 1L);
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
private void sleepQuietly(long millis, String reason) {
|
||||
if (millis <= 0L) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("interrupted while verifying transient payload upload", ex);
|
||||
throw new IllegalStateException("interrupted while " + reason, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void recordPayloadBytes(long bytes) {
|
||||
MeterRegistry registry = meterRegistry();
|
||||
if (registry == null || bytes < 0L) {
|
||||
return;
|
||||
}
|
||||
DistributionSummary.builder("aiimage.rustfs.payload.bytes")
|
||||
.baseUnit("bytes")
|
||||
.register(registry)
|
||||
.record(bytes);
|
||||
}
|
||||
|
||||
private void recordOperation(String operation, String result, long durationNanos) {
|
||||
MeterRegistry registry = meterRegistry();
|
||||
if (registry == null) {
|
||||
return;
|
||||
}
|
||||
registry.counter("aiimage.rustfs.operation.total", "operation", operation, "result", result).increment();
|
||||
if (durationNanos > 0L) {
|
||||
Timer.builder("aiimage.rustfs.operation.duration")
|
||||
.tag("operation", operation)
|
||||
.tag("result", result)
|
||||
.register(registry)
|
||||
.record(durationNanos, TimeUnit.NANOSECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
public void recordLocalFallback() {
|
||||
MeterRegistry registry = meterRegistry();
|
||||
if (registry != null) {
|
||||
registry.counter("aiimage.rustfs.fallback.local.total").increment();
|
||||
}
|
||||
}
|
||||
|
||||
private MeterRegistry meterRegistry() {
|
||||
return meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable();
|
||||
}
|
||||
|
||||
private long elapsedNanos(long startedAt) {
|
||||
return System.nanoTime() - startedAt;
|
||||
}
|
||||
|
||||
private long elapsedMillis(long startedAt) {
|
||||
return TimeUnit.NANOSECONDS.toMillis(elapsedNanos(startedAt));
|
||||
}
|
||||
|
||||
private boolean notBlank(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface CheckedSupplier<T> {
|
||||
T get() throws Exception;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1200,5 +1200,3 @@ public class QueryAsinTaskService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
|
||||
@@ -54,6 +55,7 @@ public class SimilarAsinImagePrefetchService {
|
||||
|
||||
private final SimilarAsinImageEmbedder imageEmbedder;
|
||||
private final TaskImageCacheMapper taskImageCacheMapper;
|
||||
private final SimilarAsinProperties properties;
|
||||
|
||||
/**
|
||||
* 每个 task 当前 in-flight 的预热 future。enqueue 时如果上一个还没完成,会先等它结束,
|
||||
@@ -75,6 +77,9 @@ public class SimilarAsinImagePrefetchService {
|
||||
* 同 task 串行入队(用 inflight map 排队),避免多个 batch 同时打爆图片源站。
|
||||
*/
|
||||
public void enqueue(Long taskId, List<String> urls) {
|
||||
if (!properties.isImageDbCacheEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (taskId == null || urls == null || urls.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -165,6 +170,9 @@ public class SimilarAsinImagePrefetchService {
|
||||
* 失败/未命中返回 null,由调用方走回退路径。
|
||||
*/
|
||||
public byte[] lookup(String url) {
|
||||
if (!properties.isImageDbCacheEnabled()) {
|
||||
return null;
|
||||
}
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ public interface TaskImageCacheMapper extends BaseMapper<TaskImageCacheEntity> {
|
||||
@Select("SELECT image_bytes FROM biz_task_image_cache WHERE url_hash = #{urlHash} LIMIT 1")
|
||||
byte[] selectBytesByUrlHash(@Param("urlHash") String urlHash);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(byte_size), 0) FROM biz_task_image_cache")
|
||||
Long sumByteSize();
|
||||
|
||||
@Select("SELECT COALESCE(SUM(byte_size), 0) FROM (SELECT byte_size FROM biz_task_image_cache ORDER BY last_used_at LIMIT #{limit}) oldest")
|
||||
Long sumOldestBatchByteSize(@Param("limit") int limit);
|
||||
|
||||
@Delete("DELETE FROM biz_task_image_cache WHERE last_used_at < #{cutoff} ORDER BY last_used_at LIMIT #{limit}")
|
||||
int deleteExpiredBatch(@Param("cutoff") LocalDateTime cutoff, @Param("limit") int limit);
|
||||
|
||||
@Delete("DELETE FROM biz_task_image_cache ORDER BY last_used_at LIMIT #{limit}")
|
||||
int deleteOldestBatch(@Param("limit") int limit);
|
||||
}
|
||||
|
||||
@@ -39,18 +39,18 @@ public class TaskImageCacheCleanupService {
|
||||
int maxBatches = Math.max(1, cleanupProperties.getMaxBatchesPerRun());
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays);
|
||||
|
||||
int deletedTotal = 0;
|
||||
int batches = 0;
|
||||
while (batches < maxBatches) {
|
||||
int expiredDeletedTotal = 0;
|
||||
int expiredBatches = 0;
|
||||
while (expiredBatches < maxBatches) {
|
||||
int deleted = taskImageCacheMapper.deleteExpiredBatch(cutoff, batchSize);
|
||||
if (deleted <= 0) {
|
||||
break;
|
||||
}
|
||||
deletedTotal += deleted;
|
||||
batches++;
|
||||
expiredDeletedTotal += deleted;
|
||||
expiredBatches++;
|
||||
if (!lockHandle.renew(CLEANUP_LOCK_TTL)) {
|
||||
log.warn("[task-image-cache-cleanup] stop because lock renew failed deletedTotal={} batches={}",
|
||||
deletedTotal, batches);
|
||||
expiredDeletedTotal, expiredBatches);
|
||||
break;
|
||||
}
|
||||
if (deleted < batchSize) {
|
||||
@@ -58,12 +58,49 @@ public class TaskImageCacheCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedTotal > 0 || batches >= maxBatches) {
|
||||
log.info("[task-image-cache-cleanup] finished cutoff={} retentionDays={} deletedTotal={} batches={} batchSize={} maxBatches={}",
|
||||
cutoff, retentionDays, deletedTotal, batches, batchSize, maxBatches);
|
||||
long maxBytes = cleanupProperties.getMaxBytes();
|
||||
long targetBytes = cleanupProperties.getTargetBytes();
|
||||
int capacityDeletedTotal = 0;
|
||||
int capacityBatches = 0;
|
||||
if (maxBytes > 0L) {
|
||||
long actualTargetBytes = targetBytes > 0L && targetBytes < maxBytes ? targetBytes : maxBytes / 2L;
|
||||
long currentBytes = nullToZero(taskImageCacheMapper.sumByteSize());
|
||||
boolean overCapacity = currentBytes > maxBytes;
|
||||
while (overCapacity && currentBytes > actualTargetBytes && capacityBatches < maxBatches) {
|
||||
long batchBytes = nullToZero(taskImageCacheMapper.sumOldestBatchByteSize(batchSize));
|
||||
if (batchBytes <= 0L) {
|
||||
break;
|
||||
}
|
||||
int deleted = taskImageCacheMapper.deleteOldestBatch(batchSize);
|
||||
if (deleted <= 0) {
|
||||
break;
|
||||
}
|
||||
capacityDeletedTotal += deleted;
|
||||
capacityBatches++;
|
||||
currentBytes = Math.max(0L, currentBytes - batchBytes);
|
||||
if (!lockHandle.renew(CLEANUP_LOCK_TTL)) {
|
||||
log.warn("[task-image-cache-cleanup] stop capacity cleanup because lock renew failed deletedTotal={} batches={}",
|
||||
capacityDeletedTotal, capacityBatches);
|
||||
break;
|
||||
}
|
||||
if (deleted < batchSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredDeletedTotal > 0 || expiredBatches >= maxBatches
|
||||
|| capacityDeletedTotal > 0 || capacityBatches >= maxBatches) {
|
||||
log.info("[task-image-cache-cleanup] finished cutoff={} retentionDays={} expiredDeleted={} expiredBatches={} capacityDeleted={} capacityBatches={} batchSize={} maxBatches={} maxBytes={} targetBytes={}",
|
||||
cutoff, retentionDays, expiredDeletedTotal, expiredBatches, capacityDeletedTotal,
|
||||
capacityBatches, batchSize, maxBatches, maxBytes, targetBytes);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[task-image-cache-cleanup] failed msg={}", ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static long nullToZero(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,20 +312,39 @@ public class TransientPayloadStorageService {
|
||||
return content;
|
||||
}
|
||||
String objectKey = buildObjectKey(category, moduleType, taskId, scopeHash, entryKey);
|
||||
long rawBytes = payloadBytes(content);
|
||||
if (isPositiveLimit(properties.getWarnPayloadBytes()) && rawBytes > properties.getWarnPayloadBytes()) {
|
||||
log.warn("[transient-payload] payload size exceeds warn threshold category={} moduleType={} taskId={} objectKey={} rawBytes={} warnBytes={}",
|
||||
category, moduleType, taskId, objectKey, rawBytes, properties.getWarnPayloadBytes());
|
||||
}
|
||||
boolean rawOversize = isPositiveLimit(properties.getMaxPayloadBytes()) && rawBytes > properties.getMaxPayloadBytes();
|
||||
String storedContent = encodeStoredPayload(content);
|
||||
long storedBytes = payloadBytes(storedContent);
|
||||
boolean storedOversize = isPositiveLimit(properties.getMaxStoredPayloadBytes()) && storedBytes > properties.getMaxStoredPayloadBytes();
|
||||
if (rawOversize || storedOversize) {
|
||||
log.warn("[transient-payload] payload size exceeds rustfs limit category={} moduleType={} taskId={} objectKey={} rawBytes={} storedBytes={} maxRawBytes={} maxStoredBytes={} fallbackToLocal={}",
|
||||
category, moduleType, taskId, objectKey, rawBytes, storedBytes,
|
||||
properties.getMaxPayloadBytes(), properties.getMaxStoredPayloadBytes(), properties.isFallbackToLocalOnOversize());
|
||||
if (!properties.isFallbackToLocalOnOversize()) {
|
||||
throw new IllegalStateException("transient payload exceeds configured size limit: " + objectKey);
|
||||
}
|
||||
}
|
||||
String pointer = null;
|
||||
boolean rustfsFallbackToLocal = false;
|
||||
if (rustfsObjectStorageService.isConfigured()) {
|
||||
boolean rustfsFallbackToLocal = rawOversize || storedOversize;
|
||||
if (!rustfsFallbackToLocal && rustfsObjectStorageService.isConfigured()) {
|
||||
try {
|
||||
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload);
|
||||
} catch (Exception ex) {
|
||||
rustfsFallbackToLocal = true;
|
||||
// 升级为 ERROR:rustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。
|
||||
log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} category={} taskId={} objectKey={} err={}",
|
||||
instanceMetadata.getInstanceId(), category, taskId, objectKey, ex.getMessage());
|
||||
log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} category={} moduleType={} taskId={} objectKey={} rawBytes={} storedBytes={} err={}",
|
||||
instanceMetadata.getInstanceId(), category, moduleType, taskId, objectKey, rawBytes, storedBytes, ex.getMessage());
|
||||
}
|
||||
}
|
||||
if (pointer == null) {
|
||||
if (rustfsFallbackToLocal) {
|
||||
rustfsObjectStorageService.recordLocalFallback();
|
||||
}
|
||||
pointer = storeLocal(objectKey, storedContent);
|
||||
}
|
||||
// P0-3:记录本次 store 是否走 local 兜底,供调用方在拿到 pointer 后立即查询。
|
||||
@@ -429,6 +448,14 @@ public class TransientPayloadStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
private long payloadBytes(String content) {
|
||||
return Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
private boolean isPositiveLimit(long value) {
|
||||
return value > 0L;
|
||||
}
|
||||
|
||||
private String decodeStoredPayload(String storedContent) {
|
||||
if (storedContent == null || storedContent.isBlank()) {
|
||||
return storedContent;
|
||||
|
||||
@@ -19,11 +19,37 @@ AIIMAGE_OSS_ACCESS_KEY_ID=change-me
|
||||
AIIMAGE_OSS_ACCESS_KEY_SECRET=change-me
|
||||
|
||||
AIIMAGE_TRANSIENT_STORAGE_ENABLED=true
|
||||
AIIMAGE_TRANSIENT_STORAGE_ENDPOINT=http://change-me
|
||||
AIIMAGE_TRANSIENT_STORAGE_ENDPOINT=http://121.196.149.225:9000
|
||||
AIIMAGE_TRANSIENT_STORAGE_BUCKET=change-me
|
||||
AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID=change-me
|
||||
AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET=change-me
|
||||
AIIMAGE_TRANSIENT_STORAGE_REGION=us-east-1
|
||||
AIIMAGE_TRANSIENT_STORAGE_CONNECT_TIMEOUT_SECONDS=10
|
||||
AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS=60
|
||||
AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS=60
|
||||
AIIMAGE_TRANSIENT_STORAGE_UPLOAD_MAX_RETRIES=3
|
||||
AIIMAGE_TRANSIENT_STORAGE_READ_MAX_RETRIES=3
|
||||
AIIMAGE_TRANSIENT_STORAGE_DELETE_MAX_RETRIES=3
|
||||
AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_UPLOADS=16
|
||||
AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_READS=32
|
||||
AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_DELETES=8
|
||||
AIIMAGE_TRANSIENT_STORAGE_ACQUIRE_PERMIT_TIMEOUT_MILLIS=2000
|
||||
AIIMAGE_TRANSIENT_STORAGE_BASE_RETRY_DELAY_MILLIS=500
|
||||
AIIMAGE_TRANSIENT_STORAGE_MAX_RETRY_DELAY_MILLIS=5000
|
||||
AIIMAGE_TRANSIENT_STORAGE_RETRY_JITTER_MILLIS=250
|
||||
AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_SECONDS=60
|
||||
AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_THRESHOLD=20
|
||||
AIIMAGE_TRANSIENT_STORAGE_FAILURE_COOLDOWN_MILLIS=10000
|
||||
AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_MAX_IDLE=0
|
||||
AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_KEEP_ALIVE_MILLIS=1
|
||||
AIIMAGE_TRANSIENT_STORAGE_WARN_PAYLOAD_BYTES=5242880
|
||||
AIIMAGE_TRANSIENT_STORAGE_MAX_PAYLOAD_BYTES=52428800
|
||||
AIIMAGE_TRANSIENT_STORAGE_MAX_STORED_PAYLOAD_BYTES=52428800
|
||||
AIIMAGE_TRANSIENT_STORAGE_FALLBACK_TO_LOCAL_ON_OVERSIZE=true
|
||||
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_ENABLED=true
|
||||
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_CRON=0 */5 * * * *
|
||||
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_QUEUE_CAPACITY=10000
|
||||
AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_BATCH_SIZE=200
|
||||
|
||||
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=F:/project/crawler-plugin/backend-java/data/tmp
|
||||
|
||||
|
||||
@@ -100,6 +100,28 @@ aiimage:
|
||||
read-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS:60}
|
||||
write-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS:60}
|
||||
upload-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_UPLOAD_MAX_RETRIES:3}
|
||||
read-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_READ_MAX_RETRIES:3}
|
||||
delete-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_MAX_RETRIES:3}
|
||||
max-concurrent-uploads: ${AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_UPLOADS:16}
|
||||
max-concurrent-reads: ${AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_READS:32}
|
||||
max-concurrent-deletes: ${AIIMAGE_TRANSIENT_STORAGE_MAX_CONCURRENT_DELETES:8}
|
||||
acquire-permit-timeout-millis: ${AIIMAGE_TRANSIENT_STORAGE_ACQUIRE_PERMIT_TIMEOUT_MILLIS:2000}
|
||||
base-retry-delay-millis: ${AIIMAGE_TRANSIENT_STORAGE_BASE_RETRY_DELAY_MILLIS:500}
|
||||
max-retry-delay-millis: ${AIIMAGE_TRANSIENT_STORAGE_MAX_RETRY_DELAY_MILLIS:5000}
|
||||
retry-jitter-millis: ${AIIMAGE_TRANSIENT_STORAGE_RETRY_JITTER_MILLIS:250}
|
||||
failure-window-seconds: ${AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_SECONDS:60}
|
||||
failure-window-threshold: ${AIIMAGE_TRANSIENT_STORAGE_FAILURE_WINDOW_THRESHOLD:20}
|
||||
failure-cooldown-millis: ${AIIMAGE_TRANSIENT_STORAGE_FAILURE_COOLDOWN_MILLIS:10000}
|
||||
connection-pool-max-idle: ${AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_MAX_IDLE:0}
|
||||
connection-pool-keep-alive-millis: ${AIIMAGE_TRANSIENT_STORAGE_CONNECTION_POOL_KEEP_ALIVE_MILLIS:1}
|
||||
warn-payload-bytes: ${AIIMAGE_TRANSIENT_STORAGE_WARN_PAYLOAD_BYTES:5242880}
|
||||
max-payload-bytes: ${AIIMAGE_TRANSIENT_STORAGE_MAX_PAYLOAD_BYTES:52428800}
|
||||
max-stored-payload-bytes: ${AIIMAGE_TRANSIENT_STORAGE_MAX_STORED_PAYLOAD_BYTES:52428800}
|
||||
fallback-to-local-on-oversize: ${AIIMAGE_TRANSIENT_STORAGE_FALLBACK_TO_LOCAL_ON_OVERSIZE:true}
|
||||
delete-retry-enabled: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_ENABLED:true}
|
||||
delete-retry-cron: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_CRON:0 */5 * * * *}
|
||||
delete-retry-queue-capacity: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_QUEUE_CAPACITY:10000}
|
||||
delete-retry-batch-size: ${AIIMAGE_TRANSIENT_STORAGE_DELETE_RETRY_BATCH_SIZE:200}
|
||||
storage:
|
||||
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
|
||||
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true}
|
||||
@@ -142,6 +164,8 @@ aiimage:
|
||||
retention-days: ${AIIMAGE_TASK_IMAGE_CACHE_RETENTION_DAYS:3}
|
||||
batch-size: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_BATCH_SIZE:5000}
|
||||
max-batches-per-run: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_MAX_BATCHES_PER_RUN:200}
|
||||
max-bytes: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_MAX_BYTES:2147483648}
|
||||
target-bytes: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_TARGET_BYTES:1073741824}
|
||||
result-file-job:
|
||||
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}
|
||||
topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job}
|
||||
@@ -197,6 +221,7 @@ aiimage:
|
||||
image-download-pool-size: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DOWNLOAD_POOL_SIZE:32}
|
||||
image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:5}
|
||||
image-cache-max-bytes: ${AIIMAGE_SIMILAR_ASIN_IMAGE_CACHE_MAX_BYTES:268435456}
|
||||
image-db-cache-enabled: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DB_CACHE_ENABLED:false}
|
||||
stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:30}
|
||||
stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *}
|
||||
coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true}
|
||||
|
||||
@@ -25,6 +25,24 @@ class AppearancePatentCozeClientTest {
|
||||
new BrandCheckClient(new BrandCheckProperties())
|
||||
);
|
||||
|
||||
@Test
|
||||
void markRowsFailedLeavesUserFacingResultBlankWhenAsyncPollTimeout() {
|
||||
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
||||
row.setId("1");
|
||||
|
||||
List<AppearancePatentResultRowDto> failedRows =
|
||||
client.markRowsFailed(List.of(row), "Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6");
|
||||
|
||||
assertThat(failedRows).hasSize(1);
|
||||
AppearancePatentResultRowDto failed = failedRows.get(0);
|
||||
assertThat(failed.getError()).isEqualTo("Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6");
|
||||
assertThat(failed.getStatus()).isEqualTo("FAILED");
|
||||
assertThat(failed.getTitleRisk()).isNull();
|
||||
assertThat(failed.getAppearanceRisk()).isNull();
|
||||
assertThat(failed.getPatentRisk()).isNull();
|
||||
assertThat(failed.getConclusion()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleRiskIsInfringementWhenBrandCheckHasFailedData() {
|
||||
BrandCheckClient.BrandCheckBatchResult result =
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.nanri.aiimage.modules.file.service.object;
|
||||
|
||||
import com.nanri.aiimage.config.TransientStorageProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class RustfsDeleteRetryServiceTest {
|
||||
|
||||
@Test
|
||||
void retryPendingDeletesRefillsDeferredItemsBeforeQueueDrainsCompletely() {
|
||||
TransientStorageProperties properties = new TransientStorageProperties();
|
||||
properties.setDeleteRetryEnabled(true);
|
||||
properties.setDeleteRetryQueueCapacity(2);
|
||||
properties.setDeleteRetryBatchSize(1);
|
||||
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
|
||||
doAnswer(invocation -> {
|
||||
String objectKey = invocation.getArgument(0);
|
||||
if ("b".equals(objectKey)) {
|
||||
throw new IllegalStateException("still failing");
|
||||
}
|
||||
return null;
|
||||
}).when(rustfs).deleteObjectFromRetry(org.mockito.ArgumentMatchers.anyString());
|
||||
RustfsDeleteRetryService service = new RustfsDeleteRetryService(properties, rustfs);
|
||||
|
||||
service.enqueue("a", new IllegalStateException("failed-a"));
|
||||
service.enqueue("b", new IllegalStateException("failed-b"));
|
||||
service.enqueue("c", new IllegalStateException("failed-c"));
|
||||
|
||||
service.retryPendingDeletes();
|
||||
service.retryPendingDeletes();
|
||||
service.retryPendingDeletes();
|
||||
|
||||
verify(rustfs).deleteObjectFromRetry("c");
|
||||
assertEquals(1, service.pendingCount());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.nanri.aiimage.modules.file.service.object;
|
||||
|
||||
import com.nanri.aiimage.config.TransientStorageProperties;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import io.minio.MinioClient;
|
||||
import okhttp3.OkHttpClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
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 RustfsObjectStorageServiceTest {
|
||||
|
||||
@Test
|
||||
void httpClientUsesConfiguredConnectionPoolAndKeepsRetryEnabled() {
|
||||
TransientStorageProperties properties = configuredProperties();
|
||||
properties.setConnectionPoolMaxIdle(3);
|
||||
properties.setConnectionPoolKeepAliveMillis(1500);
|
||||
RustfsObjectStorageService service = new RustfsObjectStorageService(
|
||||
properties, provider(new SimpleMeterRegistry()), emptyProvider(), null);
|
||||
|
||||
OkHttpClient client = service.getHttpClient();
|
||||
|
||||
assertTrue(client.retryOnConnectionFailure(), "RustFS OkHttp 应开启连接失败重试");
|
||||
assertNotNull(client.connectionPool(), "RustFS OkHttp 应使用显式 connectionPool 配置");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteFailureRetriesAndEnqueuesCompensation() {
|
||||
TransientStorageProperties properties = configuredProperties();
|
||||
properties.setDeleteMaxRetries(2);
|
||||
properties.setBaseRetryDelayMillis(0);
|
||||
properties.setRetryJitterMillis(0);
|
||||
RustfsDeleteRetryService retryService = mock(RustfsDeleteRetryService.class);
|
||||
RustfsObjectStorageService service = new RustfsObjectStorageService(
|
||||
properties, emptyProvider(), provider(retryService), () -> {
|
||||
throw new IllegalStateException("rustfs down");
|
||||
});
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> service.deleteObject("task/a.json"));
|
||||
|
||||
assertTrue(ex.getMessage().contains("delete"));
|
||||
verify(retryService).enqueue(any(String.class), any(Throwable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteFromRetryDoesNotEnqueueAgain() {
|
||||
TransientStorageProperties properties = configuredProperties();
|
||||
properties.setDeleteMaxRetries(1);
|
||||
RustfsDeleteRetryService retryService = mock(RustfsDeleteRetryService.class);
|
||||
RustfsObjectStorageService service = new RustfsObjectStorageService(
|
||||
properties, emptyProvider(), provider(retryService), () -> {
|
||||
throw new IllegalStateException("rustfs down");
|
||||
});
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.deleteObjectFromRetry("task/a.json"));
|
||||
|
||||
verify(retryService, never()).enqueue(any(String.class), any(Throwable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readPermitTimeoutRejectsImmediately() throws Exception {
|
||||
TransientStorageProperties properties = configuredProperties();
|
||||
properties.setAcquirePermitTimeoutMillis(0);
|
||||
properties.setMaxConcurrentReads(1);
|
||||
RustfsObjectStorageService service = new RustfsObjectStorageService(
|
||||
properties, emptyProvider(), emptyProvider(), () -> mock(MinioClient.class));
|
||||
Semaphore readSemaphore = privateSemaphore(service, "readSemaphore");
|
||||
assertTrue(readSemaphore.tryAcquire());
|
||||
|
||||
try {
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> service.readObjectAsString("task/a.json"));
|
||||
assertTrue(ex.getMessage().contains("concurrency limit"));
|
||||
} finally {
|
||||
readSemaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureWindowOpensCooldownForUpload() {
|
||||
TransientStorageProperties properties = configuredProperties();
|
||||
properties.setUploadMaxRetries(1);
|
||||
properties.setFailureWindowThreshold(1);
|
||||
properties.setFailureCooldownMillis(10000);
|
||||
RustfsObjectStorageService service = new RustfsObjectStorageService(
|
||||
properties, emptyProvider(), emptyProvider(), () -> {
|
||||
throw new IllegalStateException("rustfs down");
|
||||
});
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.uploadText("task/a.json", "{}", false));
|
||||
IllegalStateException rejected = assertThrows(IllegalStateException.class,
|
||||
() -> service.uploadText("task/b.json", "{}", false));
|
||||
|
||||
assertTrue(rejected.getMessage().contains("cooldown active"));
|
||||
}
|
||||
|
||||
private static TransientStorageProperties configuredProperties() {
|
||||
TransientStorageProperties properties = new TransientStorageProperties();
|
||||
properties.setEndpoint("http://127.0.0.1:9000");
|
||||
properties.setBucket("bucket");
|
||||
properties.setAccessKeyId("ak");
|
||||
properties.setAccessKeySecret("sk");
|
||||
properties.setBaseRetryDelayMillis(0);
|
||||
properties.setRetryJitterMillis(0);
|
||||
return properties;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Semaphore privateSemaphore(RustfsObjectStorageService service, String fieldName) throws Exception {
|
||||
Field field = RustfsObjectStorageService.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
return (Semaphore) field.get(service);
|
||||
}
|
||||
|
||||
private static <T> ObjectProvider<T> provider(T value) {
|
||||
ObjectProvider<T> provider = mock(ObjectProvider.class);
|
||||
when(provider.getIfAvailable()).thenReturn(value);
|
||||
return provider;
|
||||
}
|
||||
|
||||
private static <T> ObjectProvider<T> emptyProvider() {
|
||||
ObjectProvider<T> provider = mock(ObjectProvider.class);
|
||||
when(provider.getIfAvailable()).thenReturn(null);
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.config.StorageProperties;
|
||||
import com.nanri.aiimage.config.TransientStorageProperties;
|
||||
import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
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 TransientPayloadStorageServiceTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void oversizeRawPayloadFallsBackToLocalAndKeepsFallbackMarker() {
|
||||
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
|
||||
when(rustfs.isConfigured()).thenReturn(true);
|
||||
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(4, 1024, true));
|
||||
|
||||
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "abcdef", false);
|
||||
|
||||
assertTrue(pointer.startsWith("local:i/test-instance/task-parsed/test/1/scope/latest.json"));
|
||||
assertTrue(service.wasLastStoreLocalFallback());
|
||||
assertEquals("abcdef", service.resolvePayload(pointer, "read failed"));
|
||||
verify(rustfs, never()).uploadText(anyString(), anyString(), anyBoolean());
|
||||
verify(rustfs).recordLocalFallback();
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodedPayloadLimitCanForceLocalFallback() {
|
||||
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
|
||||
when(rustfs.isConfigured()).thenReturn(true);
|
||||
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(1024, 1, true));
|
||||
|
||||
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "a", false);
|
||||
|
||||
assertTrue(pointer.startsWith("local:"));
|
||||
assertTrue(service.wasLastStoreLocalFallback());
|
||||
verify(rustfs, never()).uploadText(anyString(), anyString(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizePayloadCanBeRejectedByConfiguration() {
|
||||
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
|
||||
when(rustfs.isConfigured()).thenReturn(true);
|
||||
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(4, 1024, false));
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> service.storeParsedPayloadFast("TEST", 1L, "scope", "abcdef", false));
|
||||
|
||||
assertTrue(ex.getMessage().contains("exceeds configured size limit"));
|
||||
verify(rustfs, never()).uploadText(anyString(), anyString(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void smallPayloadStillUsesRustfsAndClearsFallbackMarker() {
|
||||
RustfsObjectStorageService rustfs = mock(RustfsObjectStorageService.class);
|
||||
when(rustfs.isConfigured()).thenReturn(true);
|
||||
when(rustfs.uploadText(anyString(), anyString(), anyBoolean())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
TransientPayloadStorageService service = newService(rustfs, propertiesWithLimit(1024, 4096, true));
|
||||
|
||||
String pointer = service.storeParsedPayloadFast("TEST", 1L, "scope", "abc", false);
|
||||
|
||||
assertTrue(pointer.startsWith("rustfs:task-parsed/test/1/scope/latest.json"));
|
||||
assertFalse(service.wasLastStoreLocalFallback());
|
||||
verify(rustfs).uploadText(anyString(), anyString(), anyBoolean());
|
||||
}
|
||||
|
||||
private TransientPayloadStorageService newService(RustfsObjectStorageService rustfs,
|
||||
TransientStorageProperties transientProperties) {
|
||||
StorageProperties storageProperties = new StorageProperties();
|
||||
storageProperties.setLocalTempDir(tempDir.toString());
|
||||
return new TransientPayloadStorageService(
|
||||
transientProperties,
|
||||
storageProperties,
|
||||
rustfs,
|
||||
mock(OssStorageService.class),
|
||||
new ObjectMapper(),
|
||||
new InstanceMetadata("test-instance"),
|
||||
mock(TaskChunkMapper.class),
|
||||
mock(TaskScopeStateMapper.class));
|
||||
}
|
||||
|
||||
private TransientStorageProperties propertiesWithLimit(long maxPayloadBytes,
|
||||
long maxStoredPayloadBytes,
|
||||
boolean fallbackToLocalOnOversize) {
|
||||
TransientStorageProperties properties = new TransientStorageProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setMaxPayloadBytes(maxPayloadBytes);
|
||||
properties.setMaxStoredPayloadBytes(maxStoredPayloadBytes);
|
||||
properties.setWarnPayloadBytes(1);
|
||||
properties.setFallbackToLocalOnOversize(fallbackToLocalOnOversize);
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
12
frontend-vue/image-video.html
Normal file
12
frontend-vue/image-video.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>图生视频</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/image-video-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
frontend-vue/src/image-video-main.ts
Normal file
7
frontend-vue/src/image-video-main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import ImageVideoPage from '@/pages/image-video/ImageVideoPage.vue'
|
||||
|
||||
createApp(ImageVideoPage).use(ElementPlus).mount('#app')
|
||||
@@ -202,6 +202,9 @@ const dashboard = ref<SimilarAsinDashboardVo>({
|
||||
failedTaskCount: 0,
|
||||
})
|
||||
const historyItems = ref<SimilarAsinHistoryItem[]>([])
|
||||
// 前端实时进度态:保存最近一次 progress/batch 接口拿到的进度,作为 history 接口的兜底,
|
||||
// 避免历史接口暂时返回不到该任务、或字段不全时,当前任务区出现进度回退/丢失。
|
||||
const liveProgressItems = ref<Record<number, SimilarAsinHistoryItem>>({})
|
||||
|
||||
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
|
||||
const pendingParseItem = computed<SimilarAsinHistoryItem | null>(() => {
|
||||
@@ -219,10 +222,22 @@ const pendingParseItem = computed<SimilarAsinHistoryItem | null>(() => {
|
||||
})
|
||||
const currentItems = computed(() => {
|
||||
const activeIds = new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])
|
||||
const activeItems = historyItems.value.filter((item) => {
|
||||
const status = normalizeTaskStatus(item)
|
||||
return item.taskId != null && (activeIds.has(item.taskId) || status === 'RUNNING' || status === 'PENDING')
|
||||
})
|
||||
const activeItems = historyItems.value
|
||||
.filter((item) => {
|
||||
const status = normalizeTaskStatus(item)
|
||||
return item.taskId != null && (activeIds.has(item.taskId) || status === 'RUNNING' || status === 'PENDING')
|
||||
})
|
||||
.map((item) => mergeCurrentTaskItem(item))
|
||||
// 兜底:history 暂未返回该任务,但实时缓存里仍在跑,保留它在当前任务区
|
||||
for (const [taskIdText, liveItem] of Object.entries(liveProgressItems.value)) {
|
||||
const taskId = Number(taskIdText)
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) continue
|
||||
if (!activeIds.has(taskId) && normalizeTaskStatus(liveItem) !== 'RUNNING' && normalizeTaskStatus(liveItem) !== 'PENDING') {
|
||||
continue
|
||||
}
|
||||
if (activeItems.some((item) => item.taskId === taskId)) continue
|
||||
activeItems.push(mergeCurrentTaskItem(liveItem))
|
||||
}
|
||||
if (!pendingParseItem.value) return activeItems
|
||||
const exists = activeItems.some((item) => item.taskId === pendingParseItem.value?.taskId)
|
||||
return exists ? activeItems : [pendingParseItem.value, ...activeItems]
|
||||
@@ -234,6 +249,20 @@ const historyOnlyItems = computed(() =>
|
||||
}),
|
||||
)
|
||||
|
||||
function mergeCurrentTaskItem(item: SimilarAsinHistoryItem) {
|
||||
if (item.taskId == null) return item
|
||||
const liveItem = liveProgressItems.value[item.taskId]
|
||||
if (!liveItem) return item
|
||||
return {
|
||||
...item,
|
||||
...liveItem,
|
||||
sourceFilename: liveItem.sourceFilename || item.sourceFilename,
|
||||
resultFilename: liveItem.resultFilename || item.resultFilename,
|
||||
rowCount: liveItem.rowCount ?? item.rowCount,
|
||||
createdAt: liveItem.createdAt || item.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveCozeApiKey() {
|
||||
return getStoredApiSecret('similar-asin').trim()
|
||||
}
|
||||
@@ -463,6 +492,11 @@ function addPollingTask(taskId: number) {
|
||||
|
||||
function removePollingTask(taskId: number) {
|
||||
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
|
||||
if (!pendingFileTaskIds.value.includes(taskId)) {
|
||||
const next = { ...liveProgressItems.value }
|
||||
delete next[taskId]
|
||||
liveProgressItems.value = next
|
||||
}
|
||||
savePollingIds()
|
||||
}
|
||||
|
||||
@@ -476,9 +510,32 @@ function addPendingFileTask(taskId: number) {
|
||||
|
||||
function removePendingFileTask(taskId: number) {
|
||||
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
|
||||
if (!pollingTaskIds.value.includes(taskId)) {
|
||||
const next = { ...liveProgressItems.value }
|
||||
delete next[taskId]
|
||||
liveProgressItems.value = next
|
||||
}
|
||||
savePollingIds()
|
||||
}
|
||||
|
||||
function removeTaskFromLocalState(taskId?: number) {
|
||||
if (!taskId) return
|
||||
removePollingTask(taskId)
|
||||
removePendingFileTask(taskId)
|
||||
if (parseResult.value?.taskId === taskId) {
|
||||
clearParsedTask()
|
||||
}
|
||||
if (queuedTaskSummary.value?.taskId === taskId) {
|
||||
queuedTaskSummary.value = null
|
||||
}
|
||||
historyItems.value = historyItems.value.filter((row) => row.taskId !== taskId)
|
||||
if (liveProgressItems.value[taskId]) {
|
||||
const next = { ...liveProgressItems.value }
|
||||
delete next[taskId]
|
||||
liveProgressItems.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePolling() {
|
||||
if (disposed) return
|
||||
if (pollTimer.value != null) return
|
||||
@@ -528,7 +585,14 @@ async function refreshTaskProgress() {
|
||||
const task = detail.task
|
||||
if (!task?.id) continue
|
||||
const item = detail.items?.[0]
|
||||
if (item) mergeHistoryItem(item)
|
||||
if (item) {
|
||||
// 实时缓存:以最新一次 progress 为准,避免 history 接口字段缺失/延迟时丢失进度
|
||||
liveProgressItems.value = {
|
||||
...liveProgressItems.value,
|
||||
[task.id]: item,
|
||||
}
|
||||
mergeHistoryItem(item)
|
||||
}
|
||||
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
|
||||
removePollingTask(task.id)
|
||||
shouldRefreshDashboard = true
|
||||
@@ -848,11 +912,17 @@ async function downloadResult(item: SimilarAsinHistoryItem) {
|
||||
|
||||
async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
|
||||
try {
|
||||
if (item.taskStatus === 'RUNNING' && item.taskId) {
|
||||
await deleteSimilarAsinTask(item.taskId)
|
||||
removePollingTask(item.taskId)
|
||||
const status = normalizeTaskStatus(item)
|
||||
const taskId = item.taskId
|
||||
const shouldDeleteTask = taskId != null && (status === 'RUNNING' || status === 'PENDING' || !item.resultId)
|
||||
if (shouldDeleteTask) {
|
||||
await deleteSimilarAsinTask(taskId)
|
||||
removeTaskFromLocalState(taskId)
|
||||
} else if (item.resultId) {
|
||||
await deleteSimilarAsinHistory(item.resultId)
|
||||
if (item.taskId) {
|
||||
historyItems.value = historyItems.value.filter((row) => row.taskId !== item.taskId)
|
||||
}
|
||||
}
|
||||
await loadDashboard()
|
||||
await loadHistory({ force: true })
|
||||
|
||||
198
frontend-vue/src/pages/image-video/ImageVideoPage.vue
Normal file
198
frontend-vue/src/pages/image-video/ImageVideoPage.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="image-video-page">
|
||||
<header class="page-header">
|
||||
<span class="header-title">数富AI</span>
|
||||
<a class="back-link" href="/home">返回首页</a>
|
||||
</header>
|
||||
|
||||
<main class="entrances" aria-label="图生视频入口">
|
||||
<button
|
||||
type="button"
|
||||
class="entrance-btn"
|
||||
:disabled="launching"
|
||||
@click="launchDesktop"
|
||||
>
|
||||
数字人
|
||||
</button>
|
||||
<button type="button" class="entrance-btn" @click="showSoon('带货视频')">
|
||||
带货视频
|
||||
</button>
|
||||
<button type="button" class="entrance-btn" @click="showSoon('混剪')">
|
||||
混剪
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
|
||||
{{ statusText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
|
||||
const launching = ref(false)
|
||||
const statusText = ref('')
|
||||
const statusType = ref<'normal' | 'error'>('normal')
|
||||
let statusTimer: number | undefined
|
||||
|
||||
function showStatus(message: string, type: 'normal' | 'error' = 'normal') {
|
||||
statusText.value = message
|
||||
statusType.value = type
|
||||
if (statusTimer) {
|
||||
window.clearTimeout(statusTimer)
|
||||
}
|
||||
statusTimer = window.setTimeout(() => {
|
||||
statusText.value = ''
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
function showSoon(name: string) {
|
||||
showStatus(`${name}暂未开通,敬请期待`)
|
||||
}
|
||||
|
||||
async function launchDesktop() {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.launch_yaoayanui) {
|
||||
showStatus('当前环境不支持拉起桌面端', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
launching.value = true
|
||||
try {
|
||||
const result = await api.launch_yaoayanui()
|
||||
if (result?.success) {
|
||||
showStatus('已发送启动请求')
|
||||
return
|
||||
}
|
||||
showStatus(result?.error || '启动失败', 'error')
|
||||
} catch (error) {
|
||||
showStatus(error instanceof Error ? error.message : String(error), 'error')
|
||||
} finally {
|
||||
launching.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-video-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: #333;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||
background:
|
||||
linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)),
|
||||
url('/static/bg.jpg') center/cover no-repeat,
|
||||
#eef3f7;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.entrances {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.entrance-btn {
|
||||
width: 160px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #333;
|
||||
background: #c5c1c1;
|
||||
border: 4px solid #b8d4e3;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.entrance-btn:hover:not(:disabled) {
|
||||
background: #f8fbfd;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.entrance-btn:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
padding: 12px 24px;
|
||||
max-width: min(420px, calc(100vw - 48px));
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: rgba(176, 42, 55, 0.92);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.image-video-page {
|
||||
padding: 88px 24px 40px;
|
||||
}
|
||||
|
||||
.entrances {
|
||||
width: 100%;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.entrance-btn {
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -60,6 +60,11 @@ export interface PywebviewApi {
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
launch_yaoayanui?: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
enqueue_json?: (
|
||||
data: unknown,
|
||||
) => Promise<{ success: boolean; queue_size?: number; error?: string }>;
|
||||
|
||||
@@ -54,6 +54,7 @@ export default defineConfig({
|
||||
'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
|
||||
'query-asin': resolve(__dirname, 'query-asin.html'),
|
||||
'collect-data': resolve(__dirname, 'collect-data.html'),
|
||||
'image-video': resolve(__dirname, 'image-video.html'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'assets/[name].js',
|
||||
|
||||
1
new_web_source/assets/image-video-BrABETd2.css
Normal file
1
new_web_source/assets/image-video-BrABETd2.css
Normal file
@@ -0,0 +1 @@
|
||||
.image-video-page[data-v-6d2992da]{min-height:100vh;background:#0f1117;color:#f5f7fb;font-family:Microsoft YaHei,PingFang SC,sans-serif}.top-bar[data-v-6d2992da]{min-height:88px;display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding:10px 24px 12px;background:#111;border-bottom:1px solid #2a2a2a}.logo-area[data-v-6d2992da]{display:flex;align-items:center;gap:16px;min-width:0;padding-top:10px}.app-name[data-v-6d2992da]{color:#fff;font-size:18px;font-weight:800;letter-spacing:.02em;white-space:nowrap}.btn-home[data-v-6d2992da]{color:#d2d2d2;font-size:13px;text-decoration:none;white-space:nowrap}.btn-home[data-v-6d2992da]:hover{color:#fff}.nav-sections[data-v-6d2992da]{flex:1;min-width:0;display:flex;align-items:stretch;justify-content:center;gap:0}.nav-section[data-v-6d2992da]{flex:1;min-width:0;padding:0 18px;position:relative}.nav-section+.nav-section[data-v-6d2992da]:before{content:"";position:absolute;left:0;top:14px;bottom:8px;width:1px;background:#ffffff24}.nav-section-title[data-v-6d2992da]{margin-bottom:10px;text-align:center;color:#e8dfcf;font-size:16px;font-weight:800;letter-spacing:.08em}.nav-section-items[data-v-6d2992da]{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;padding:10px 14px;border-radius:14px;background:linear-gradient(180deg,#534a43eb,#48413bf5);box-shadow:inset 0 1px #ffffff0f}.nav-item[data-v-6d2992da]{display:inline-flex;align-items:center;justify-content:center;min-height:28px;padding:0 2px;color:#f3eee4;font-size:14px;font-weight:700;text-decoration:none;border:0;border-bottom:2px solid transparent;transition:color .18s ease,border-color .18s ease,opacity .18s ease}.nav-button[data-v-6d2992da]{background:transparent;cursor:pointer}.nav-item[data-v-6d2992da]:hover{color:#fff;border-bottom-color:#ffffffad}.nav-item.active[data-v-6d2992da]{color:#fff;border-bottom-color:#8dc4ff}.nav-item.disabled[data-v-6d2992da]{color:#ffffffc7;opacity:.82;cursor:default}.top-right[data-v-6d2992da]{width:60px;flex-shrink:0}.main-content[data-v-6d2992da]{min-height:calc(100vh - 88px);padding:40px;background:linear-gradient(#0f1117db,#0f1117eb),radial-gradient(circle at 20% 20%,rgba(80,160,255,.24),transparent 28%),#0f1117}.hero-panel[data-v-6d2992da]{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:32px;max-width:1120px;margin:60px auto 0;align-items:stretch}.hero-copy[data-v-6d2992da],.workflow-preview[data-v-6d2992da]{border:1px solid rgba(255,255,255,.12);background:#161922db;border-radius:8px;padding:32px}.eyebrow[data-v-6d2992da]{color:#8dc4ff;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}h1[data-v-6d2992da]{margin:12px 0 16px;font-size:42px;line-height:1.16}.summary[data-v-6d2992da]{max-width:680px;color:#c7cfdb;font-size:16px;line-height:1.8}.actions[data-v-6d2992da]{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:28px}.primary-action[data-v-6d2992da]{min-width:200px;min-height:42px;padding:0 22px;color:#fff;background:#2f7df6;border:1px solid rgba(255,255,255,.18);border-radius:6px;font-size:15px;font-weight:700;cursor:pointer}.primary-action[data-v-6d2992da]:hover{background:#246ce1}.primary-action[data-v-6d2992da]:disabled{cursor:wait;opacity:.72}.status-text[data-v-6d2992da]{min-height:20px;color:#9dd7a8;font-size:13px}.status-text.error[data-v-6d2992da]{color:#ff9c9c}.workflow-preview[data-v-6d2992da]{display:flex;flex-direction:column;gap:14px}.preview-step[data-v-6d2992da]{display:flex;align-items:center;gap:14px;min-height:68px;padding:0 18px;background:#ffffff0f;border:1px solid rgba(255,255,255,.08);border-radius:8px;color:#eef3fb;font-weight:700}.step-index[data-v-6d2992da]{color:#8dc4ff;font-family:Consolas,monospace;font-size:18px}@media(max-width:1024px){.top-bar[data-v-6d2992da],.nav-sections[data-v-6d2992da],.hero-panel[data-v-6d2992da]{display:flex;flex-direction:column}.nav-section[data-v-6d2992da]{padding:0}.nav-section+.nav-section[data-v-6d2992da]:before,.top-right[data-v-6d2992da]{display:none}.main-content[data-v-6d2992da]{padding:24px}h1[data-v-6d2992da]{font-size:34px}}
|
||||
1
new_web_source/assets/image-video-CEVk_4iB.css
Normal file
1
new_web_source/assets/image-video-CEVk_4iB.css
Normal file
@@ -0,0 +1 @@
|
||||
.image-video-page[data-v-f93fde09]{min-height:100vh;background:#0f1117;color:#f5f7fb;font-family:Microsoft YaHei,PingFang SC,sans-serif}.top-bar[data-v-f93fde09]{min-height:88px;display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding:10px 24px 12px;background:#111;border-bottom:1px solid #2a2a2a}.logo-area[data-v-f93fde09]{display:flex;align-items:center;gap:16px;min-width:0;padding-top:10px}.app-name[data-v-f93fde09]{color:#fff;font-size:18px;font-weight:800;letter-spacing:.02em;white-space:nowrap}.btn-home[data-v-f93fde09]{color:#d2d2d2;font-size:13px;text-decoration:none;white-space:nowrap}.btn-home[data-v-f93fde09]:hover{color:#fff}.nav-sections[data-v-f93fde09]{flex:1;min-width:0;display:flex;align-items:stretch;justify-content:center;gap:0}.nav-section[data-v-f93fde09]{flex:1;min-width:0;padding:0 18px;position:relative}.nav-section+.nav-section[data-v-f93fde09]:before{content:"";position:absolute;left:0;top:14px;bottom:8px;width:1px;background:#ffffff24}.nav-section-title[data-v-f93fde09]{margin-bottom:10px;text-align:center;color:#e8dfcf;font-size:16px;font-weight:800;letter-spacing:.08em}.nav-section-items[data-v-f93fde09]{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;padding:10px 14px;border-radius:14px;background:linear-gradient(180deg,#534a43eb,#48413bf5);box-shadow:inset 0 1px #ffffff0f}.nav-item[data-v-f93fde09]{display:inline-flex;align-items:center;justify-content:center;min-height:28px;padding:0 2px;color:#f3eee4;font-size:14px;font-weight:700;text-decoration:none;border:0;border-bottom:2px solid transparent;transition:color .18s ease,border-color .18s ease,opacity .18s ease}.nav-button[data-v-f93fde09]{background:transparent;cursor:pointer}.nav-item[data-v-f93fde09]:hover{color:#fff;border-bottom-color:#ffffffad}.nav-item.active[data-v-f93fde09]{color:#fff;border-bottom-color:#8dc4ff}.nav-item.disabled[data-v-f93fde09]{color:#ffffffc7;opacity:.82;cursor:default}.top-right[data-v-f93fde09]{width:60px;flex-shrink:0}.main-content[data-v-f93fde09]{min-height:calc(100vh - 88px);padding:40px;background:linear-gradient(#0f1117db,#0f1117eb),radial-gradient(circle at 20% 20%,rgba(80,160,255,.24),transparent 28%),#0f1117}.hero-panel[data-v-f93fde09]{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:32px;max-width:1120px;margin:60px auto 0;align-items:stretch}.hero-copy[data-v-f93fde09],.workflow-preview[data-v-f93fde09]{border:1px solid rgba(255,255,255,.12);background:#161922db;border-radius:8px;padding:32px}.eyebrow[data-v-f93fde09]{color:#8dc4ff;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}h1[data-v-f93fde09]{margin:12px 0 16px;font-size:42px;line-height:1.16}.summary[data-v-f93fde09]{max-width:680px;color:#c7cfdb;font-size:16px;line-height:1.8}.actions[data-v-f93fde09]{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:28px}.primary-action[data-v-f93fde09]{min-width:200px;min-height:42px;padding:0 22px;color:#fff;background:#2f7df6;border:1px solid rgba(255,255,255,.18);border-radius:6px;font-size:15px;font-weight:700;cursor:pointer}.primary-action[data-v-f93fde09]:hover{background:#246ce1}.primary-action[data-v-f93fde09]:disabled{cursor:wait;opacity:.72}.status-text[data-v-f93fde09]{min-height:20px;color:#9dd7a8;font-size:13px}.status-text.error[data-v-f93fde09]{color:#ff9c9c}.workflow-preview[data-v-f93fde09]{display:flex;flex-direction:column;gap:14px}.preview-step[data-v-f93fde09]{display:flex;align-items:center;gap:14px;min-height:68px;padding:0 18px;background:#ffffff0f;border:1px solid rgba(255,255,255,.08);border-radius:8px;color:#eef3fb;font-weight:700}.step-index[data-v-f93fde09]{color:#8dc4ff;font-family:Consolas,monospace;font-size:18px}@media(max-width:1024px){.top-bar[data-v-f93fde09],.nav-sections[data-v-f93fde09],.hero-panel[data-v-f93fde09]{display:flex;flex-direction:column}.nav-section[data-v-f93fde09]{padding:0}.nav-section+.nav-section[data-v-f93fde09]:before,.top-right[data-v-f93fde09]{display:none}.main-content[data-v-f93fde09]{padding:24px}h1[data-v-f93fde09]{font-size:34px}}
|
||||
1
new_web_source/assets/image-video-DYvaWCXF.css
Normal file
1
new_web_source/assets/image-video-DYvaWCXF.css
Normal file
@@ -0,0 +1 @@
|
||||
.image-video-page[data-v-8768f882]{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:#333;font-family:Microsoft YaHei,PingFang SC,Hiragino Sans GB,sans-serif;background:linear-gradient(#ffffff80,#ffffff80),url(/static/bg.jpg) center/cover no-repeat,#eef3f7}.entrances[data-v-8768f882]{display:flex;gap:32px;flex-wrap:wrap;justify-content:center}.entrance-btn[data-v-8768f882]{width:160px;height:100px;display:flex;align-items:center;justify-content:center;color:#333;background:#c5c1c1;border:4px solid #b8d4e3;border-radius:12px;cursor:pointer;font:inherit;font-size:18px;font-weight:600;text-decoration:none;transition:all .2s}.entrance-btn[data-v-8768f882]:hover:not(:disabled){background:#f8fbfd;box-shadow:0 4px 12px #00000014}.entrance-btn[data-v-8768f882]:disabled{cursor:wait;opacity:.72}.toast[data-v-8768f882]{position:fixed;bottom:40px;left:50%;padding:12px 24px;max-width:min(420px,calc(100vw - 48px));color:#fff;background:#000000bf;border-radius:8px;font-size:14px;text-align:center;opacity:0;pointer-events:none;transform:translate(-50%);transition:opacity .3s}.toast.show[data-v-8768f882]{opacity:1}.toast.error[data-v-8768f882]{background:#b02a37eb}@media(max-width:640px){.image-video-page[data-v-8768f882]{padding:88px 24px 40px}.entrances[data-v-8768f882]{width:100%;gap:18px}.entrance-btn[data-v-8768f882]{width:100%;max-width:220px}}
|
||||
1
new_web_source/assets/image-video-uN8PfarH.css
Normal file
1
new_web_source/assets/image-video-uN8PfarH.css
Normal file
@@ -0,0 +1 @@
|
||||
.image-video-page[data-v-e41486d3]{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:#333;font-family:Microsoft YaHei,PingFang SC,Hiragino Sans GB,sans-serif;background:linear-gradient(#ffffff80,#ffffff80),url(/static/bg.jpg) center/cover no-repeat,#eef3f7}.page-header[data-v-e41486d3]{position:absolute;top:0;left:0;right:0;height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:#ffffff80}.header-title[data-v-e41486d3]{color:#333;font-size:18px;font-weight:600}.back-link[data-v-e41486d3]{color:#555;font-size:14px;text-decoration:none}.back-link[data-v-e41486d3]:hover{color:#667eea}.entrances[data-v-e41486d3]{display:flex;gap:32px;flex-wrap:wrap;justify-content:center}.entrance-btn[data-v-e41486d3]{width:160px;height:100px;display:flex;align-items:center;justify-content:center;color:#333;background:#c5c1c1;border:4px solid #b8d4e3;border-radius:12px;cursor:pointer;font:inherit;font-size:18px;font-weight:600;text-decoration:none;transition:all .2s}.entrance-btn[data-v-e41486d3]:hover:not(:disabled){background:#f8fbfd;box-shadow:0 4px 12px #00000014}.entrance-btn[data-v-e41486d3]:disabled{cursor:wait;opacity:.72}.toast[data-v-e41486d3]{position:fixed;bottom:40px;left:50%;padding:12px 24px;max-width:min(420px,calc(100vw - 48px));color:#fff;background:#000000bf;border-radius:8px;font-size:14px;text-align:center;opacity:0;pointer-events:none;transform:translate(-50%);transition:opacity .3s}.toast.show[data-v-e41486d3]{opacity:1}.toast.error[data-v-e41486d3]{background:#b02a37eb}@media(max-width:640px){.image-video-page[data-v-e41486d3]{padding:88px 24px 40px}.entrances[data-v-e41486d3]{width:100%;gap:18px}.entrance-btn[data-v-e41486d3]{width:100%;max-width:220px}}
|
||||
1
new_web_source/assets/image-video.js
Normal file
1
new_web_source/assets/image-video.js
Normal file
@@ -0,0 +1 @@
|
||||
import{d as p,a as m,c as f,e as a,t as _,n as v,g as l,i as b,_ as g,j as y,k as h}from"./pywebview-B8lwamDf.js";const w={class:"image-video-page"},k={class:"entrances","aria-label":"图生视频入口"},C=["disabled"],T=p({__name:"ImageVideoPage",setup(I){const o=l(!1),n=l(""),i=l("normal");let r;function s(t,e="normal"){n.value=t,i.value=e,r&&window.clearTimeout(r),r=window.setTimeout(()=>{n.value=""},2200)}function u(t){s(`${t}暂未开通,敬请期待`)}async function c(){const t=b();if(!t?.launch_yaoayanui){s("当前环境不支持拉起桌面端","error");return}o.value=!0;try{const e=await t.launch_yaoayanui();if(e?.success){s("已发送启动请求");return}s(e?.error||"启动失败","error")}catch(e){s(e instanceof Error?e.message:String(e),"error")}finally{o.value=!1}}return(t,e)=>(m(),f("div",w,[e[2]||(e[2]=a("header",{class:"page-header"},[a("span",{class:"header-title"},"数富AI"),a("a",{class:"back-link",href:"/home"},"返回首页")],-1)),a("main",k,[a("button",{type:"button",class:"entrance-btn",disabled:o.value,onClick:c}," 数字人 ",8,C),a("button",{type:"button",class:"entrance-btn",onClick:e[0]||(e[0]=d=>u("带货视频"))}," 带货视频 "),a("button",{type:"button",class:"entrance-btn",onClick:e[1]||(e[1]=d=>u("混剪"))}," 混剪 ")]),a("div",{class:v(["toast",{show:!!n.value,error:i.value==="error"}])},_(n.value),3)]))}}),B=g(T,[["__scopeId","data-v-e41486d3"]]);y(B).use(h).mount("#app");
|
||||
50
new_web_source/assets/pywebview-B8lwamDf.js
Normal file
50
new_web_source/assets/pywebview-B8lwamDf.js
Normal file
File diff suppressed because one or more lines are too long
1
new_web_source/assets/pywebview-xvHHTGU_.css
Normal file
1
new_web_source/assets/pywebview-xvHHTGU_.css
Normal file
File diff suppressed because one or more lines are too long
15
new_web_source/image-video.html
Normal file
15
new_web_source/image-video.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>图生视频</title>
|
||||
<script type="module" crossorigin src="/assets/image-video.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/pywebview-B8lwamDf.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/pywebview-xvHHTGU_.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/image-video-uN8PfarH.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user