新增内容,新增拉起软件层

This commit is contained in:
super
2026-06-08 09:19:15 +08:00
parent 8ab647c419
commit c85e5b278f
67 changed files with 1552 additions and 189 deletions

View File

@@ -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

View File

@@ -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};

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

View 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}}

View 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

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

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

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

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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:

View File

@@ -83,6 +83,7 @@ public class SimilarAsinProperties {
* 出现淘汰过频影响命中率时可上调到 512MB2GB 堆约束下不建议超过 768MB。 * 出现淘汰过频影响命中率时可上调到 512MB2GB 堆约束下不建议超过 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 字段。

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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);
} }

View File

@@ -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 "";

View File

@@ -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;
}
}
}

View File

@@ -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;
}
} }

View File

@@ -1200,5 +1200,3 @@ public class QueryAsinTaskService {
} }
} }
} }

View File

@@ -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;
} }

View File

@@ -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);
} }

View File

@@ -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;
}
} }

View File

@@ -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;
// 升级为 ERRORrustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。 // 升级为 ERRORrustfs 失败后只能落到本地,多实例下其他节点读不到,必须能告警。
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;

View File

@@ -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

View File

@@ -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}

View File

@@ -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 =

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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>

View 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')

View File

@@ -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 })

View 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>

View File

@@ -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 }>;

View File

@@ -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',

View 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}}

View 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}}

View 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}}

View 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}}

View 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

File diff suppressed because one or more lines are too long

View 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>