新增内容,新增拉起软件层
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
|
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://api.aishufu.top:18080/
|
||||||
# java_api_base=http://127.0.0.1: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:
|
except Exception as e:
|
||||||
return {'success': False, 'error': str(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):
|
def enqueue_json(self, data):
|
||||||
"""保存任务到队列"""
|
"""保存任务到队列"""
|
||||||
try:
|
try:
|
||||||
@@ -538,7 +606,7 @@ def main():
|
|||||||
|
|
||||||
api = WindowAPI(window)
|
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,
|
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(
|
webview.start(
|
||||||
debug=True,
|
debug=True,
|
||||||
storage_path=cache_path,
|
storage_path=cache_path,
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>格式转换 - 数富AI</title>
|
<title>格式转换 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/convert.js"></script>
|
<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/pywebview-B8lwamDf.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
|
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/convert-6oxZNMye.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>数据去重 - 数富AI</title>
|
<title>数据去重 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/dedupe.js"></script>
|
<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/pywebview-B8lwamDf.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
|
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/dedupe-DpfPYQDv.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>删除品牌 - 数富AI</title>
|
<title>删除品牌 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/delete-brand.js"></script>
|
<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/pywebview-B8lwamDf.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.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="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">
|
<link rel="stylesheet" crossorigin href="/assets/delete-brand-DZ3x5hOX.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>数据拆分 - 数富AI</title>
|
<title>数据拆分 - 数富AI</title>
|
||||||
<script type="module" crossorigin src="/assets/split.js"></script>
|
<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/pywebview-B8lwamDf.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/brand-5GpfAyHD.js">
|
<link rel="modulepreload" crossorigin href="/assets/java-modules-B9jblDzU.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/java-modules-B3hGvRck.css">
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/split-CR1PtDQE.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>首页 - 数富AI</title>
|
<title>首页 - 数富AI</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
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)),
|
background: linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)),
|
||||||
url("/static/bg.jpg") center/cover no-repeat;;
|
url("/static/bg.jpg") center/cover no-repeat;
|
||||||
|
;
|
||||||
/*background-image: url("/static/bg.jpg");*/
|
/*background-image: url("/static/bg.jpg");*/
|
||||||
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -19,6 +26,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -29,20 +37,40 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 24px;
|
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-title {
|
||||||
.header-right a, .header-right span {
|
font-size: 18px;
|
||||||
font-size: 14px; color: #555; text-decoration: none;
|
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 {
|
.entrances {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entrance-btn {
|
.entrance-btn {
|
||||||
width: 160px;
|
width: 160px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
@@ -59,21 +87,27 @@
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entrance-btn:hover {
|
.entrance-btn:hover {
|
||||||
background: #f8fbfd;
|
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 {
|
.entrance-btn.disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 40px;
|
bottom: 40px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: rgba(0,0,0,0.75);
|
background: rgba(0, 0, 0, 0.75);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -82,19 +116,25 @@
|
|||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.toast.show { opacity: 1; }
|
|
||||||
|
.toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-link {
|
.admin-link {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-section {
|
.update-section {
|
||||||
margin-top: 48px;
|
margin-top: 48px;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
background: rgba(255,255,255,0.85);
|
background: rgba(255, 255, 255, 0.85);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(0,0,0,0.06);
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-section-title {
|
.update-section-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #333;
|
color: #333;
|
||||||
@@ -103,16 +143,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-block {
|
.update-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-version-text {
|
.update-version-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-check-update {
|
.btn-check-update {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -122,15 +165,18 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-check-update:hover {
|
.btn-check-update:hover {
|
||||||
background: #5a6fd6;
|
background: #5a6fd6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-hint {
|
.update-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #666;
|
color: #666;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-download-update {
|
.btn-download-update {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
@@ -141,15 +187,17 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-download-update:hover {
|
.btn-download-update:hover {
|
||||||
background: #218838;
|
background: #218838;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-update-btn {
|
.header-update-btn {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: none;
|
||||||
background: rgba(102,126,234,0.12);
|
background: rgba(102, 126, 234, 0.12);
|
||||||
color: #4c5bd4;
|
color: #4c5bd4;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -158,22 +206,25 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-update-btn:hover {
|
.header-update-btn:hover {
|
||||||
background: rgba(102,126,234,0.2);
|
background: rgba(102, 126, 234, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-update-panel {
|
.header-update-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 44px;
|
top: 44px;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
background: rgba(255,255,255,0.98);
|
background: rgba(255, 255, 255, 0.98);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
border: 1px solid rgba(0,0,0,0.04);
|
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-update-title {
|
.header-update-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
@@ -184,12 +235,13 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body data-user-id="{{ user_id or '' }}">
|
<body data-user-id="{{ user_id or '' }}">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<span class="header-title">数富AI</span>
|
<span class="header-title">数富AI</span>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
|
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span>{{ username }}</span>
|
<span>{{ username }}</span>
|
||||||
<button type="button" class="header-update-btn" id="homeUpdateToggle" title="检测更新">↻</button>
|
<button type="button" class="header-update-btn" id="homeUpdateToggle" title="检测更新">↻</button>
|
||||||
@@ -202,7 +254,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="update-hint" id="homeUpdateHint"></div>
|
<div class="update-hint" id="homeUpdateHint"></div>
|
||||||
<div style="margin-top: 4px;">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,7 +263,7 @@
|
|||||||
|
|
||||||
<div class="entrances" id="entrances">
|
<div class="entrances" id="entrances">
|
||||||
<a class="entrance-btn" href="/brand" data-column-key="brand">亚马逊</a>
|
<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>
|
<a class="entrance-btn image" href="/image" data-column-key="image">图片</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -221,44 +274,44 @@
|
|||||||
return 'app_column_permissions:' + String(uid || '');
|
return 'app_column_permissions:' + String(uid || '');
|
||||||
}
|
}
|
||||||
// 权限校验:根据 Java column-permissions 接口按 column_key 显示入口
|
// 权限校验:根据 Java column-permissions 接口按 column_key 显示入口
|
||||||
(function() {
|
(function () {
|
||||||
localStorage.setItem("uid",{{ user_id }})
|
localStorage.setItem("uid", {{ user_id }});
|
||||||
var uid = document.body.getAttribute('data-user-id');
|
var uid = document.body.getAttribute('data-user-id');
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
var cacheKey = getAppPermissionCacheKey(uid);
|
var cacheKey = getAppPermissionCacheKey(uid);
|
||||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||||
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
||||||
var token = '';
|
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;
|
if (!JAVA_API_BASE || !token) return;
|
||||||
var apiUrl = JAVA_API_BASE + '/api/admin/permission-users/' + uid + '/column-permissions?menuType=app';
|
var apiUrl = JAVA_API_BASE + '/api/admin/permission-users/' + uid + '/column-permissions?menuType=app';
|
||||||
fetch(apiUrl, {
|
fetch(apiUrl, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Authorization': 'Bearer ' + token }
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function(res) {
|
.then(function (res) {
|
||||||
if (!res || !res.success || !Array.isArray(res.data)) return;
|
if (!res || !res.success || !Array.isArray(res.data)) return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(cacheKey, JSON.stringify(res.data));
|
localStorage.setItem(cacheKey, JSON.stringify(res.data));
|
||||||
} catch (e) {}
|
} catch (e) { }
|
||||||
var allowedKeys = res.data.map(function(item) { return (item.columnKey || item.column_key || '').toLowerCase(); });
|
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) {
|
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function (btn) {
|
||||||
var key = (btn.getAttribute('data-column-key') || '').toLowerCase();
|
var key = (btn.getAttribute('data-column-key') || '').toLowerCase();
|
||||||
btn.style.display = allowedKeys.indexOf(key) >= 0 ? '' : 'none';
|
btn.style.display = allowedKeys.indexOf(key) >= 0 ? '' : 'none';
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function() {});
|
.catch(function () { });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
document.querySelectorAll('.entrance-btn.disabled').forEach(function(btn) {
|
document.querySelectorAll('.entrance-btn.disabled').forEach(function (btn) {
|
||||||
btn.onclick = function(e) {
|
btn.onclick = function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var msg = btn.getAttribute('data-msg') || '暂未开通,敬请期待';
|
var msg = btn.getAttribute('data-msg') || '暂未开通,敬请期待';
|
||||||
var t = document.getElementById('toast');
|
var t = document.getElementById('toast');
|
||||||
t.textContent = msg;
|
t.textContent = msg;
|
||||||
t.classList.add('show');
|
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 AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||||
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
||||||
var token = '';
|
var token = '';
|
||||||
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
|
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) { }
|
||||||
|
|
||||||
function cleanLocal() {
|
function cleanLocal() {
|
||||||
try {
|
try {
|
||||||
@@ -304,7 +357,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 软件更新(与 index 设置中逻辑一致)
|
// 软件更新(与 index 设置中逻辑一致)
|
||||||
(function() {
|
(function () {
|
||||||
const versionText = document.getElementById('homeVersionText');
|
const versionText = document.getElementById('homeVersionText');
|
||||||
const updateHint = document.getElementById('homeUpdateHint');
|
const updateHint = document.getElementById('homeUpdateHint');
|
||||||
const btnDownload = document.getElementById('homeBtnDownloadUpdate');
|
const btnDownload = document.getElementById('homeBtnDownloadUpdate');
|
||||||
@@ -316,25 +369,25 @@
|
|||||||
versionText.textContent = '当前版本: v' + currentVersion;
|
versionText.textContent = '当前版本: v' + currentVersion;
|
||||||
|
|
||||||
// 打开/关闭浮层
|
// 打开/关闭浮层
|
||||||
toggleBtn.addEventListener('click', function(e) {
|
toggleBtn.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const isVisible = updatePanel.style.display === 'block';
|
const isVisible = updatePanel.style.display === 'block';
|
||||||
updatePanel.style.display = isVisible ? 'none' : 'block';
|
updatePanel.style.display = isVisible ? 'none' : 'block';
|
||||||
});
|
});
|
||||||
|
|
||||||
// 点击外部关闭浮层
|
// 点击外部关闭浮层
|
||||||
document.addEventListener('click', function() {
|
document.addEventListener('click', function () {
|
||||||
updatePanel.style.display = 'none';
|
updatePanel.style.display = 'none';
|
||||||
});
|
});
|
||||||
updatePanel.addEventListener('click', function(e) {
|
updatePanel.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('homeBtnCheckUpdate').onclick = async function() {
|
document.getElementById('homeBtnCheckUpdate').onclick = async function () {
|
||||||
updateHint.textContent = '正在检测更新...';
|
updateHint.textContent = '正在检测更新...';
|
||||||
btnDownload.style.display = 'none';
|
btnDownload.style.display = 'none';
|
||||||
try {
|
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) {
|
if (resp && resp.ok) {
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
currentVersion = (data.version || currentVersion).replace(/^v/i, '');
|
currentVersion = (data.version || currentVersion).replace(/^v/i, '');
|
||||||
@@ -343,7 +396,7 @@
|
|||||||
updateHint.textContent = '发现新版本 v' + data.latest_version + (data.desc ? ':' + data.desc : '');
|
updateHint.textContent = '发现新版本 v' + data.latest_version + (data.desc ? ':' + data.desc : '');
|
||||||
btnDownload.style.display = 'inline-block';
|
btnDownload.style.display = 'inline-block';
|
||||||
btnDownload.textContent = '立即更新';
|
btnDownload.textContent = '立即更新';
|
||||||
btnDownload.onclick = async function() {
|
btnDownload.onclick = async function () {
|
||||||
if (!confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return;
|
if (!confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return;
|
||||||
if (!data.file_url) {
|
if (!data.file_url) {
|
||||||
updateHint.textContent = '暂无下载地址,请关注官方渠道。';
|
updateHint.textContent = '暂无下载地址,请关注官方渠道。';
|
||||||
@@ -358,7 +411,7 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ file_url: data.file_url })
|
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) {
|
if (result.success) {
|
||||||
updateHint.textContent = '更新已启动,程序即将退出...';
|
updateHint.textContent = '更新已启动,程序即将退出...';
|
||||||
} else {
|
} else {
|
||||||
@@ -384,4 +437,5 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ AIIMAGE_SERVER_PORT=18080
|
|||||||
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=/app/data/tmp
|
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=/app/data/tmp
|
||||||
AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true
|
AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true
|
||||||
AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876
|
AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876
|
||||||
|
AIIMAGE_TRANSIENT_STORAGE_ENDPOINT=http://121.196.149.225:9000
|
||||||
```
|
```
|
||||||
|
|
||||||
Recommended startup command:
|
Recommended startup command:
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ public class SimilarAsinProperties {
|
|||||||
* 出现淘汰过频影响命中率时可上调到 512MB;2GB 堆约束下不建议超过 768MB。
|
* 出现淘汰过频影响命中率时可上调到 512MB;2GB 堆约束下不建议超过 768MB。
|
||||||
*/
|
*/
|
||||||
private long imageCacheMaxBytes = 256L * 1024L * 1024L;
|
private long imageCacheMaxBytes = 256L * 1024L * 1024L;
|
||||||
|
private boolean imageDbCacheEnabled = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否在 Coze 请求 parameters 中附带 api_key 字段。
|
* 是否在 Coze 请求 parameters 中附带 api_key 字段。
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ public class TaskImageCacheCleanupProperties {
|
|||||||
private int retentionDays = 3;
|
private int retentionDays = 3;
|
||||||
private int batchSize = 5000;
|
private int batchSize = 5000;
|
||||||
private int maxBatchesPerRun = 200;
|
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 writeTimeoutSeconds = 60;
|
||||||
private int uploadMaxRetries = 3;
|
private int uploadMaxRetries = 3;
|
||||||
private int readMaxRetries = 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 INFRINGEMENT = "侵权";
|
||||||
private static final String NO_INFRINGEMENT = "无侵权";
|
private static final String NO_INFRINGEMENT = "无侵权";
|
||||||
private static final String BRAND_QUERY_FAILED = "商标查询失败";
|
private static final String BRAND_QUERY_FAILED = "商标查询失败";
|
||||||
|
private static final String COZE_ASYNC_POLL_TIMEOUT_MESSAGE = "Coze 异步工作流轮询超时";
|
||||||
|
|
||||||
private final AppearancePatentProperties properties;
|
private final AppearancePatentProperties properties;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@@ -691,6 +692,18 @@ public class AppearancePatentCozeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) {
|
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()
|
String reviewMessage = failureMessage == null || failureMessage.isBlank()
|
||||||
? "Coze 检测失败"
|
? "Coze 检测失败"
|
||||||
: "Coze 检测失败:" + failureMessage;
|
: "Coze 检测失败:" + failureMessage;
|
||||||
@@ -718,6 +731,12 @@ public class AppearancePatentCozeClient {
|
|||||||
return row;
|
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) {
|
private boolean shouldSplitBatch(List<AppearancePatentResultRowDto> rows, Exception ex) {
|
||||||
return rows != null && rows.size() > 1 && isRetryableBatchFailure(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_RUNNING = "RUNNING";
|
||||||
private static final String STATUS_SUCCESS = "SUCCESS";
|
private static final String STATUS_SUCCESS = "SUCCESS";
|
||||||
private static final String STATUS_FAILED = "FAILED";
|
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_EMPTY_RESULT_MESSAGE = "Coze returned empty result rows";
|
||||||
private static final String COZE_STATUS_SUBMITTED = "SUBMITTED";
|
private static final String COZE_STATUS_SUBMITTED = "SUBMITTED";
|
||||||
private static final String COZE_STATUS_RUNNING = "RUNNING";
|
private static final String COZE_STATUS_RUNNING = "RUNNING";
|
||||||
@@ -4193,6 +4194,9 @@ public class AppearancePatentTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String userFacingCozeCellValue(AppearancePatentResultRowDto row, String value) {
|
private String userFacingCozeCellValue(AppearancePatentResultRowDto row, String value) {
|
||||||
|
if (row != null && isCozeAsyncPollTimeout(row.getError())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
String normalizedValue = normalize(value);
|
String normalizedValue = normalize(value);
|
||||||
if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {
|
if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {
|
||||||
return value;
|
return value;
|
||||||
@@ -4211,6 +4215,9 @@ public class AppearancePatentTaskService {
|
|||||||
if (row == null) {
|
if (row == null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
if (isCozeAsyncPollTimeout(row.getError())) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
String conclusion = normalize(row.getConclusion());
|
String conclusion = normalize(row.getConclusion());
|
||||||
if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) {
|
if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) {
|
||||||
return row.getConclusion();
|
return row.getConclusion();
|
||||||
@@ -4225,6 +4232,12 @@ public class AppearancePatentTaskService {
|
|||||||
return firstNonBlank(row.getConclusion(), "");
|
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) {
|
private String userFacingStatus(AppearancePatentResultRowDto row) {
|
||||||
if (row == null) {
|
if (row == null) {
|
||||||
return "";
|
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;
|
package com.nanri.aiimage.modules.file.service.object;
|
||||||
|
|
||||||
import com.nanri.aiimage.config.TransientStorageProperties;
|
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.GetObjectArgs;
|
||||||
import io.minio.MinioClient;
|
import io.minio.MinioClient;
|
||||||
import io.minio.PutObjectArgs;
|
import io.minio.PutObjectArgs;
|
||||||
import io.minio.RemoveObjectArgs;
|
import io.minio.RemoveObjectArgs;
|
||||||
import io.minio.StatObjectArgs;
|
import io.minio.StatObjectArgs;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import okhttp3.ConnectionPool;
|
||||||
import okhttp3.OkHttpClient;
|
import okhttp3.OkHttpClient;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.Semaphore;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class RustfsObjectStorageService {
|
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 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;
|
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() {
|
public boolean isConfigured() {
|
||||||
return notBlank(properties.getEndpoint())
|
return notBlank(properties.getEndpoint())
|
||||||
&& notBlank(properties.getBucket())
|
&& notBlank(properties.getBucket())
|
||||||
@@ -39,10 +81,10 @@ public class RustfsObjectStorageService {
|
|||||||
if (!isConfigured()) {
|
if (!isConfigured()) {
|
||||||
throw new IllegalStateException("transient storage is not configured");
|
throw new IllegalStateException("transient storage is not configured");
|
||||||
}
|
}
|
||||||
|
rejectIfCircuitOpen(OP_UPLOAD, objectKey);
|
||||||
byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
|
byte[] bytes = Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8);
|
||||||
Exception last = null;
|
recordPayloadBytes(bytes.length);
|
||||||
int maxRetries = Math.max(1, properties.getUploadMaxRetries());
|
return executeWithRetry(OP_UPLOAD, objectKey, bytes.length, Math.max(1, properties.getUploadMaxRetries()), uploadSemaphore, () -> {
|
||||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
try (ByteArrayInputStream stream = new ByteArrayInputStream(bytes)) {
|
try (ByteArrayInputStream stream = new ByteArrayInputStream(bytes)) {
|
||||||
buildClient().putObject(PutObjectArgs.builder()
|
buildClient().putObject(PutObjectArgs.builder()
|
||||||
.bucket(properties.getBucket())
|
.bucket(properties.getBucket())
|
||||||
@@ -54,56 +96,117 @@ public class RustfsObjectStorageService {
|
|||||||
verifyObjectVisible(objectKey);
|
verifyObjectVisible(objectKey);
|
||||||
}
|
}
|
||||||
return 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) {
|
public String readObjectAsString(String objectKey) {
|
||||||
if (!isConfigured()) {
|
if (!isConfigured()) {
|
||||||
throw new IllegalStateException("transient storage is not configured");
|
throw new IllegalStateException("transient storage is not configured");
|
||||||
}
|
}
|
||||||
Exception last = null;
|
rejectIfCircuitOpen(OP_READ, objectKey);
|
||||||
int maxRetries = Math.max(1, properties.getReadMaxRetries());
|
return executeWithRetry(OP_READ, objectKey, 0, Math.max(1, properties.getReadMaxRetries()), readSemaphore, () -> {
|
||||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
try (var stream = buildClient().getObject(GetObjectArgs.builder()
|
try (var stream = buildClient().getObject(GetObjectArgs.builder()
|
||||||
.bucket(properties.getBucket())
|
.bucket(properties.getBucket())
|
||||||
.object(objectKey)
|
.object(objectKey)
|
||||||
.build())) {
|
.build())) {
|
||||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
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) {
|
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)) {
|
if (!isConfigured() || !notBlank(objectKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
buildClient().removeObject(RemoveObjectArgs.builder()
|
executeWithRetry(OP_DELETE, objectKey, 0, Math.max(1, properties.getDeleteMaxRetries()), deleteSemaphore, () -> {
|
||||||
.bucket(properties.getBucket())
|
buildClient().removeObject(RemoveObjectArgs.builder()
|
||||||
.object(objectKey)
|
.bucket(properties.getBucket())
|
||||||
.build());
|
.object(objectKey)
|
||||||
} catch (Exception ex) {
|
.build());
|
||||||
throw new IllegalStateException("failed to delete payload from transient storage", ex);
|
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() {
|
private MinioClient buildClient() {
|
||||||
|
if (minioClientSupplier != null) {
|
||||||
|
return minioClientSupplier.get();
|
||||||
|
}
|
||||||
return MinioClient.builder()
|
return MinioClient.builder()
|
||||||
.endpoint(properties.getEndpoint())
|
.endpoint(properties.getEndpoint())
|
||||||
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
|
.credentials(properties.getAccessKeyId(), properties.getAccessKeySecret())
|
||||||
@@ -112,7 +215,7 @@ public class RustfsObjectStorageService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private OkHttpClient getHttpClient() {
|
OkHttpClient getHttpClient() {
|
||||||
OkHttpClient current = httpClient;
|
OkHttpClient current = httpClient;
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
return current;
|
return current;
|
||||||
@@ -123,13 +226,26 @@ public class RustfsObjectStorageService {
|
|||||||
.connectTimeout(Math.max(1, properties.getConnectTimeoutSeconds()), TimeUnit.SECONDS)
|
.connectTimeout(Math.max(1, properties.getConnectTimeoutSeconds()), TimeUnit.SECONDS)
|
||||||
.readTimeout(Math.max(1, properties.getReadTimeoutSeconds()), TimeUnit.SECONDS)
|
.readTimeout(Math.max(1, properties.getReadTimeoutSeconds()), TimeUnit.SECONDS)
|
||||||
.writeTimeout(Math.max(1, properties.getWriteTimeoutSeconds()), 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();
|
.build();
|
||||||
}
|
}
|
||||||
return httpClient;
|
return httpClient;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void evictIdleConnections() {
|
||||||
|
OkHttpClient current = httpClient;
|
||||||
|
if (current != null) {
|
||||||
|
current.connectionPool().evictAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void verifyObjectVisible(String objectKey) {
|
private void verifyObjectVisible(String objectKey) {
|
||||||
|
long startedAt = System.nanoTime();
|
||||||
RuntimeException last = null;
|
RuntimeException last = null;
|
||||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
@@ -137,11 +253,14 @@ public class RustfsObjectStorageService {
|
|||||||
.bucket(properties.getBucket())
|
.bucket(properties.getBucket())
|
||||||
.object(objectKey)
|
.object(objectKey)
|
||||||
.build());
|
.build());
|
||||||
|
recordOperation(OP_STAT, "success", elapsedNanos(startedAt));
|
||||||
return;
|
return;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
last = new IllegalStateException("transient payload is not visible after upload: " + objectKey, 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) {
|
if (attempt < 3) {
|
||||||
sleepQuietly(100L * attempt);
|
sleepQuietly(100L * attempt, "verifying transient payload upload");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,16 +269,117 @@ public class RustfsObjectStorageService {
|
|||||||
: last;
|
: 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 {
|
try {
|
||||||
Thread.sleep(millis);
|
Thread.sleep(millis);
|
||||||
} catch (InterruptedException ex) {
|
} catch (InterruptedException ex) {
|
||||||
Thread.currentThread().interrupt();
|
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) {
|
private boolean notBlank(String value) {
|
||||||
return value != null && !value.isBlank();
|
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;
|
package com.nanri.aiimage.modules.similarasin.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
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;
|
||||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
|
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
|
||||||
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
|
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
|
||||||
@@ -54,6 +55,7 @@ public class SimilarAsinImagePrefetchService {
|
|||||||
|
|
||||||
private final SimilarAsinImageEmbedder imageEmbedder;
|
private final SimilarAsinImageEmbedder imageEmbedder;
|
||||||
private final TaskImageCacheMapper taskImageCacheMapper;
|
private final TaskImageCacheMapper taskImageCacheMapper;
|
||||||
|
private final SimilarAsinProperties properties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每个 task 当前 in-flight 的预热 future。enqueue 时如果上一个还没完成,会先等它结束,
|
* 每个 task 当前 in-flight 的预热 future。enqueue 时如果上一个还没完成,会先等它结束,
|
||||||
@@ -75,6 +77,9 @@ public class SimilarAsinImagePrefetchService {
|
|||||||
* 同 task 串行入队(用 inflight map 排队),避免多个 batch 同时打爆图片源站。
|
* 同 task 串行入队(用 inflight map 排队),避免多个 batch 同时打爆图片源站。
|
||||||
*/
|
*/
|
||||||
public void enqueue(Long taskId, List<String> urls) {
|
public void enqueue(Long taskId, List<String> urls) {
|
||||||
|
if (!properties.isImageDbCacheEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (taskId == null || urls == null || urls.isEmpty()) {
|
if (taskId == null || urls == null || urls.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -165,6 +170,9 @@ public class SimilarAsinImagePrefetchService {
|
|||||||
* 失败/未命中返回 null,由调用方走回退路径。
|
* 失败/未命中返回 null,由调用方走回退路径。
|
||||||
*/
|
*/
|
||||||
public byte[] lookup(String url) {
|
public byte[] lookup(String url) {
|
||||||
|
if (!properties.isImageDbCacheEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
return 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")
|
@Select("SELECT image_bytes FROM biz_task_image_cache WHERE url_hash = #{urlHash} LIMIT 1")
|
||||||
byte[] selectBytesByUrlHash(@Param("urlHash") String urlHash);
|
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}")
|
@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);
|
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());
|
int maxBatches = Math.max(1, cleanupProperties.getMaxBatchesPerRun());
|
||||||
LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays);
|
LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays);
|
||||||
|
|
||||||
int deletedTotal = 0;
|
int expiredDeletedTotal = 0;
|
||||||
int batches = 0;
|
int expiredBatches = 0;
|
||||||
while (batches < maxBatches) {
|
while (expiredBatches < maxBatches) {
|
||||||
int deleted = taskImageCacheMapper.deleteExpiredBatch(cutoff, batchSize);
|
int deleted = taskImageCacheMapper.deleteExpiredBatch(cutoff, batchSize);
|
||||||
if (deleted <= 0) {
|
if (deleted <= 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
deletedTotal += deleted;
|
expiredDeletedTotal += deleted;
|
||||||
batches++;
|
expiredBatches++;
|
||||||
if (!lockHandle.renew(CLEANUP_LOCK_TTL)) {
|
if (!lockHandle.renew(CLEANUP_LOCK_TTL)) {
|
||||||
log.warn("[task-image-cache-cleanup] stop because lock renew failed deletedTotal={} batches={}",
|
log.warn("[task-image-cache-cleanup] stop because lock renew failed deletedTotal={} batches={}",
|
||||||
deletedTotal, batches);
|
expiredDeletedTotal, expiredBatches);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (deleted < batchSize) {
|
if (deleted < batchSize) {
|
||||||
@@ -58,12 +58,49 @@ public class TaskImageCacheCleanupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deletedTotal > 0 || batches >= maxBatches) {
|
long maxBytes = cleanupProperties.getMaxBytes();
|
||||||
log.info("[task-image-cache-cleanup] finished cutoff={} retentionDays={} deletedTotal={} batches={} batchSize={} maxBatches={}",
|
long targetBytes = cleanupProperties.getTargetBytes();
|
||||||
cutoff, retentionDays, deletedTotal, batches, batchSize, maxBatches);
|
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) {
|
} catch (Exception ex) {
|
||||||
log.warn("[task-image-cache-cleanup] failed msg={}", ex.getMessage(), 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;
|
return content;
|
||||||
}
|
}
|
||||||
String objectKey = buildObjectKey(category, moduleType, taskId, scopeHash, entryKey);
|
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);
|
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;
|
String pointer = null;
|
||||||
boolean rustfsFallbackToLocal = false;
|
boolean rustfsFallbackToLocal = rawOversize || storedOversize;
|
||||||
if (rustfsObjectStorageService.isConfigured()) {
|
if (!rustfsFallbackToLocal && rustfsObjectStorageService.isConfigured()) {
|
||||||
try {
|
try {
|
||||||
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload);
|
pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, storedContent, verifyAfterUpload);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
rustfsFallbackToLocal = true;
|
rustfsFallbackToLocal = true;
|
||||||
// 升级为 ERROR:rustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。
|
// 升级为 ERROR:rustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。
|
||||||
log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} category={} taskId={} objectKey={} err={}",
|
log.error("[transient-payload] rustfs upload failed, fallback to local store instanceId={} category={} moduleType={} taskId={} objectKey={} rawBytes={} storedBytes={} err={}",
|
||||||
instanceMetadata.getInstanceId(), category, taskId, objectKey, ex.getMessage());
|
instanceMetadata.getInstanceId(), category, moduleType, taskId, objectKey, rawBytes, storedBytes, ex.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pointer == null) {
|
if (pointer == null) {
|
||||||
|
if (rustfsFallbackToLocal) {
|
||||||
|
rustfsObjectStorageService.recordLocalFallback();
|
||||||
|
}
|
||||||
pointer = storeLocal(objectKey, storedContent);
|
pointer = storeLocal(objectKey, storedContent);
|
||||||
}
|
}
|
||||||
// P0-3:记录本次 store 是否走 local 兜底,供调用方在拿到 pointer 后立即查询。
|
// 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) {
|
private String decodeStoredPayload(String storedContent) {
|
||||||
if (storedContent == null || storedContent.isBlank()) {
|
if (storedContent == null || storedContent.isBlank()) {
|
||||||
return storedContent;
|
return storedContent;
|
||||||
|
|||||||
@@ -19,11 +19,37 @@ AIIMAGE_OSS_ACCESS_KEY_ID=change-me
|
|||||||
AIIMAGE_OSS_ACCESS_KEY_SECRET=change-me
|
AIIMAGE_OSS_ACCESS_KEY_SECRET=change-me
|
||||||
|
|
||||||
AIIMAGE_TRANSIENT_STORAGE_ENABLED=true
|
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_BUCKET=change-me
|
||||||
AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID=change-me
|
AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID=change-me
|
||||||
AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET=change-me
|
AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET=change-me
|
||||||
AIIMAGE_TRANSIENT_STORAGE_REGION=us-east-1
|
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
|
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}
|
read-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_READ_TIMEOUT_SECONDS:60}
|
||||||
write-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS:60}
|
write-timeout-seconds: ${AIIMAGE_TRANSIENT_STORAGE_WRITE_TIMEOUT_SECONDS:60}
|
||||||
upload-max-retries: ${AIIMAGE_TRANSIENT_STORAGE_UPLOAD_MAX_RETRIES:3}
|
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:
|
storage:
|
||||||
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
|
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
|
||||||
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true}
|
cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true}
|
||||||
@@ -142,6 +164,8 @@ aiimage:
|
|||||||
retention-days: ${AIIMAGE_TASK_IMAGE_CACHE_RETENTION_DAYS:3}
|
retention-days: ${AIIMAGE_TASK_IMAGE_CACHE_RETENTION_DAYS:3}
|
||||||
batch-size: ${AIIMAGE_TASK_IMAGE_CACHE_CLEANUP_BATCH_SIZE:5000}
|
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-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:
|
result-file-job:
|
||||||
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}
|
mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:true}
|
||||||
topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job}
|
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-pool-size: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DOWNLOAD_POOL_SIZE:32}
|
||||||
image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:5}
|
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-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-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:30}
|
||||||
stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *}
|
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}
|
coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true}
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ class AppearancePatentCozeClientTest {
|
|||||||
new BrandCheckClient(new BrandCheckProperties())
|
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
|
@Test
|
||||||
void titleRiskIsInfringementWhenBrandCheckHasFailedData() {
|
void titleRiskIsInfringementWhenBrandCheckHasFailedData() {
|
||||||
BrandCheckClient.BrandCheckBatchResult result =
|
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,
|
failedTaskCount: 0,
|
||||||
})
|
})
|
||||||
const historyItems = ref<SimilarAsinHistoryItem[]>([])
|
const historyItems = ref<SimilarAsinHistoryItem[]>([])
|
||||||
|
// 前端实时进度态:保存最近一次 progress/batch 接口拿到的进度,作为 history 接口的兜底,
|
||||||
|
// 避免历史接口暂时返回不到该任务、或字段不全时,当前任务区出现进度回退/丢失。
|
||||||
|
const liveProgressItems = ref<Record<number, SimilarAsinHistoryItem>>({})
|
||||||
|
|
||||||
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
|
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
|
||||||
const pendingParseItem = computed<SimilarAsinHistoryItem | null>(() => {
|
const pendingParseItem = computed<SimilarAsinHistoryItem | null>(() => {
|
||||||
@@ -219,10 +222,22 @@ const pendingParseItem = computed<SimilarAsinHistoryItem | null>(() => {
|
|||||||
})
|
})
|
||||||
const currentItems = computed(() => {
|
const currentItems = computed(() => {
|
||||||
const activeIds = new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])
|
const activeIds = new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])
|
||||||
const activeItems = historyItems.value.filter((item) => {
|
const activeItems = historyItems.value
|
||||||
const status = normalizeTaskStatus(item)
|
.filter((item) => {
|
||||||
return item.taskId != null && (activeIds.has(item.taskId) || status === 'RUNNING' || status === 'PENDING')
|
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
|
if (!pendingParseItem.value) return activeItems
|
||||||
const exists = activeItems.some((item) => item.taskId === pendingParseItem.value?.taskId)
|
const exists = activeItems.some((item) => item.taskId === pendingParseItem.value?.taskId)
|
||||||
return exists ? activeItems : [pendingParseItem.value, ...activeItems]
|
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() {
|
function effectiveCozeApiKey() {
|
||||||
return getStoredApiSecret('similar-asin').trim()
|
return getStoredApiSecret('similar-asin').trim()
|
||||||
}
|
}
|
||||||
@@ -463,6 +492,11 @@ function addPollingTask(taskId: number) {
|
|||||||
|
|
||||||
function removePollingTask(taskId: number) {
|
function removePollingTask(taskId: number) {
|
||||||
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
|
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
|
||||||
|
if (!pendingFileTaskIds.value.includes(taskId)) {
|
||||||
|
const next = { ...liveProgressItems.value }
|
||||||
|
delete next[taskId]
|
||||||
|
liveProgressItems.value = next
|
||||||
|
}
|
||||||
savePollingIds()
|
savePollingIds()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,9 +510,32 @@ function addPendingFileTask(taskId: number) {
|
|||||||
|
|
||||||
function removePendingFileTask(taskId: number) {
|
function removePendingFileTask(taskId: number) {
|
||||||
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
|
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
|
||||||
|
if (!pollingTaskIds.value.includes(taskId)) {
|
||||||
|
const next = { ...liveProgressItems.value }
|
||||||
|
delete next[taskId]
|
||||||
|
liveProgressItems.value = next
|
||||||
|
}
|
||||||
savePollingIds()
|
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() {
|
function ensurePolling() {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (pollTimer.value != null) return
|
if (pollTimer.value != null) return
|
||||||
@@ -528,7 +585,14 @@ async function refreshTaskProgress() {
|
|||||||
const task = detail.task
|
const task = detail.task
|
||||||
if (!task?.id) continue
|
if (!task?.id) continue
|
||||||
const item = detail.items?.[0]
|
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') {
|
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
|
||||||
removePollingTask(task.id)
|
removePollingTask(task.id)
|
||||||
shouldRefreshDashboard = true
|
shouldRefreshDashboard = true
|
||||||
@@ -848,11 +912,17 @@ async function downloadResult(item: SimilarAsinHistoryItem) {
|
|||||||
|
|
||||||
async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
|
async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
|
||||||
try {
|
try {
|
||||||
if (item.taskStatus === 'RUNNING' && item.taskId) {
|
const status = normalizeTaskStatus(item)
|
||||||
await deleteSimilarAsinTask(item.taskId)
|
const taskId = item.taskId
|
||||||
removePollingTask(item.taskId)
|
const shouldDeleteTask = taskId != null && (status === 'RUNNING' || status === 'PENDING' || !item.resultId)
|
||||||
|
if (shouldDeleteTask) {
|
||||||
|
await deleteSimilarAsinTask(taskId)
|
||||||
|
removeTaskFromLocalState(taskId)
|
||||||
} else if (item.resultId) {
|
} else if (item.resultId) {
|
||||||
await deleteSimilarAsinHistory(item.resultId)
|
await deleteSimilarAsinHistory(item.resultId)
|
||||||
|
if (item.taskId) {
|
||||||
|
historyItems.value = historyItems.value.filter((row) => row.taskId !== item.taskId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await loadDashboard()
|
await loadDashboard()
|
||||||
await loadHistory({ force: true })
|
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;
|
path?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
launch_yaoayanui?: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
enqueue_json?: (
|
enqueue_json?: (
|
||||||
data: unknown,
|
data: unknown,
|
||||||
) => Promise<{ success: boolean; queue_size?: number; error?: string }>;
|
) => Promise<{ success: boolean; queue_size?: number; error?: string }>;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export default defineConfig({
|
|||||||
'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
|
'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
|
||||||
'query-asin': resolve(__dirname, 'query-asin.html'),
|
'query-asin': resolve(__dirname, 'query-asin.html'),
|
||||||
'collect-data': resolve(__dirname, 'collect-data.html'),
|
'collect-data': resolve(__dirname, 'collect-data.html'),
|
||||||
|
'image-video': resolve(__dirname, 'image-video.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'assets/[name].js',
|
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