Compare commits
199 Commits
aeb4e1710c
...
1e087c1aae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e087c1aae | ||
|
|
2e2de02476 | ||
|
|
b4b80cb572 | ||
|
|
14cebf22c3 | ||
|
|
01ecde45c5 | ||
|
|
1dc2cac19a | ||
|
|
01f4dbb83a | ||
|
|
fe692fcd1d | ||
|
|
18b0c2d211 | ||
|
|
648e7d2f14 | ||
|
|
289b1c67ce | ||
|
|
52095eb992 | ||
|
|
a2d0bd3c1c | ||
|
|
683a3934fc | ||
|
|
b0514c3e9a | ||
|
|
8be12ca9bc | ||
|
|
afeeb8327c | ||
|
|
4d2da94692 | ||
|
|
b746812e23 | ||
|
|
3fbbf87c10 | ||
|
|
c3810fdb7b | ||
|
|
b16c63225f | ||
|
|
45a45dc0fe | ||
|
|
c315bb350c | ||
|
|
6d398b66bc | ||
|
|
934919c699 | ||
|
|
12d84a22e4 | ||
|
|
dc457aff9e | ||
|
|
578847d596 | ||
|
|
6416c14f20 | ||
|
|
add6e6aeb5 | ||
|
|
7968cef8c3 | ||
|
|
34148b5d8a | ||
|
|
79e408333d | ||
|
|
74a7cd22b0 | ||
|
|
e1cca219b8 | ||
|
|
4419c5bacd | ||
|
|
4eec0dd5a4 | ||
|
|
8e60f50616 | ||
|
|
f85e3be40e | ||
|
|
7f9116f12a | ||
|
|
11f0d2c745 | ||
|
|
955a6439a0 | ||
|
|
172917ac41 | ||
|
|
a5f6898d06 | ||
|
|
83bc90ecf2 | ||
|
|
cbfc29e953 | ||
|
|
b66465aa6a | ||
|
|
fbd1a4dbea | ||
|
|
be8a8492a9 | ||
|
|
b352eea21d | ||
|
|
51bf0aaa8c | ||
|
|
28a752265c | ||
|
|
1d9d138423 | ||
|
|
8c5edc6ad6 | ||
|
|
ee6fb5d33a | ||
|
|
cd2b437be5 | ||
|
|
a504e7a13b | ||
|
|
325687d532 | ||
|
|
9760d1171c | ||
|
|
d42ed57119 | ||
|
|
18208f691c | ||
|
|
c06a4d0e06 | ||
|
|
f21934e55b | ||
|
|
515135c120 | ||
|
|
42bdd59c71 | ||
|
|
8be85fd332 | ||
|
|
e4bf104ae2 | ||
|
|
5105bf7049 | ||
|
|
08997e9e20 | ||
|
|
9146d17625 | ||
|
|
1aca3d6152 | ||
|
|
052bb31aea | ||
|
|
225b525ba1 | ||
|
|
e1543416e3 | ||
|
|
0b57aea5f6 | ||
|
|
a04c2f9a19 | ||
|
|
c352c34501 | ||
|
|
5c9671df44 | ||
|
|
8a8d3c5cd9 | ||
|
|
d272afae1c | ||
|
|
03e697f5d3 | ||
|
|
c502afb588 | ||
|
|
3fada5d198 | ||
|
|
c0fdea6570 | ||
|
|
0391cb223f | ||
|
|
b25111e4b4 | ||
|
|
2f2db4986e | ||
|
|
6f970b3783 | ||
|
|
95d5c82474 | ||
|
|
87507708ce | ||
|
|
c6ae7ca170 | ||
|
|
6894f9cc57 | ||
|
|
524d8763ce | ||
|
|
1e845a1510 | ||
|
|
0341838d19 | ||
|
|
ea35273597 | ||
|
|
72c8167472 | ||
|
|
9b1138c83e | ||
|
|
dc5e23892f | ||
|
|
4f8cbc3e38 | ||
|
|
3ffbb2b004 | ||
|
|
34ed15a9bd | ||
|
|
4b6295dd44 | ||
|
|
01116d5607 | ||
|
|
07904a29aa | ||
|
|
8d683a791d | ||
|
|
ef0e0df0ac | ||
|
|
ac07416352 | ||
|
|
025ca6d4fd | ||
| 951a353881 | |||
|
|
eb04caccf1 | ||
|
|
cd84def61d | ||
|
|
d8098b0378 | ||
|
|
62d30ec190 | ||
|
|
d0d3c6ee67 | ||
|
|
21b6b5b270 | ||
|
|
365050b890 | ||
|
|
73392a9b83 | ||
|
|
c47c03fde4 | ||
|
|
86fd9475e4 | ||
|
|
2d0d1d3461 | ||
|
|
ccaec4a984 | ||
|
|
169ba7edb6 | ||
|
|
0327d1cc51 | ||
|
|
e02ddab599 | ||
|
|
336338cff5 | ||
|
|
acff31e652 | ||
|
|
ad304a6880 | ||
|
|
cb34788e53 | ||
|
|
13b0ffb5d8 | ||
|
|
c9947c4ac8 | ||
|
|
afffb7aad2 | ||
|
|
9720453ac4 | ||
|
|
1ff6d5220e | ||
|
|
46d91fd8ca | ||
|
|
a24db2f73c | ||
|
|
ca9537f862 | ||
|
|
94ae7e68ff | ||
|
|
94e1930538 | ||
|
|
5bbe5a3077 | ||
|
|
8cead01270 | ||
|
|
99fc14b9f9 | ||
|
|
d3671206e1 | ||
|
|
0a8202788b | ||
|
|
855f1affd9 | ||
|
|
5a294e5c85 | ||
|
|
df606a1087 | ||
|
|
ece3552b89 | ||
|
|
4df3945131 | ||
|
|
6eb0f33421 | ||
|
|
25ce9f74b8 | ||
|
|
0f1f471590 | ||
|
|
d9487b6885 | ||
|
|
9845ab8f2a | ||
|
|
6fa130386a | ||
|
|
53df8b9971 | ||
|
|
d6625762cf | ||
|
|
cb3b9e9cfe | ||
|
|
3d39236b13 | ||
|
|
9e57fa5762 | ||
|
|
9c6232bc85 | ||
|
|
d3c7938627 | ||
|
|
06256946fa | ||
|
|
2042d386e2 | ||
|
|
7176754564 | ||
|
|
a20222192e | ||
|
|
a5c2681e88 | ||
|
|
5e2f750359 | ||
|
|
27602890ab | ||
|
|
dfe0d652ce | ||
|
|
24076adf98 | ||
|
|
15a78abc1b | ||
|
|
7fcdf1a761 | ||
|
|
d0ca28ae2d | ||
|
|
32a9494dd6 | ||
|
|
a5da06537e | ||
|
|
21c6f41c69 | ||
|
|
506eb0faef | ||
|
|
485b4dd485 | ||
|
|
4f728107d1 | ||
|
|
01e491e332 | ||
|
|
ed91c2ae7d | ||
|
|
55ac81f0cc | ||
|
|
64e32523cd | ||
|
|
5b636c3dfd | ||
|
|
c5d62c71a3 | ||
|
|
fa384a6ec8 | ||
|
|
24cf47e1e3 | ||
|
|
8e66926b9e | ||
|
|
574b372f42 | ||
|
|
74a5fb9ce1 | ||
|
|
4b83bf76b4 | ||
|
|
82ccd9a8ea | ||
|
|
2dbeec7d6d | ||
|
|
fb32f7bd35 | ||
|
|
63d3e4047c | ||
|
|
7c6d9dd989 | ||
|
|
ce121cf0d0 |
3
app/.env
3
app/.env
@@ -15,6 +15,9 @@ client_name=ShuFuAI
|
||||
java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://121.196.149.225:18080
|
||||
|
||||
# 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致
|
||||
AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,4 +15,9 @@ java_api_base=http://47.111.163.154:18080
|
||||
# java_api_base=http://127.0.0.1:18080
|
||||
# java_api_base=http://47.111.163.154:18080
|
||||
|
||||
# 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致
|
||||
AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes
|
||||
# JWT cookie 名称,默认 aiimage_token;改动需与 Java 端 aiimage.auth.cookie-name 保持一致
|
||||
# AIIMAGE_AUTH_COOKIE_NAME=aiimage_token
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
按功能拆分为蓝图:认证(auth)、主页面(main)、管理员(admin)、图片(image)、品牌(brand)
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import timedelta, datetime
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from flask import Flask
|
||||
@@ -73,12 +72,6 @@ def create_app():
|
||||
supports_credentials=True,
|
||||
resources={r"/api/*": {"origins": cors_origins}, r"/login": {"origins": cors_origins}},
|
||||
)
|
||||
# 生产环境必须通过环境变量注入固定 SECRET_KEY;否则服务重启会使旧会话失效。
|
||||
app.secret_key = os.environ.get('SECRET_KEY', 'dev-secret-key-change-me')
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
|
||||
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE', '0') == '1'
|
||||
|
||||
# 注册蓝图(不设 url_prefix,保持原有 URL 路径不变,前端无需改动)
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
公共模块:数据库连接、初始化、会话校验、装饰器、模板渲染
|
||||
公共模块:数据库连接、初始化、JWT 鉴权装饰器、模板渲染
|
||||
供各蓝图复用
|
||||
"""
|
||||
import os
|
||||
@@ -8,18 +8,19 @@ from datetime import timedelta
|
||||
from functools import wraps
|
||||
|
||||
import pymysql
|
||||
from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string
|
||||
from flask import request, redirect, url_for, jsonify, render_template, render_template_string, g
|
||||
|
||||
from config import mysql_host, mysql_user, mysql_password, mysql_database
|
||||
from jwt_util import parse_token, get_token_from_request
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_DIR = os.path.join(BASE_DIR, 'static')
|
||||
ASSETS_DIR = os.path.join(BASE_DIR, 'assets')
|
||||
WEB_SOURCE_DIR = os.path.join(BASE_DIR, 'web_source')
|
||||
TEMPLATE_FALLBACK_DIRS = (
|
||||
WEB_SOURCE_DIR,
|
||||
os.path.join(WEB_SOURCE_DIR, 'templates_backup'),
|
||||
)
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_DIR = os.path.join(BASE_DIR, 'static')
|
||||
ASSETS_DIR = os.path.join(BASE_DIR, 'assets')
|
||||
WEB_SOURCE_DIR = os.path.join(BASE_DIR, 'web_source')
|
||||
TEMPLATE_FALLBACK_DIRS = (
|
||||
WEB_SOURCE_DIR,
|
||||
os.path.join(WEB_SOURCE_DIR, 'templates_backup'),
|
||||
)
|
||||
|
||||
|
||||
def get_db():
|
||||
@@ -35,13 +36,13 @@ def get_db():
|
||||
|
||||
def _render_html(template_name: str, **context):
|
||||
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
|
||||
path = next(
|
||||
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
|
||||
if os.path.isfile(candidate)),
|
||||
None,
|
||||
)
|
||||
if path is None:
|
||||
return render_template(template_name, **context)
|
||||
path = next(
|
||||
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
|
||||
if os.path.isfile(candidate)),
|
||||
None,
|
||||
)
|
||||
if path is None:
|
||||
return render_template(template_name, **context)
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
@@ -54,13 +55,13 @@ def _render_html(template_name: str, **context):
|
||||
|
||||
def _render_html_new(template_name: str, **context):
|
||||
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
|
||||
path = next(
|
||||
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
|
||||
if os.path.isfile(candidate)),
|
||||
None,
|
||||
)
|
||||
if path is None:
|
||||
return render_template(template_name, **context)
|
||||
path = next(
|
||||
(candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS)
|
||||
if os.path.isfile(candidate)),
|
||||
None,
|
||||
)
|
||||
if path is None:
|
||||
return render_template(template_name, **context)
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
@@ -72,9 +73,36 @@ def _render_html_new(template_name: str, **context):
|
||||
|
||||
|
||||
|
||||
def _resolve_jwt_user():
|
||||
"""从 Authorization/cookie 解析 JWT,命中后写入 flask.g 缓存。无效返回 None。"""
|
||||
cached = getattr(g, "_aiimage_user", None)
|
||||
if cached is not None:
|
||||
return cached or None
|
||||
token = get_token_from_request()
|
||||
payload = parse_token(token) if token else None
|
||||
if not payload:
|
||||
g._aiimage_user = False
|
||||
return None
|
||||
g._aiimage_user = payload
|
||||
g.aiimage_user_id = payload.get("user_id")
|
||||
g.aiimage_username = payload.get("username") or ""
|
||||
g.aiimage_device_id = payload.get("device_id") or ""
|
||||
return payload
|
||||
|
||||
|
||||
def current_user_id():
|
||||
user = _resolve_jwt_user()
|
||||
return user.get("user_id") if user else None
|
||||
|
||||
|
||||
def current_username():
|
||||
user = _resolve_jwt_user()
|
||||
return user.get("username") if user else ""
|
||||
|
||||
|
||||
def _is_session_user_valid():
|
||||
"""校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False"""
|
||||
uid = session.get('user_id')
|
||||
"""兼容旧调用:判断当前 JWT 用户是否仍存在于数据库。"""
|
||||
uid = current_user_id()
|
||||
if not uid:
|
||||
return False
|
||||
try:
|
||||
@@ -83,23 +111,22 @@ def _is_session_user_valid():
|
||||
cur.execute("SELECT id FROM users WHERE id = %s", (uid,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
session.clear()
|
||||
return False
|
||||
return True
|
||||
return bool(row)
|
||||
except Exception:
|
||||
session.clear()
|
||||
return False
|
||||
|
||||
|
||||
def _get_current_admin_role():
|
||||
"""获取当前登录用户的管理角色:super_admin / admin / None(非管理员)"""
|
||||
uid = current_user_id()
|
||||
if not uid:
|
||||
return None, None
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
|
||||
(session['user_id'],)
|
||||
(uid,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
@@ -110,13 +137,19 @@ def _get_current_admin_role():
|
||||
return None, None
|
||||
|
||||
|
||||
def _unauthorized_response():
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \
|
||||
or request.path.startswith('/api/') \
|
||||
or 'application/json' in (request.headers.get('Accept') or ''):
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get('user_id') or not _is_session_user_valid():
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
return redirect(url_for('auth.login'))
|
||||
if not current_user_id():
|
||||
return _unauthorized_response()
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
@@ -124,22 +157,24 @@ def login_required(f):
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not session.get('user_id'):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
return redirect(url_for('auth.login'))
|
||||
uid = current_user_id()
|
||||
if not uid:
|
||||
return _unauthorized_response()
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],))
|
||||
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (uid,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row or not row.get('is_admin'):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \
|
||||
or request.path.startswith('/api/') \
|
||||
or 'application/json' in (request.headers.get('Accept') or ''):
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
return redirect(url_for('main.home'))
|
||||
except Exception as e:
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \
|
||||
or request.path.startswith('/api/'):
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
return redirect(url_for('main.home'))
|
||||
return f(*args, **kwargs)
|
||||
|
||||
1
app/assets/appearance-patent-eAIM0l0f.css
Normal file
1
app/assets/appearance-patent-eAIM0l0f.css
Normal file
File diff suppressed because one or more lines are too long
9
app/assets/appearance-patent.js
Normal file
9
app/assets/appearance-patent.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/brand-D__LhhPM.js
Normal file
1
app/assets/brand-D__LhhPM.js
Normal file
@@ -0,0 +1 @@
|
||||
import{b8 as r}from"./pywebview-3bFwh9Cg.js";const n="";function s(e){return r(`${n}/api/brand/expand-folder-recursive`,{folder:e})}export{s as e};
|
||||
1
app/assets/categorized-timers-JPA-olTr.js
Normal file
1
app/assets/categorized-timers-JPA-olTr.js
Normal file
@@ -0,0 +1 @@
|
||||
const r=new Map;function c(n){let t=r.get(n);return t||(t=new Map,r.set(n,t)),t}function u(n,t){const e=r.get(n);e&&(e.delete(t),e.size||r.delete(n))}function l(n,t,e){const i=window.setTimeout(()=>{u(n,i),t()},e);return c(n).set(i,{id:i,kind:"timeout",category:n}),i}function a(n,t,e){const i=window.setInterval(t,e);return c(n).set(i,{id:i,kind:"interval",category:n}),i}function f(n,t){if(t==null)return;const i=r.get(n)?.get(t);i?.kind==="interval"?window.clearInterval(t):window.clearTimeout(t),i?.cancel?.(),u(n,t)}function s(n){const t=r.get(n);if(t){for(const e of t.values())e.kind==="interval"?window.clearInterval(e.id):window.clearTimeout(e.id),e.cancel?.();r.delete(n)}}function d(n,t){return new Promise(e=>{const i=window.setTimeout(()=>{u(n,i),e()},t);c(n).set(i,{id:i,kind:"timeout",category:n,cancel:e})})}function m(n){const t=`${n}:`;for(const e of Array.from(r.keys()))(e===n||e.startsWith(t))&&s(e)}function w(n){const t=e=>`${n}:${e}`;return{setTimeout(e,i,o){return l(t(e),i,o)},setInterval(e,i,o){return a(t(e),i,o)},clearTimer(e,i){f(t(e),i)},clearCategory(e){s(t(e))},clearScope(){m(n)},sleep(e,i){return d(t(e),i)}}}export{w as c};
|
||||
1
app/assets/convert-BKSNvX8i.css
Normal file
1
app/assets/convert-BKSNvX8i.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/convert.js
Normal file
1
app/assets/convert.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/dedupe-DNlVfFj-.css
Normal file
1
app/assets/dedupe-DNlVfFj-.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/dedupe.js
Normal file
1
app/assets/dedupe.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/delete-brand-BJod9K65.css
Normal file
1
app/assets/delete-brand-BJod9K65.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/delete-brand.js
Normal file
1
app/assets/delete-brand.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/el-input-Cxc-k42P.css
Normal file
1
app/assets/el-input-Cxc-k42P.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/listingFilters-BpGOU_pJ.js
Normal file
1
app/assets/listingFilters-BpGOU_pJ.js
Normal file
@@ -0,0 +1 @@
|
||||
const e=[{value:"SearchSuppressed",label:"在搜索结果中禁止显示"},{value:"ApprovalRequired",label:"需要批准"},{value:"Active",label:"在售"},{value:"DetailPageRemoved",label:"详情页面已删除"}];export{e as L};
|
||||
1
app/assets/listingFilters-CK58rX4v.css
Normal file
1
app/assets/listingFilters-CK58rX4v.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/patrol-delete-CBBdonF4.css
Normal file
1
app/assets/patrol-delete-CBBdonF4.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/patrol-delete.js
Normal file
1
app/assets/patrol-delete.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/price-track-Hozgt_em.css
Normal file
1
app/assets/price-track-Hozgt_em.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/price-track.js
Normal file
1
app/assets/price-track.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/product-risk-DV5AzjVc.css
Normal file
1
app/assets/product-risk-DV5AzjVc.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/product-risk.js
Normal file
1
app/assets/product-risk.js
Normal file
File diff suppressed because one or more lines are too long
55
app/assets/pywebview-3bFwh9Cg.js
Normal file
55
app/assets/pywebview-3bFwh9Cg.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/pywebview-B7UKcPVJ.css
Normal file
1
app/assets/pywebview-B7UKcPVJ.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/query-asin-CACA04ev.css
Normal file
1
app/assets/query-asin-CACA04ev.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/query-asin.js
Normal file
1
app/assets/query-asin.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/shop-match-BPVfWxWj.css
Normal file
1
app/assets/shop-match-BPVfWxWj.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/shop-match.js
Normal file
1
app/assets/shop-match.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/similar-asin-B5CvMmwV.css
Normal file
1
app/assets/similar-asin-B5CvMmwV.css
Normal file
@@ -0,0 +1 @@
|
||||
.module-page[data-v-49f8fcb3]{min-height:100vh;background:#1a1a1a}.main-content[data-v-49f8fcb3]{display:flex;height:calc(100vh - 56px);min-height:calc(100vh - 56px)}.left-panel[data-v-49f8fcb3]{width:400px;background:#1e1e1e;padding:20px;overflow-y:auto;border-right:1px solid #2a2a2a}.right-panel[data-v-49f8fcb3]{flex:1;min-width:0;background:#1a1a1a;display:flex;flex-direction:column}.section-title[data-v-49f8fcb3],.subsection-title[data-v-49f8fcb3]{font-size:13px;color:#bbb;margin-bottom:10px}.upload-zone[data-v-49f8fcb3]{border:1px dashed #3a3a3a;border-radius:10px;padding:18px;background:#252525;margin-bottom:18px}.hint[data-v-49f8fcb3],.loading-msg[data-v-49f8fcb3],.files[data-v-49f8fcb3],.muted[data-v-49f8fcb3]{color:#888;font-size:12px;line-height:1.5}.link[data-v-49f8fcb3]{color:#6ea8fe;text-decoration:none}.link[data-v-49f8fcb3]:hover{color:#9fc5ff}.btns[data-v-49f8fcb3],.run-row[data-v-49f8fcb3]{display:flex;gap:10px;flex-wrap:wrap}.opt-btn[data-v-49f8fcb3],.btn-run[data-v-49f8fcb3],.btn-delete[data-v-49f8fcb3],.download[data-v-49f8fcb3]{border:none;cursor:pointer;border-radius:7px}.opt-btn[data-v-49f8fcb3]{padding:8px 14px;color:#ccc;background:#2a2a2a;border:1px solid #3a3a3a}.btn-run[data-v-49f8fcb3]{padding:10px 18px;color:#fff;background:#3498db;font-weight:600}.btn-queue[data-v-49f8fcb3]{background:#27ae60}.btn-run[data-v-49f8fcb3]:disabled{opacity:.55;cursor:not-allowed}.selected-files[data-v-49f8fcb3]{margin-top:14px;color:#999;font-size:12px;word-break:break-all}.selected-files span[data-v-49f8fcb3]{display:block;margin:4px 0}.parse-card[data-v-49f8fcb3],.queue-payload[data-v-49f8fcb3]{margin-top:14px;padding:12px;border:1px solid #2a2a2a;border-radius:8px;background:#202020;color:#b8c1cc;font-size:12px}.queue-payload[data-v-49f8fcb3]{max-height:220px;overflow:auto;color:#8fd3ff;white-space:pre-wrap}.panel-header[data-v-49f8fcb3]{padding:16px 20px;border-bottom:1px solid #2a2a2a;font-size:15px;font-weight:600;color:#ddd}.task-list-wrap[data-v-49f8fcb3]{flex:1;padding:16px 20px;overflow:auto}.clean-result-summary[data-v-49f8fcb3]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-49f8fcb3]{padding:14px 16px;border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e}.summary-card strong[data-v-49f8fcb3]{display:block;margin-top:8px;color:#eaf4ff;font-size:22px}.summary-label[data-v-49f8fcb3]{color:#8d8d8d;font-size:12px}.result-list-wrap[data-v-49f8fcb3]{border:1px solid #2a2a2a;border-radius:8px;background:#1e1e1e;min-height:180px;margin:0 0 16px}.result-list-header[data-v-49f8fcb3]{display:flex;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:14px}.empty-tasks[data-v-49f8fcb3]{color:#666;font-size:13px;padding:18px;text-align:center}.task-list[data-v-49f8fcb3]{list-style:none;margin:0;padding:12px}.task-item[data-v-49f8fcb3]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:12px 14px;border:1px solid #2a2a2a;border-radius:8px;margin-bottom:8px;background:#222}.left[data-v-49f8fcb3]{flex:1;min-width:0}.id[data-v-49f8fcb3]{color:#e0e0e0;font-size:13px;font-weight:600}.task-right[data-v-49f8fcb3]{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.status[data-v-49f8fcb3]{padding:4px 10px;border-radius:6px;font-size:12px}.status.success[data-v-49f8fcb3]{background:#2ecc712e;color:#2ecc71}.status.failed[data-v-49f8fcb3]{background:#e74c3c2e;color:#ff6b6b}.status.running[data-v-49f8fcb3]{background:#3498db2e;color:#3498db}.status.pending[data-v-49f8fcb3]{background:#95a5a62e;color:#bdc3c7}.result-hint[data-v-49f8fcb3]{margin-top:6px;color:#e0b96d}.file-progress[data-v-49f8fcb3]{margin-top:8px;max-width:520px}.file-progress-meta[data-v-49f8fcb3]{display:flex;justify-content:space-between;gap:12px;color:#d8c278;font-size:12px}.file-progress-track[data-v-49f8fcb3]{margin-top:5px;height:8px;border-radius:999px;overflow:hidden;background:#303030;border:1px solid #3b3b3b}.file-progress-bar[data-v-49f8fcb3]{height:100%;border-radius:inherit;background:linear-gradient(90deg,#4aa3ff,#f0c75e);transition:width .25s ease}.file-progress-count[data-v-49f8fcb3]{margin-top:4px;color:#858585;font-size:11px}.download[data-v-49f8fcb3]{padding:6px 10px;color:#d6ecff;background:#3498db2e}.btn-delete[data-v-49f8fcb3]{padding:6px 10px;color:#ff8f8f;background:#e74c3c1f}@media(max-width:1100px){.main-content[data-v-49f8fcb3]{flex-direction:column;height:auto}.left-panel[data-v-49f8fcb3]{width:100%;border-right:none;border-bottom:1px solid #2a2a2a}.clean-result-summary[data-v-49f8fcb3]{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
1
app/assets/similar-asin.js
Normal file
1
app/assets/similar-asin.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/split-BoBVrLdC.css
Normal file
1
app/assets/split-BoBVrLdC.css
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/split.js
Normal file
1
app/assets/split.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/zh-cn-CfXLYURP.js
Normal file
1
app/assets/zh-cn-CfXLYURP.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,379 +1,17 @@
|
||||
"""
|
||||
管理员蓝图:用户管理(列表/创建/更新/删除)、生成历史、管理页
|
||||
"""
|
||||
import json
|
||||
import pymysql
|
||||
from flask import Blueprint, request, jsonify
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required
|
||||
from flask import session
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
def _parse_json(val, default=None):
|
||||
if val is None:
|
||||
return default if default is not None else []
|
||||
if isinstance(val, (list, dict)):
|
||||
return val
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return default if default is not None else []
|
||||
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_page():
|
||||
return _render_html('admin.html')
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users')
|
||||
@admin_required
|
||||
def admin_list_users():
|
||||
"""分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选"""
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
|
||||
offset = (page - 1) * page_size
|
||||
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
|
||||
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
|
||||
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
|
||||
if role != 'super_admin':
|
||||
created_by_id = None
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
if role == 'super_admin':
|
||||
where_parts = ["1=1"]
|
||||
params = []
|
||||
if search_username:
|
||||
where_parts.append("u.username LIKE %s")
|
||||
params.append("%" + search_username + "%")
|
||||
if created_by_id is not None:
|
||||
where_parts.append("u.created_by_id = %s")
|
||||
params.append(created_by_id)
|
||||
where_sql = " AND ".join(where_parts)
|
||||
cur.execute(
|
||||
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
|
||||
creator.username AS creator_username
|
||||
FROM users u
|
||||
LEFT JOIN users creator ON creator.id = u.created_by_id
|
||||
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
|
||||
tuple(params) + (page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params))
|
||||
total = cur.fetchone()['total']
|
||||
cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id")
|
||||
admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()]
|
||||
else:
|
||||
admin_id = current_row['id']
|
||||
where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"]
|
||||
params = [admin_id, admin_id]
|
||||
if search_username:
|
||||
where_parts.append("u.username LIKE %s")
|
||||
params.append("%" + search_username + "%")
|
||||
where_sql = " AND ".join(where_parts)
|
||||
cur.execute(
|
||||
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
|
||||
creator.username AS creator_username
|
||||
FROM users u
|
||||
LEFT JOIN users creator ON creator.id = u.created_by_id
|
||||
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
|
||||
tuple(params) + (page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as total FROM users u WHERE " + where_sql,
|
||||
tuple(params),
|
||||
)
|
||||
total = cur.fetchone()['total']
|
||||
admins = []
|
||||
items = [
|
||||
{
|
||||
'id': r['id'],
|
||||
'username': r['username'],
|
||||
'is_admin': bool(r.get('is_admin')),
|
||||
'role': r.get('role') or 'normal',
|
||||
'created_by_id': r.get('created_by_id'),
|
||||
'creator_username': r.get('creator_username') or '',
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
conn.close()
|
||||
payload = {
|
||||
'success': True,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'current_user_role': role,
|
||||
'admins': admins,
|
||||
}
|
||||
return jsonify(payload)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_create_user():
|
||||
data = request.get_json() or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
want_role = (data.get('role') or 'normal').strip() or 'normal'
|
||||
if want_role not in ('admin', 'normal'):
|
||||
want_role = 'normal'
|
||||
if role == 'admin' and want_role == 'admin':
|
||||
return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'})
|
||||
if want_role == 'admin':
|
||||
want_created_by = current_row['id']
|
||||
elif role == 'super_admin':
|
||||
want_created_by = data.get('created_by_id')
|
||||
else:
|
||||
want_created_by = current_row['id']
|
||||
if not username or not password:
|
||||
return jsonify({'success': False, 'error': '用户名和密码不能为空'})
|
||||
if len(username) < 2:
|
||||
return jsonify({'success': False, 'error': '用户名至少2个字符'})
|
||||
if len(password) < 6:
|
||||
return jsonify({'success': False, 'error': '密码至少6个字符'})
|
||||
if want_role == 'normal' and role == 'super_admin' and want_created_by is None:
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1")
|
||||
r = cur.fetchone()
|
||||
conn.close()
|
||||
want_created_by = r['id'] if r else current_row['id']
|
||||
except Exception:
|
||||
want_created_by = current_row['id']
|
||||
if want_role == 'normal' and want_created_by is None:
|
||||
want_created_by = current_row['id']
|
||||
is_admin = 1 if want_role in ('super_admin', 'admin') else 0
|
||||
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)",
|
||||
(username, pwd_hash, is_admin, want_role, want_created_by),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'msg': '用户创建成功'})
|
||||
except pymysql.IntegrityError:
|
||||
return jsonify({'success': False, 'error': '用户名已存在'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>', methods=['PUT'])
|
||||
@admin_required
|
||||
def admin_update_user(uid):
|
||||
data = request.get_json() or {}
|
||||
password = data.get('password')
|
||||
want_role = (data.get('role') or '').strip() or data.get('role')
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
if want_role is None and not password:
|
||||
return jsonify({'success': False, 'error': '请提供要修改的内容'})
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
|
||||
target = cur.fetchone()
|
||||
if not target:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
if role == 'admin':
|
||||
if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403
|
||||
want_role = None
|
||||
else:
|
||||
if target.get('role') == 'super_admin':
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '不能修改超级管理员'})
|
||||
if want_role == 'super_admin':
|
||||
return jsonify({'success': False, 'error': '不能将用户设为超级管理员'})
|
||||
if want_role not in ('admin', 'normal', None, ''):
|
||||
want_role = None
|
||||
if password:
|
||||
if len(password) < 6:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '密码至少6个字符'})
|
||||
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid))
|
||||
if want_role is not None and want_role != '':
|
||||
is_admin = 1 if want_role == 'admin' else 0
|
||||
cur.execute(
|
||||
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
|
||||
(is_admin, want_role, uid),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'msg': '更新成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def admin_delete_user(uid):
|
||||
from flask import session
|
||||
if session.get('user_id') == uid:
|
||||
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
|
||||
target = cur.fetchone()
|
||||
if not target:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
if target.get('role') == 'super_admin':
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '不能删除超级管理员'})
|
||||
if role == 'admin':
|
||||
if target.get('role') != 'normal' or target.get('created_by_id') != current_row['id']:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '只能删除自己创建的普通用户'}), 403
|
||||
cur.execute("DELETE FROM users WHERE id = %s", (uid,))
|
||||
affected = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if affected == 0:
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
return jsonify({'success': True, 'msg': '删除成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>/column-permissions')
|
||||
@login_required
|
||||
def admin_user_column_permissions(uid):
|
||||
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
|
||||
current_uid = session.get('user_id')
|
||||
menu_type = (request.args.get('menu_type') or '').strip().lower()
|
||||
if current_uid != uid:
|
||||
role, _ = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
|
||||
user_row = cur.fetchone()
|
||||
valid_menu_type = menu_type if menu_type in ('app', 'admin') else ''
|
||||
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
|
||||
sql = """
|
||||
SELECT id, name, column_key, menu_type, route_path, sort_order, created_at
|
||||
FROM columns
|
||||
"""
|
||||
params = []
|
||||
if valid_menu_type:
|
||||
sql += " WHERE menu_type = %s"
|
||||
params.append(valid_menu_type)
|
||||
sql += " ORDER BY sort_order ASC, id ASC"
|
||||
cur.execute(sql, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
else:
|
||||
sql = """
|
||||
SELECT c.id, c.name, c.column_key, c.menu_type, c.route_path, c.sort_order, c.created_at
|
||||
FROM columns c
|
||||
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
|
||||
WHERE ucp.user_id = %s
|
||||
"""
|
||||
params = [uid]
|
||||
if valid_menu_type:
|
||||
sql += " AND c.menu_type = %s"
|
||||
params.append(valid_menu_type)
|
||||
sql += " ORDER BY c.sort_order ASC, c.id ASC"
|
||||
cur.execute(sql, tuple(params))
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
items = [
|
||||
{
|
||||
'id': r['id'],
|
||||
'name': r['name'],
|
||||
'column_key': r['column_key'],
|
||||
'menu_type': r.get('menu_type') or 'app',
|
||||
'route_path': r.get('route_path') or '',
|
||||
'sort_order': r.get('sort_order') or 0,
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return jsonify({'success': True, 'items': items})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/history')
|
||||
@admin_required
|
||||
def admin_history():
|
||||
"""管理员分页获取所有生成记录,支持按用户和时间筛选"""
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
|
||||
offset = (page - 1) * page_size
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
time_start = (request.args.get('time_start') or '').strip()
|
||||
time_end = (request.args.get('time_end') or '').strip()
|
||||
conditions, params = [], []
|
||||
if user_id:
|
||||
conditions.append("h.user_id = %s")
|
||||
params.append(user_id)
|
||||
if time_start:
|
||||
conditions.append("h.created_at >= %s")
|
||||
params.append(time_start)
|
||||
if time_end:
|
||||
conditions.append("h.created_at <= %s")
|
||||
params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end)
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
params_count = params[:]
|
||||
params.extend([page_size, offset])
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls,
|
||||
h.long_image_url, u.username
|
||||
FROM image_history h
|
||||
LEFT JOIN users u ON h.user_id = u.id
|
||||
WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count)
|
||||
total = cur.fetchone()['total']
|
||||
conn.close()
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
'id': r['id'],
|
||||
'user_id': r['user_id'],
|
||||
'username': r.get('username') or '-',
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
|
||||
'panel_type': r['panel_type'] or '',
|
||||
'original_urls': _parse_json(r['original_urls'], []),
|
||||
'params': _parse_json(r['params'], {}),
|
||||
'result_urls': _parse_json(r['result_urls'], []),
|
||||
'long_image_url': (r.get('long_image_url') or '').strip() or None,
|
||||
})
|
||||
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
"""
|
||||
管理员蓝图:仅保留 /admin 页面渲染。
|
||||
用户管理、栏目权限、生成历史等接口已全部迁移至 Java 端。
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
from app_common import _render_html, admin_required, login_required
|
||||
from config import JAVA_API_BASE
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_page():
|
||||
return _render_html('admin.html', java_api_base=JAVA_API_BASE)
|
||||
|
||||
@@ -1,107 +1,55 @@
|
||||
"""
|
||||
认证蓝图:登录、登出、登录状态校验
|
||||
"""
|
||||
from flask import Blueprint, request, redirect, url_for, session, jsonify
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from app_common import (
|
||||
get_db,
|
||||
_render_html,
|
||||
_is_session_user_valid,
|
||||
login_required,
|
||||
BASE_DIR,
|
||||
)
|
||||
from tool.devices import DeviceIDGenerator
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if session.get('user_id') and _is_session_user_valid():
|
||||
return redirect(url_for('main.home'))
|
||||
if request.method == 'POST':
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
if not username or not password:
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': '请输入用户名和密码'})
|
||||
return _render_html('login.html', error='请输入用户名和密码')
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s",
|
||||
(username,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and check_password_hash(row['password_hash'], password):
|
||||
current_machine = DeviceIDGenerator().get_device_id()
|
||||
stored_machine = (row.get('machine') or '').strip()
|
||||
if not stored_machine:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id']))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
session.permanent = True
|
||||
session['user_id'] = row['id']
|
||||
session['username'] = username
|
||||
if request.is_json:
|
||||
return jsonify({'success': True, 'redirect': url_for('main.home')})
|
||||
return redirect(url_for('main.home'))
|
||||
print("验证设备",stored_machine)
|
||||
print("当前设备",current_machine)
|
||||
if stored_machine != current_machine and row.get("is_admin") != 1:
|
||||
conn.close()
|
||||
err_msg = '当前设备与首次登录设备不一致,请在原设备上登录'
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': err_msg})
|
||||
return _render_html('login.html', error=err_msg)
|
||||
conn.close()
|
||||
session.permanent = True
|
||||
session['user_id'] = row['id']
|
||||
session['username'] = username
|
||||
if request.is_json:
|
||||
return jsonify({'success': True, 'redirect': url_for('main.home')})
|
||||
return redirect(url_for('main.home'))
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
return _render_html('login.html', error='登录失败,请稍后重试')
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': '用户名或密码错误'})
|
||||
return _render_html('login.html', error='用户名或密码错误')
|
||||
return _render_html('login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/check')
|
||||
@login_required
|
||||
def api_auth_check():
|
||||
"""校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致"""
|
||||
if not session.get('user_id'):
|
||||
return jsonify({'logged_in': False})
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return jsonify({'logged_in': False})
|
||||
stored_machine = (row.get('machine') or '').strip()
|
||||
if stored_machine:
|
||||
current_machine = DeviceIDGenerator().get_device_id()
|
||||
if stored_machine != current_machine and row.get("is_admin") != 1:
|
||||
session.clear()
|
||||
return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'})
|
||||
except Exception:
|
||||
return jsonify({'logged_in': False})
|
||||
return jsonify({'logged_in': True, 'redirect': url_for('main.home')})
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for('auth.login'))
|
||||
"""
|
||||
认证蓝图:登录页(仅 GET 渲染模板)+ 登出(清 JWT cookie)+ token 同步
|
||||
登录表单提交已经直连 Java 后端 /login,Python 这边只负责:
|
||||
1. 渲染登录页模板
|
||||
2. 把 Java 签发的 JWT 从前端写到 Python 同源 cookie,供后续页面跳转携带
|
||||
3. 登出:清 cookie 跳回登录页
|
||||
"""
|
||||
import sys
|
||||
|
||||
from flask import Blueprint, redirect, url_for, make_response, request, jsonify
|
||||
|
||||
from app_common import _render_html, current_user_id
|
||||
from config import JAVA_API_BASE
|
||||
from jwt_util import COOKIE_NAME, parse_token_with_reason
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET'])
|
||||
def login():
|
||||
if current_user_id():
|
||||
return redirect(url_for('main.home'))
|
||||
return _render_html('login.html', java_api_base=JAVA_API_BASE)
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/sync', methods=['POST'])
|
||||
def api_auth_sync():
|
||||
"""前端拿到 Java 返回的 JWT 后调用,把 token 写进 Python 同源 cookie。"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
token = (data.get('token') or '').strip()
|
||||
if not token:
|
||||
return jsonify({'success': False, 'error': '缺少 token'}), 400
|
||||
payload, reason = parse_token_with_reason(token)
|
||||
if not payload:
|
||||
# 把根因打到服务端日志,并回传给前端,便于现场排查
|
||||
print(f"[auth] /api/auth/sync 校验失败: {reason}", file=sys.stderr)
|
||||
return jsonify({'success': False, 'error': f'token 无效: {reason}'}), 401
|
||||
resp = make_response(jsonify({'success': True}))
|
||||
resp.set_cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
max_age=7 * 24 * 3600,
|
||||
path='/',
|
||||
httponly=True,
|
||||
samesite='Lax',
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
resp = make_response(redirect(url_for('auth.login')))
|
||||
resp.delete_cookie(COOKIE_NAME, path='/')
|
||||
return resp
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ from queue import Queue, Empty
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
|
||||
from flask import Blueprint, request, jsonify, send_file, redirect, Response
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from app_common import get_db, login_required, current_user_id, BASE_DIR
|
||||
from config import bucket_path,JAVA_API_BASE
|
||||
from brand_spider.main import single_file_handle, TaskCancelledError
|
||||
|
||||
@@ -70,12 +70,12 @@ def _get_uid_from_request_headers():
|
||||
|
||||
def _resolve_user_id():
|
||||
"""
|
||||
优先使用请求头 uid;没有请求头 uid 时回退到 session['user_id']。
|
||||
优先使用请求头 uid;没有请求头 uid 时回退到 JWT 解析的当前用户。
|
||||
若二者同时存在但不一致,则视为无效请求并返回 None。
|
||||
"""
|
||||
req_uid = _get_uid_from_request_headers()
|
||||
if req_uid is not None:
|
||||
sess_uid = session.get('user_id')
|
||||
sess_uid = current_user_id()
|
||||
if sess_uid is not None:
|
||||
try:
|
||||
if int(sess_uid) != int(req_uid):
|
||||
@@ -84,7 +84,7 @@ def _resolve_user_id():
|
||||
if str(sess_uid) != str(req_uid):
|
||||
return None
|
||||
return req_uid
|
||||
return session.get('user_id')
|
||||
return current_user_id()
|
||||
|
||||
|
||||
def _get_user_id_or_error():
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
"""
|
||||
品牌爬虫蓝图:展开文件夹、运行任务、任务列表/详情、下载结果
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import traceback
|
||||
import zipfile
|
||||
import io
|
||||
import time
|
||||
import requests
|
||||
from queue import Queue, Empty
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from config import bucket_path,JAVA_API_BASE
|
||||
from brand_spider.main import single_file_handle, TaskCancelledError
|
||||
|
||||
brand_bp = Blueprint('brand', __name__)
|
||||
BRAND_OUTPUT_DIR = os.path.join(BASE_DIR, 'brand_output')
|
||||
OSS_PREFIX = "brand_results"
|
||||
|
||||
# 任务完成时推送给前端的 SSE 队列:task_id -> [Queue, ...]
|
||||
_task_event_queues = {}
|
||||
_task_event_lock = threading.Lock()
|
||||
|
||||
# 任务行级进度(当前处理文件的行数/总行数),仅存内存,不落库:
|
||||
# task_id -> {
|
||||
# 'file_index': int, # 当前处理第几个文件
|
||||
# 'file_total': int, # 总文件数
|
||||
# 'file_path': str, # 当前文件路径
|
||||
# 'current_line': int, # 当前行号(或当前品牌序号)
|
||||
# 'total_lines': int, # 总行数(或总品牌数)
|
||||
# }
|
||||
_task_line_progress = {}
|
||||
_task_line_progress_lock = threading.Lock()
|
||||
_cancelled_brand_tasks = set()
|
||||
_cancelled_brand_tasks_lock = threading.Lock()
|
||||
|
||||
|
||||
def _mark_task_cancelled(task_id):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
_cancelled_brand_tasks.add(task_id)
|
||||
|
||||
|
||||
def _is_task_cancelled(task_id):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
return task_id in _cancelled_brand_tasks
|
||||
|
||||
|
||||
def _get_uid_from_request_headers():
|
||||
"""
|
||||
从请求头读取 uid。
|
||||
前端会在 headers 里携带 uid,用于确定本次请求的归属用户。
|
||||
"""
|
||||
uid = request.headers.get('uid')
|
||||
if uid is None:
|
||||
return None
|
||||
uid = str(uid).strip()
|
||||
if not uid:
|
||||
return None
|
||||
try:
|
||||
return int(uid)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_user_id():
|
||||
"""
|
||||
优先使用请求头 uid;没有请求头 uid 时回退到 session['user_id']。
|
||||
若二者同时存在但不一致,则视为无效请求并返回 None。
|
||||
"""
|
||||
req_uid = _get_uid_from_request_headers()
|
||||
if req_uid is not None:
|
||||
sess_uid = session.get('user_id')
|
||||
if sess_uid is not None:
|
||||
try:
|
||||
if int(sess_uid) != int(req_uid):
|
||||
return None
|
||||
except Exception:
|
||||
if str(sess_uid) != str(req_uid):
|
||||
return None
|
||||
return req_uid
|
||||
return session.get('user_id')
|
||||
|
||||
|
||||
def _get_user_id_or_error():
|
||||
user_id = _resolve_user_id()
|
||||
if not user_id:
|
||||
return None, (jsonify({'success': False, 'error': '未登录或缺少uid'}), 401)
|
||||
try:
|
||||
return int(user_id), None
|
||||
except Exception:
|
||||
return None, (jsonify({'success': False, 'error': 'uid无效'}), 400)
|
||||
|
||||
|
||||
def _push_task_event(task_id, event):
|
||||
"""向订阅了该任务的所有 SSE 连接推送事件,并移除该任务的队列列表。
|
||||
同时清理内存中的行级进度,不通过数据库中转行级进度。"""
|
||||
with _task_event_lock:
|
||||
queues = _task_event_queues.pop(task_id, [])
|
||||
for q in queues:
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
# 任务进入终态时,顺便清理行级进度缓存
|
||||
with _task_line_progress_lock:
|
||||
_task_line_progress.pop(task_id, None)
|
||||
status = (event or {}).get('status') if isinstance(event, dict) else None
|
||||
if status in ('success', 'failed', 'cancelled'):
|
||||
with _cancelled_brand_tasks_lock:
|
||||
_cancelled_brand_tasks.discard(task_id)
|
||||
|
||||
|
||||
def _expand_folder_xlsx(folder_path):
|
||||
"""返回文件夹下所有 .xlsx 文件的绝对路径列表"""
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
return []
|
||||
paths = []
|
||||
for name in os.listdir(folder_path):
|
||||
if name.endswith('.xlsx') or name.endswith('.XLSX'):
|
||||
paths.append(os.path.normpath(os.path.join(folder_path, name)))
|
||||
return paths
|
||||
|
||||
|
||||
def _upload_local_xlsx_to_oss(local_path, user_id):
|
||||
"""将本地 xlsx 文件上传到 OSS,返回 (url, None) 或 (None, error_message)。"""
|
||||
if not local_path or not os.path.isfile(local_path):
|
||||
return None, "文件不存在"
|
||||
try:
|
||||
from ali_oss import upload_file as oss_upload_file
|
||||
except ImportError:
|
||||
return None, "OSS 模块未配置"
|
||||
base = os.path.basename(local_path)
|
||||
safe_base = "".join(c if c.isalnum() or c in '-_.' else '_' for c in base)
|
||||
if not safe_base.endswith('.xlsx'):
|
||||
safe_base = safe_base + '.xlsx'
|
||||
key = f"{bucket_path}brand_input/{user_id}/{int(time.time() * 1000)}/{safe_base}"
|
||||
try:
|
||||
with open(local_path, "rb") as f:
|
||||
content = f.read()
|
||||
url = oss_upload_file(content, key)
|
||||
return url, None
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def _run_brand_single(taskid,brand_ls, fileUrl,strategy,totalLines,chunkTotal,chunkIndex):
|
||||
""""""
|
||||
invalidBrands = [] # 不符合品牌的数据
|
||||
queryFailedBrands = [] # 查询失败的数据
|
||||
keptRows = []
|
||||
for brand in brand_ls:
|
||||
if _is_task_cancelled(taskid):
|
||||
raise TaskCancelledError("任务已取消")
|
||||
faild_data,query_faild_data = single_file_handle(brand,strategy)
|
||||
invalidBrands.extend(faild_data)
|
||||
queryFailedBrands.extend(query_faild_data)
|
||||
if len(faild_data) == 0 and len(query_faild_data) == 0:
|
||||
keptRows.append(brand)
|
||||
|
||||
data = { "strategy": strategy ,
|
||||
"files": [
|
||||
{
|
||||
"fileUrl": fileUrl,
|
||||
"originalFilename": "",
|
||||
"relativePath": "",
|
||||
"mainSheetName": "",
|
||||
"chunkIndex": chunkIndex,
|
||||
"chunkTotal": chunkTotal,
|
||||
"totalLines": totalLines,
|
||||
"keptRows": keptRows,
|
||||
"invalidBrands": invalidBrands,
|
||||
"queryFailedBrands": queryFailedBrands
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks/{taskid}/result",headers={"accept":"application/json"},
|
||||
# json={ "strategy": strategy ,
|
||||
# "files": [
|
||||
# { "fileUrl": fileUrl,"originalFilename": "","relativePath": "",
|
||||
# "mainSheetName": "","columns": [],"keptRows": [],
|
||||
# "invalidBrands": invalidBrands,"queryFailedBrands": queryFailedBrands
|
||||
# }]})
|
||||
json=data)
|
||||
# print(data)
|
||||
# print(taskid,brand_ls, fileUrl)
|
||||
print("提交结果",data,"\n-->",resp.text)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _background_brand_task(task_id,data):
|
||||
"""
|
||||
后台执行爬虫任务
|
||||
参数示例:
|
||||
data : {
|
||||
"taskId": 348,
|
||||
"strategy": "Terms",
|
||||
"files": [
|
||||
{
|
||||
"fileIndex": 1,
|
||||
"fileUrl": "ab8cce4878754bfd89b4cd3ed20e1395",
|
||||
"originalFilename": "品牌样例.xlsx",
|
||||
"relativePath": "店铺A/品牌样例.xlsx",
|
||||
"sheetName": "Sheet1",
|
||||
"columns": [
|
||||
"品牌",
|
||||
"ASIH",
|
||||
"状态",
|
||||
"时间"
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"品牌": "YQAUTEC",
|
||||
"ASIH": "B0F28NZ752",
|
||||
"状态": "",
|
||||
"时间": "",
|
||||
"__rowIndex": 2
|
||||
}
|
||||
],
|
||||
"uniqueBrands": [
|
||||
"YQAUTEC"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
file_ls = data.get("files")
|
||||
strategy = data.get("strategy")
|
||||
if not isinstance(file_ls, list):
|
||||
file_ls = []
|
||||
tasks = []
|
||||
for file_data in file_ls:
|
||||
rows = file_data.get("rows") or []
|
||||
brand_ls = [i.get("品牌") for i in rows if i.get("品牌")]
|
||||
fileUrl = file_data.get("fileUrl")
|
||||
if not brand_ls:
|
||||
continue
|
||||
for i in range(0, len(brand_ls), 5):
|
||||
chunk = brand_ls[i:i + 5]
|
||||
tasks.append((chunk, fileUrl))
|
||||
|
||||
if tasks:
|
||||
max_workers = min(8, len(tasks))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
if _is_task_cancelled(task_id):
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return
|
||||
futures = [
|
||||
executor.submit(_run_brand_single, task_id, chunk, file_url, strategy,len(brand_ls),len(tasks),chunkIndex+1)
|
||||
for chunkIndex,(chunk, file_url) in enumerate(tasks)
|
||||
]
|
||||
for future in as_completed(futures):
|
||||
if _is_task_cancelled(task_id):
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return
|
||||
future.result()
|
||||
_push_task_event(task_id, {'status': 'success'})
|
||||
except Exception as e:
|
||||
print("执行出错",traceback.format_exc())
|
||||
_push_task_event(task_id, {'status': 'failed', 'error_message': str(e)})
|
||||
print("执行完成")
|
||||
|
||||
|
||||
def _parse_json(val, default=None):
|
||||
if val is None:
|
||||
return default if default is not None else []
|
||||
if isinstance(val, (list, dict)):
|
||||
return val
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return default if default is not None else []
|
||||
|
||||
|
||||
def _is_url(s):
|
||||
if not isinstance(s, str) or not s.strip():
|
||||
return False
|
||||
return s.strip().startswith('http://') or s.strip().startswith('https://')
|
||||
|
||||
|
||||
def _normalize_result_paths(raw):
|
||||
"""
|
||||
将 result_paths 规范化为 (url_list, zip_url)。
|
||||
支持旧格式 list 或新格式 dict {"urls": [...], "zip_url": "..."}。
|
||||
"""
|
||||
if not raw:
|
||||
return [], None
|
||||
if isinstance(raw, dict):
|
||||
urls = raw.get('urls') or []
|
||||
if not isinstance(urls, list):
|
||||
urls = []
|
||||
zip_url = raw.get('zip_url')
|
||||
if zip_url and not isinstance(zip_url, str):
|
||||
zip_url = None
|
||||
return urls, zip_url
|
||||
if isinstance(raw, list):
|
||||
return raw, None
|
||||
return [], None
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/expand-folder', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_expand_folder():
|
||||
"""展开文件夹,返回其下所有 .xlsx 文件路径列表"""
|
||||
try:
|
||||
_, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
|
||||
paths = _expand_folder_xlsx(folder)
|
||||
return jsonify({'success': True, 'paths': paths})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def _expand_folder_xlsx_recursive_items(folder_path):
|
||||
"""返回文件夹下所有 .xlsx 文件的绝对路径和相对路径列表(递归)"""
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
return []
|
||||
items = []
|
||||
root = os.path.normpath(folder_path)
|
||||
for current_root, _, filenames in os.walk(root):
|
||||
for name in filenames:
|
||||
if not (name.endswith('.xlsx') or name.endswith('.XLSX')):
|
||||
continue
|
||||
absolute_path = os.path.normpath(os.path.join(current_root, name))
|
||||
relative_path = os.path.relpath(absolute_path, root).replace('\\', '/')
|
||||
items.append({
|
||||
'absolutePath': absolute_path,
|
||||
'relativePath': relative_path,
|
||||
})
|
||||
items.sort(key=lambda item: item['relativePath'])
|
||||
return items
|
||||
|
||||
@brand_bp.route('/api/brand/expand-folder-recursive', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_expand_folder_recursive():
|
||||
"""递归展开文件夹,返回 .xlsx 文件绝对路径及相对路径列表"""
|
||||
try:
|
||||
_, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
|
||||
items = _expand_folder_xlsx_recursive_items(folder)
|
||||
return jsonify({'success': True, 'items': items})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/run', methods=['POST'])
|
||||
# @login_required
|
||||
def api_brand_run():
|
||||
"""立即运行:创建任务并后台执行,立即返回 task_id,前端可轮询进度与取消"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
paths = data.get('paths') or []
|
||||
# task_type: 1=立即执行,2=添加任务;此接口默认 1
|
||||
try:
|
||||
task_type = int(data.get('task_type') or 1)
|
||||
except Exception:
|
||||
task_type = 1
|
||||
if task_type not in (1, 2):
|
||||
task_type = 1
|
||||
strategy = (data.get('strategy') or 'Terms').strip()
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
paths = [p.strip() for p in paths if p and isinstance(p, str) and os.path.isfile(p.strip())]
|
||||
if not paths:
|
||||
return jsonify({'success': False, 'error': '没有有效的 xlsx 文件路径'}), 400
|
||||
# 先上传到 OSS,获取链接再入库
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
urls = []
|
||||
for p in paths:
|
||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||
if err:
|
||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||
base_name = os.path.basename(p)
|
||||
urls.append({"fileUrl": url,"originalFilename": base_name,"relativePath": p })
|
||||
|
||||
# 请求提交
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
|
||||
"content-type":"application/json",
|
||||
},data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
|
||||
print({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""})
|
||||
print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}")
|
||||
resp_data = resp.json()
|
||||
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id} 返回:",resp_data,"状态:",resp.status_code)
|
||||
if resp_data.get("success"):
|
||||
task_id = resp_data.get("data").get("taskId")
|
||||
data = resp_data.get("data")
|
||||
threading.Thread(target=_background_brand_task, args=(task_id,data), daemon=True).start()
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
else:
|
||||
print(resp_data)
|
||||
return jsonify({'success': False, 'error': "请求接口失败"}), 500
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks', methods=['GET', 'POST'])
|
||||
# @login_required
|
||||
def api_brand_tasks():
|
||||
"""GET: 获取当前用户的任务列表;POST: 添加任务(后台执行)"""
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks", params={
|
||||
"userId": user_id
|
||||
})
|
||||
resp_data = resp.json()
|
||||
print("获取列表",resp.text)
|
||||
if resp_data.get("success"):
|
||||
items = resp_data.get("data").get("items")
|
||||
return jsonify({'success': True, 'items': items})
|
||||
return jsonify({'success': True, 'items': []})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print("获取列表失败",e)
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
# POST
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
paths = data.get('paths') or []
|
||||
# task_type: 1=立即执行,2=添加任务;此接口默认 2
|
||||
try:
|
||||
task_type = int(data.get('task_type') or 2)
|
||||
except Exception:
|
||||
task_type = 2
|
||||
if task_type not in (1, 2):
|
||||
task_type = 2
|
||||
strategy = (data.get('strategy') or 'Terms').strip()
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
paths = [p.strip() for p in paths if p and isinstance(p, str)]
|
||||
if not paths:
|
||||
return jsonify({'success': False, 'error': '请提供至少一个文件路径或链接'}), 400
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
urls = []
|
||||
for p in paths:
|
||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||
if err:
|
||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||
base_name = os.path.basename(p)
|
||||
urls.append({"fileUrl": url, "originalFilename": base_name, "relativePath": p})
|
||||
params = {
|
||||
"userId": user_id
|
||||
}
|
||||
# resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}",headers={
|
||||
# "content-type":"application/json",
|
||||
# },data=json.dumps({ "files": urls, "strategy": strategy,"taskType": 1,"archiveName": ""}))
|
||||
req_data = {"files": urls, "strategy": strategy, "taskType": 2, "archiveName": ""}
|
||||
resp = requests.post(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id}", headers={"content-type": "application/json"},
|
||||
data=json.dumps(req_data))
|
||||
print(req_data)
|
||||
# print(f"{JAVA_API_BASE}/api/brand/tasks?userId={user_id},返回", resp.text)
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
task_id = resp_data.get("data").get("taskId")
|
||||
data = resp_data.get("data")
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
return jsonify({'success': False, 'task_id': "请求异常"})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>')
|
||||
# @login_required
|
||||
def api_brand_task_detail(task_id):
|
||||
"""获取单个任务详情(用于轮询状态)"""
|
||||
try:
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
task = resp_data["data"]["task"]
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'task': task
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/line-progress')
|
||||
# @login_required
|
||||
def api_brand_task_line_progress(task_id):
|
||||
"""
|
||||
获取任务的“当前文件行级进度”(当前行数/总行数)。
|
||||
该信息仅存放在内存字典 _task_line_progress 中,不写入数据库。
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(f"{JAVA_API_BASE}/api/brand/tasks/{task_id}")
|
||||
resp_data = resp.json()
|
||||
if resp_data.get("success"):
|
||||
if resp_data["data"]["line_progress"]["has_progress"]:
|
||||
info = resp_data["data"]["line_progress"]["info"]
|
||||
resp = {
|
||||
'file_index': int(info.get('file_index') or 0),
|
||||
'file_total': int(info.get('file_total') or 0),
|
||||
'file_name': info.get("file_name",""),
|
||||
'current_line': int(info.get('current_line') or 0),
|
||||
'total_lines': int(info.get('total_lines') or 0),
|
||||
}
|
||||
return jsonify({'success': True, 'has_progress': True, 'info': resp})
|
||||
raise RuntimeError("获取进度失败")
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/events')
|
||||
# @login_required
|
||||
def api_brand_task_events(task_id):
|
||||
"""SSE:任务完成时后端主动推送事件,前端监听后隐藏进度条与取消按钮"""
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
def _task_status_event():
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status, error_message FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||
(task_id, user_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
st = (row.get('status') or '').lower()
|
||||
if st in ('success', 'failed', 'cancelled'):
|
||||
return {'status': st, 'error_message': (row.get('error_message') or '').strip() or None}
|
||||
return None
|
||||
|
||||
# 若任务已处于终态,直接返回一条事件后结束
|
||||
ev = _task_status_event()
|
||||
if ev is not None:
|
||||
def _one_shot():
|
||||
yield "data: " + json.dumps(ev, ensure_ascii=False) + "\n\n"
|
||||
return Response(
|
||||
_one_shot(),
|
||||
mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||
)
|
||||
|
||||
# 否则注册队列,等待后台任务完成时推送
|
||||
q = Queue()
|
||||
with _task_event_lock:
|
||||
_task_event_queues.setdefault(task_id, []).append(q)
|
||||
|
||||
def _stream():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = q.get(timeout=20)
|
||||
yield "data: " + json.dumps(event, ensure_ascii=False) + "\n\n"
|
||||
return
|
||||
except Empty:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
with _task_event_lock:
|
||||
lst = _task_event_queues.get(task_id, [])
|
||||
if q in lst:
|
||||
lst.remove(q)
|
||||
if not lst:
|
||||
_task_event_queues.pop(task_id, None)
|
||||
|
||||
return Response(
|
||||
_stream(),
|
||||
mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||
)
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/cancel', methods=['POST'])
|
||||
# @login_required
|
||||
def api_brand_task_cancel(task_id):
|
||||
"""取消正在执行或等待中的任务(pending/running 均可取消)"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
|
||||
resp = requests.post(
|
||||
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}/cancel",
|
||||
headers={"accept": "application/json"},
|
||||
timeout=20
|
||||
)
|
||||
try:
|
||||
resp_data = resp.json()
|
||||
except Exception:
|
||||
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
|
||||
if not resp_data.get('success'):
|
||||
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
|
||||
|
||||
_mark_task_cancelled(task_id)
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_brand_task_delete(task_id):
|
||||
"""删除任务(仅限非 running 状态)"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
resp = requests.delete(
|
||||
f"{JAVA_API_BASE}/api/brand/tasks/{task_id}",
|
||||
headers={"accept": "application/json"},
|
||||
timeout=20
|
||||
)
|
||||
try:
|
||||
resp_data = resp.json()
|
||||
except Exception:
|
||||
return jsonify({'success': False, 'error': '取消接口返回非 JSON'}), 500
|
||||
if not resp_data.get('success'):
|
||||
return jsonify({'success': False, 'error': '第三方取消任务失败'}), 400
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/download/<int:task_id>')
|
||||
@login_required
|
||||
def api_brand_download(task_id):
|
||||
"""下载任务结果:优先使用 OSS zip_url 重定向;否则本地/单链接/多链接按原逻辑处理"""
|
||||
try:
|
||||
user_id, err = _get_user_id_or_error()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT result_paths FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||
(task_id, user_id)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row or not row.get('result_paths'):
|
||||
return jsonify({'success': False, 'error': '无结果可下载'}), 404
|
||||
raw = row['result_paths']
|
||||
if isinstance(raw, str):
|
||||
raw = _parse_json(raw)
|
||||
url_list, zip_url = _normalize_result_paths(raw)
|
||||
# 新格式:有 zip_url 直接重定向到 OSS
|
||||
if zip_url and _is_url(zip_url):
|
||||
return redirect(zip_url, code=302)
|
||||
|
||||
# 兼容旧格式:result_paths 可能为 list(本地路径或 url)
|
||||
if not url_list and isinstance(raw, list):
|
||||
url_list = raw
|
||||
local_paths = [p for p in url_list if isinstance(p, str) and not _is_url(p) and os.path.isfile(p)]
|
||||
url_paths = [p.strip() for p in url_list if isinstance(p, str) and _is_url(p)]
|
||||
|
||||
if len(url_paths) == 1 and not local_paths:
|
||||
return redirect(url_paths[0], code=302)
|
||||
|
||||
if len(local_paths) == 1 and not url_paths:
|
||||
return send_file(
|
||||
local_paths[0],
|
||||
as_attachment=True,
|
||||
download_name=os.path.basename(local_paths[0])
|
||||
)
|
||||
|
||||
if local_paths or url_paths:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in local_paths:
|
||||
zf.write(p, os.path.basename(p))
|
||||
for url in url_paths:
|
||||
try:
|
||||
import requests as req
|
||||
r = req.get(url, timeout=30, stream=True)
|
||||
r.raise_for_status()
|
||||
name = os.path.basename(urlparse(url).path) or ('file_%s' % (url_paths.index(url)))
|
||||
if not name or name == 'file_%s' % url_paths.index(url):
|
||||
name = 'download_%s' % url_paths.index(url)
|
||||
zf.writestr(name, r.content)
|
||||
except Exception:
|
||||
pass
|
||||
buf.seek(0)
|
||||
return send_file(
|
||||
buf,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name='brand_task_%s.zip' % task_id
|
||||
)
|
||||
|
||||
return jsonify({'success': False, 'error': '结果文件不存在或链接不可用'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ import subprocess
|
||||
import requests
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response, session, send_file
|
||||
from flask import Blueprint, request, jsonify, Response, send_file
|
||||
from PIL import Image
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from app_common import get_db, login_required, current_user_id, BASE_DIR
|
||||
from config import STITCH_WORKFLOW_ID,client_name
|
||||
|
||||
image_bp = Blueprint('image', __name__)
|
||||
@@ -146,11 +146,12 @@ def api_generate():
|
||||
except (TypeError, ValueError):
|
||||
hid = None
|
||||
conn = get_db()
|
||||
_uid = current_user_id()
|
||||
with conn.cursor() as cur:
|
||||
if hid is not None and hid > 0:
|
||||
cur.execute(
|
||||
"SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s",
|
||||
(hid, session['user_id']),
|
||||
(hid, _uid),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
existing_urls = []
|
||||
@@ -182,7 +183,7 @@ def api_generate():
|
||||
_json.dumps(_sanitize_params_for_history(params)),
|
||||
_json.dumps(merged_result_urls),
|
||||
hid,
|
||||
session['user_id'],
|
||||
_uid,
|
||||
),
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
@@ -192,7 +193,7 @@ def api_generate():
|
||||
"""INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(
|
||||
session['user_id'],
|
||||
_uid,
|
||||
params.get('panel_type', ''),
|
||||
_json.dumps(result.get('original_urls') or []),
|
||||
_json.dumps(_sanitize_params_for_history(params)),
|
||||
@@ -366,7 +367,7 @@ def api_history():
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
where_user = "user_id = %s"
|
||||
params_where = [session['user_id']]
|
||||
params_where = [current_user_id()]
|
||||
if panel_type:
|
||||
where_user += " AND panel_type = %s"
|
||||
params_where.append(panel_type)
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
"""
|
||||
主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo
|
||||
"""
|
||||
"""
|
||||
主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo
|
||||
"""
|
||||
import os
|
||||
from flask import Blueprint, send_file, render_template_string
|
||||
|
||||
from app_common import (
|
||||
get_db,
|
||||
_render_html,
|
||||
_is_session_user_valid,
|
||||
login_required,
|
||||
admin_required,
|
||||
STATIC_DIR,
|
||||
BASE_DIR,
|
||||
ASSETS_DIR
|
||||
)
|
||||
from flask import redirect, url_for, session
|
||||
from flask import Flask, request, Response, stream_with_context
|
||||
import requests
|
||||
|
||||
|
||||
from app_common import (
|
||||
get_db,
|
||||
_render_html,
|
||||
login_required,
|
||||
admin_required,
|
||||
current_user_id,
|
||||
current_username,
|
||||
STATIC_DIR,
|
||||
BASE_DIR,
|
||||
ASSETS_DIR
|
||||
)
|
||||
from flask import redirect, url_for
|
||||
from flask import Flask, request, Response, stream_with_context
|
||||
import requests
|
||||
|
||||
from config import base_url,version,JAVA_API_BASE
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
@@ -63,35 +64,36 @@ def _resolve_asset_filename(filename):
|
||||
return matches[0]
|
||||
|
||||
return safe_name
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
if session.get('user_id') and _is_session_user_valid():
|
||||
return redirect(url_for('main.home'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@main_bp.route('/home')
|
||||
@login_required
|
||||
def home():
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version)
|
||||
except Exception:
|
||||
return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version)
|
||||
|
||||
|
||||
@main_bp.route('/image')
|
||||
@login_required
|
||||
def wb():
|
||||
return _render_html('index.html')
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
if current_user_id():
|
||||
return redirect(url_for('main.home'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@main_bp.route('/home')
|
||||
@login_required
|
||||
def home():
|
||||
uid = current_user_id()
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (uid,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=uid, baseUrl=base_url, version=version, java_api_base=JAVA_API_BASE)
|
||||
except Exception:
|
||||
return _render_html('home.html', username=current_username(), is_admin=False, user_id=uid, baseUrl=base_url, version=version, java_api_base=JAVA_API_BASE)
|
||||
|
||||
|
||||
@main_bp.route('/image')
|
||||
@login_required
|
||||
def wb():
|
||||
return _render_html('index.html')
|
||||
|
||||
|
||||
@main_bp.route('/brand')
|
||||
@login_required
|
||||
def brand_page():
|
||||
@@ -112,41 +114,41 @@ def brand_page():
|
||||
if raw[:7] == b'gAAAAAB':
|
||||
raise
|
||||
content = raw.decode('utf-8', errors='replace')
|
||||
return render_template_string(content, user_id=session.get('user_id'))
|
||||
return render_template_string(content, user_id=current_user_id(), java_api_base=JAVA_API_BASE)
|
||||
except Exception:
|
||||
continue
|
||||
return _render_html('brand.html', user_id=session.get('user_id'))
|
||||
|
||||
|
||||
@main_bp.route('/brand/legacy')
|
||||
@login_required
|
||||
def brand_page_legacy():
|
||||
legacy_path = os.path.join(BASE_DIR, 'web_source', 'templates_backup', 'brand.html')
|
||||
if not os.path.isfile(legacy_path):
|
||||
return '', 404
|
||||
with open(legacy_path, 'rb') as f:
|
||||
content = f.read().decode('utf-8', errors='replace')
|
||||
content = content.replace(
|
||||
'<header class="top-bar">',
|
||||
'<header class="top-bar" style="display:none !important;">',
|
||||
1,
|
||||
return _render_html('brand.html', user_id=current_user_id(), java_api_base=JAVA_API_BASE)
|
||||
|
||||
|
||||
@main_bp.route('/brand/legacy')
|
||||
@login_required
|
||||
def brand_page_legacy():
|
||||
legacy_path = os.path.join(BASE_DIR, 'web_source', 'templates_backup', 'brand.html')
|
||||
if not os.path.isfile(legacy_path):
|
||||
return '', 404
|
||||
with open(legacy_path, 'rb') as f:
|
||||
content = f.read().decode('utf-8', errors='replace')
|
||||
content = content.replace(
|
||||
'<header class="top-bar">',
|
||||
'<header class="top-bar" style="display:none !important;">',
|
||||
1,
|
||||
)
|
||||
content = content.replace('height: calc(100vh - 56px);', 'height: 100vh;', 1)
|
||||
return render_template_string(content, user_id=session.get('user_id'))
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/static/<path:filename>')
|
||||
def serve_static(filename):
|
||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
|
||||
static_abs = os.path.abspath(STATIC_DIR)
|
||||
file_abs = os.path.abspath(filepath)
|
||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
|
||||
return render_template_string(content, user_id=current_user_id())
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/static/<path:filename>')
|
||||
def serve_static(filename):
|
||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
|
||||
static_abs = os.path.abspath(STATIC_DIR)
|
||||
file_abs = os.path.abspath(filepath)
|
||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
|
||||
|
||||
@main_bp.route('/assets/<path:filename>')
|
||||
def serve_assets(filename):
|
||||
@@ -165,8 +167,8 @@ def serve_assets(filename):
|
||||
if os.path.isfile(file_abs):
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
return '', 404
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/new_web_source/<path:filename>')
|
||||
@login_required
|
||||
def serve_new_web_source(filename):
|
||||
@@ -179,7 +181,7 @@ def serve_new_web_source(filename):
|
||||
if filename.lower().endswith('.html'):
|
||||
with open(file_abs, 'rb') as f:
|
||||
content = f.read().decode('utf-8', errors='replace')
|
||||
raw_uid = session.get('user_id')
|
||||
raw_uid = current_user_id()
|
||||
uid_value = str(int(raw_uid)) if str(raw_uid or '').isdigit() else '""'
|
||||
uid_script = (
|
||||
"\n<script>"
|
||||
@@ -195,22 +197,22 @@ def serve_new_web_source(filename):
|
||||
|
||||
@main_bp.route('/logo.jpg', methods=['GET'])
|
||||
def get_logo_image():
|
||||
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')
|
||||
|
||||
|
||||
@main_bp.route('/newApi/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
|
||||
def proxy(path):
|
||||
target_url = f"{JAVA_API_BASE}/{path}"
|
||||
|
||||
# 复制请求参数
|
||||
params = request.args.to_dict()
|
||||
|
||||
# 处理请求头
|
||||
headers = {}
|
||||
for key, value in request.headers:
|
||||
if key.lower() in ['host', 'content-length', 'connection']:
|
||||
continue
|
||||
headers[key] = value
|
||||
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')
|
||||
|
||||
|
||||
@main_bp.route('/newApi/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
|
||||
def proxy(path):
|
||||
target_url = f"{JAVA_API_BASE}/{path}"
|
||||
|
||||
# 复制请求参数
|
||||
params = request.args.to_dict()
|
||||
|
||||
# 处理请求头
|
||||
headers = {}
|
||||
for key, value in request.headers:
|
||||
if key.lower() in ['host', 'content-length', 'connection']:
|
||||
continue
|
||||
headers[key] = value
|
||||
ignore_url = [f"{JAVA_API_BASE}/api/delete-brand/tasks/batch",
|
||||
f"{JAVA_API_BASE}/api/delete-brand/tasks/progress/batch",
|
||||
f"{JAVA_API_BASE}/api/delete-brand/history",
|
||||
@@ -229,36 +231,36 @@ def proxy(path):
|
||||
print("=============================")
|
||||
except Exception as e:
|
||||
print("打印失败", e)
|
||||
|
||||
try:
|
||||
# 使用流式请求
|
||||
req = requests.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
data=request.get_data() if request.get_data() else None,
|
||||
cookies=request.cookies,
|
||||
stream=True, # 启用流式传输
|
||||
|
||||
try:
|
||||
# 使用流式请求
|
||||
req = requests.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
data=request.get_data() if request.get_data() else None,
|
||||
cookies=request.cookies,
|
||||
stream=True, # 启用流式传输
|
||||
timeout=proxy_timeout
|
||||
)
|
||||
|
||||
# 流式响应
|
||||
def generate():
|
||||
for chunk in req.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
# 构建响应
|
||||
response = Response(stream_with_context(generate()), status=req.status_code)
|
||||
|
||||
# 复制响应头
|
||||
for key, value in req.headers.items():
|
||||
if key.lower() not in ['content-encoding', 'content-length', 'transfer-encoding', 'connection']:
|
||||
response.headers[key] = value
|
||||
|
||||
return response
|
||||
|
||||
)
|
||||
|
||||
# 流式响应
|
||||
def generate():
|
||||
for chunk in req.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
# 构建响应
|
||||
response = Response(stream_with_context(generate()), status=req.status_code)
|
||||
|
||||
# 复制响应头
|
||||
for key, value in req.headers.items():
|
||||
if key.lower() not in ['content-encoding', 'content-length', 'transfer-encoding', 'connection']:
|
||||
response.headers[key] = value
|
||||
|
||||
return response
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
print(f"后端服务超时:target_url={target_url}, timeout={proxy_timeout}")
|
||||
return Response("后端服务超时", status=504)
|
||||
|
||||
@@ -50,7 +50,6 @@ file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
||||
|
||||
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
||||
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
||||
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||
|
||||
|
||||
debug = True
|
||||
|
||||
83
app/jwt_util.py
Normal file
83
app/jwt_util.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
JWT 工具:解析 Java 端签发的 token、从请求中提取 token。
|
||||
与 Java 端 JwtService 共用同一个 HS256 密钥(AIIMAGE_JWT_SECRET)。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from flask import request
|
||||
|
||||
try:
|
||||
import jwt as pyjwt
|
||||
_PYJWT_IMPORT_ERROR = None
|
||||
except ImportError as _e:
|
||||
pyjwt = None
|
||||
_PYJWT_IMPORT_ERROR = _e
|
||||
# 启动时立刻给出醒目提示,避免上线后才发现一直 401
|
||||
print(
|
||||
"[auth] WARNING: PyJWT 未安装,所有依赖 JWT 的接口都会返回 401。"
|
||||
" 请执行 `pip install PyJWT==2.10.1` 或 `pip install -r requirements.txt`。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_SECRET = "please-change-this-secret-please-rotate-at-least-32-bytes"
|
||||
COOKIE_NAME = os.getenv("AIIMAGE_AUTH_COOKIE_NAME", "aiimage_token")
|
||||
|
||||
|
||||
def _signing_key() -> bytes:
|
||||
"""与 Java JwtService.signingKey 保持一致:UTF-8 字节,不足 32 字节右侧补 0。"""
|
||||
secret = os.getenv("AIIMAGE_JWT_SECRET", _DEFAULT_SECRET)
|
||||
key_bytes = secret.encode("utf-8")
|
||||
if len(key_bytes) < 32:
|
||||
key_bytes = key_bytes + b"\x00" * (32 - len(key_bytes))
|
||||
return key_bytes
|
||||
|
||||
|
||||
def parse_token_with_reason(token: str) -> Tuple[Optional[dict], Optional[str]]:
|
||||
"""解析 JWT,返回 (payload, error_reason)。
|
||||
payload 命中时 error_reason 为 None;失败时 payload 为 None,error_reason 描述根因。"""
|
||||
if not token:
|
||||
return None, "token 为空"
|
||||
if pyjwt is None:
|
||||
return None, f"PyJWT 未安装({_PYJWT_IMPORT_ERROR})"
|
||||
# 打印 token 头,便于发现 alg 不是 HS256 等情况(不验签,仅 base64 解码 header)
|
||||
try:
|
||||
header = pyjwt.get_unverified_header(token)
|
||||
except Exception as e:
|
||||
return None, f"无法解析 token header: {type(e).__name__}: {e}"
|
||||
alg = header.get("alg") or "?"
|
||||
try:
|
||||
payload = pyjwt.decode(token, _signing_key(), algorithms=["HS256", "HS384", "HS512"])
|
||||
except Exception as e:
|
||||
# 常见:ExpiredSignatureError / InvalidSignatureError / DecodeError / InvalidAlgorithmError
|
||||
return None, f"{type(e).__name__}: {e} (token alg={alg})"
|
||||
sub = payload.get("sub")
|
||||
try:
|
||||
user_id = int(sub) if sub is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None, f"sub 字段非法: {sub!r}"
|
||||
if user_id is None:
|
||||
return None, "payload 缺少 sub 字段"
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"username": payload.get("username") or "",
|
||||
"device_id": payload.get("deviceId") or "",
|
||||
}, None
|
||||
|
||||
|
||||
def parse_token(token: str) -> Optional[dict]:
|
||||
"""解析 JWT,返回 {user_id, username, device_id};失败返回 None。"""
|
||||
payload, _ = parse_token_with_reason(token)
|
||||
return payload
|
||||
|
||||
|
||||
def get_token_from_request() -> str:
|
||||
"""优先从 Authorization: Bearer 取,其次从 cookie 取。"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token = auth[len("Bearer "):].strip()
|
||||
if token:
|
||||
return token
|
||||
return (request.cookies.get(COOKIE_NAME) or "").strip()
|
||||
10
app/main.py
10
app/main.py
@@ -20,6 +20,7 @@ import subprocess
|
||||
from amazon.del_brand import kill_process
|
||||
from app import run_app
|
||||
from generate_api import generate
|
||||
from tool.devices import DeviceIDGenerator
|
||||
|
||||
|
||||
with open("version.txt","w",encoding="utf-8") as file:
|
||||
@@ -347,6 +348,13 @@ class WindowAPI:
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_device_id(self):
|
||||
"""暴露给 HTML 的设备指纹接口:复用桌面端硬件特征生成 64 位 SHA256 ID。"""
|
||||
try:
|
||||
return {'success': True, 'device_id': DeviceIDGenerator().get_device_id()}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_detail_del(self,task_id):
|
||||
"""获取正在执行的删除品牌任务详情"""
|
||||
task_info = runing_task.get(task_id)
|
||||
@@ -432,7 +440,7 @@ def main():
|
||||
|
||||
api = WindowAPI(window)
|
||||
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder,
|
||||
api.save_file_from_url, api.save_template_xlsx,api.save_template_zip,api.upload_file_to_java,api.open_external_url,api.enqueue_json,api.get_detail_del,api.save_file_from_url_new)
|
||||
api.save_file_from_url, 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.get_device_id)
|
||||
webview.start(
|
||||
debug=True,
|
||||
storage_path=cache_path,
|
||||
|
||||
@@ -54,6 +54,7 @@ pyinstaller==6.19.0
|
||||
pyinstaller-hooks-contrib==2026.3
|
||||
PyMsgBox==2.0.1
|
||||
PyMySQL==1.1.2
|
||||
PyJWT==2.10.1
|
||||
pyperclip==1.11.0
|
||||
PyQt5==5.15.11
|
||||
PyQt5-Qt5==5.15.2
|
||||
|
||||
583
app/web_source/admin.html
Normal file
583
app/web_source/admin.html
Normal file
@@ -0,0 +1,583 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理后台 - 数富AI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 24px;
|
||||
}
|
||||
.nav { margin-bottom: 24px; }
|
||||
.nav a { color: #667eea; text-decoration: none; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
h1 { font-size: 20px; margin-bottom: 20px; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
font-size: 15px;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab:hover { color: #667eea; }
|
||||
.tab.active { color: #667eea; font-weight: 600; border-bottom-color: #667eea; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* Form & Table */
|
||||
.form-box, .panel-box {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 14px; margin-bottom: 6px; }
|
||||
.form-group input, .form-group select {
|
||||
width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px;
|
||||
}
|
||||
.form-group input[type="checkbox"] { width: auto; margin-right: 8px; }
|
||||
.form-row { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 16px; }
|
||||
.form-row .form-group { margin-bottom: 0; flex: 1; min-width: 120px; }
|
||||
|
||||
.btn { padding: 10px 20px; background: #667eea; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.btn-sm { padding: 6px 12px; font-size: 13px; }
|
||||
.btn-danger { background: #e74c3c; }
|
||||
.btn-secondary { background: #95a5a6; }
|
||||
|
||||
.msg { margin-top: 12px; font-size: 14px; }
|
||||
.msg.ok { color: #27ae60; }
|
||||
.msg.err { color: #e74c3c; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; }
|
||||
th { background: #f8f9fa; font-weight: 600; }
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pagination span { font-size: 14px; color: #666; }
|
||||
.pagination button { padding: 6px 12px; border: 1px solid #ddd; background: #fff; cursor: pointer; border-radius: 4px; }
|
||||
.pagination button:hover:not(:disabled) { background: #f0f0f0; }
|
||||
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; margin-right: 8px; vertical-align: middle; }
|
||||
.thumb-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.empty-tip { color: #999; font-size: 14px; padding: 24px; text-align: center; }
|
||||
.modal-mask {
|
||||
display: none;
|
||||
position: fixed; left: 0; top: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.4); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-mask.show { display: flex; }
|
||||
.modal { background: #fff; border-radius: 8px; padding: 24px; min-width: 320px; max-width: 90%; }
|
||||
.modal h3 { margin-bottom: 16px; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav"><a href="/home">← 返回首页</a></div>
|
||||
<h1>管理后台</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="users">用户管理</div>
|
||||
<div class="tab" data-tab="history">查看生成记录</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户管理 -->
|
||||
<div id="panel-users" class="tab-panel active">
|
||||
<div class="form-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">创建用户</h3>
|
||||
<p style="margin-bottom:16px;font-size:13px;color:#666;">无注册入口,仅管理员可在此创建用户。层级:超级管理员 → 管理员 → 普通号。</p>
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="username" placeholder="用户名(至少2个字符)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" id="password" placeholder="密码(至少6个字符)">
|
||||
</div>
|
||||
<div class="form-group" id="formGroupRole">
|
||||
<label>角色</label>
|
||||
<select id="createRole">
|
||||
<option value="normal">普通号</option>
|
||||
<option value="admin" id="optAdmin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="formGroupCreatedBy" style="display:none;">
|
||||
<label>所属管理员</label>
|
||||
<select id="createCreatedBy">
|
||||
<option value="">请选择管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" id="btnCreate">创建用户</button>
|
||||
<p class="msg" id="msgCreate"></p>
|
||||
</div>
|
||||
|
||||
<div class="panel-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">用户列表</h3>
|
||||
<div class="form-row" style="margin-bottom:16px;">
|
||||
<div class="form-group" style="min-width:180px;">
|
||||
<label>用户名(模糊搜索)</label>
|
||||
<input type="text" id="searchUsername" placeholder="输入用户名关键字">
|
||||
</div>
|
||||
<div class="form-group" id="filterCreatedByGroup" style="min-width:160px;display:none;">
|
||||
<label>所属管理员</label>
|
||||
<select id="filterCreatedBy">
|
||||
<option value="">全部</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" id="btnSearchUsers">查询</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>所属管理员</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userListBody"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="userPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看生成记录 -->
|
||||
<div id="panel-history" class="tab-panel">
|
||||
<div class="form-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:140px;">
|
||||
<label>指定用户</label>
|
||||
<select id="filterUser">
|
||||
<option value="">全部用户</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width:140px;">
|
||||
<label>开始时间</label>
|
||||
<input type="datetime-local" id="filterTimeStart">
|
||||
</div>
|
||||
<div class="form-group" style="min-width:140px;">
|
||||
<label>结束时间</label>
|
||||
<input type="datetime-local" id="filterTimeEnd">
|
||||
</div>
|
||||
<button class="btn" id="btnFilterHistory">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">生成记录</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户</th>
|
||||
<th>类型</th>
|
||||
<th>创建时间</th>
|
||||
<th>结果预览</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="historyListBody"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="historyPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户弹窗 -->
|
||||
<div class="modal-mask" id="editUserModal">
|
||||
<div class="modal">
|
||||
<h3>编辑用户</h3>
|
||||
<input type="hidden" id="editUserId">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="editUsername" readonly style="background:#f5f5f5;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码(不修改留空)</label>
|
||||
<input type="password" id="editPassword" placeholder="留空则不修改密码">
|
||||
</div>
|
||||
<div class="form-group" id="editFormGroupRole" style="display:none;">
|
||||
<label>角色</label>
|
||||
<select id="editRole">
|
||||
<option value="normal">普通号</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="editFormGroupCreator" style="display:none;">
|
||||
<label>所属管理员</label>
|
||||
<input type="text" id="editCreatorName" readonly style="background:#f5f5f5;">
|
||||
</div>
|
||||
<p class="msg" id="msgEdit"></p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<button class="btn" id="btnSaveUser">保存</button>
|
||||
<button class="btn btn-secondary" id="btnCloseEdit">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||
function buildJavaUrl(path) { return JAVA_API_BASE + path; }
|
||||
function getAuthToken() {
|
||||
try { return localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) { return ''; }
|
||||
}
|
||||
function authHeaders(extra) {
|
||||
var headers = extra || {};
|
||||
var token = getAuthToken();
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
return headers;
|
||||
}
|
||||
function jsonHeaders() {
|
||||
return authHeaders({ 'Content-Type': 'application/json' });
|
||||
}
|
||||
function parseJson(r) { return r.json().catch(function() { return {}; }); }
|
||||
function errMsg(res) {
|
||||
return (res && (res.message || res.error)) || '请求失败';
|
||||
}
|
||||
if (!JAVA_API_BASE) {
|
||||
alert('Java 服务地址未配置,请联系管理员设置 java_api_base');
|
||||
}
|
||||
|
||||
// Tab 切换
|
||||
document.querySelectorAll('.tab').forEach(function(t) {
|
||||
t.onclick = function() {
|
||||
document.querySelectorAll('.tab').forEach(function(x) { x.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(x) { x.classList.remove('active'); });
|
||||
t.classList.add('active');
|
||||
var id = 'panel-' + t.dataset.tab;
|
||||
document.getElementById(id).classList.add('active');
|
||||
if (t.dataset.tab === 'users') loadUsers(1);
|
||||
else if (t.dataset.tab === 'history') loadHistory(1);
|
||||
};
|
||||
});
|
||||
|
||||
// ========== 用户管理 ==========
|
||||
var userPage = 1, userPageSize = 15;
|
||||
var currentUserRole = 'admin';
|
||||
var adminsList = [];
|
||||
function roleLabel(role) {
|
||||
if (role === 'super_admin') return '超级管理员';
|
||||
if (role === 'admin') return '管理员';
|
||||
return '普通号';
|
||||
}
|
||||
function buildUserListQuery(page) {
|
||||
var q = 'page=' + (page || 1) + '&page_size=' + userPageSize;
|
||||
var kw = (document.getElementById('searchUsername').value || '').trim();
|
||||
if (kw) q += '&username=' + encodeURIComponent(kw);
|
||||
var cby = document.getElementById('filterCreatedBy').value;
|
||||
if (cby) q += '&created_by_id=' + encodeURIComponent(cby);
|
||||
return q;
|
||||
}
|
||||
function loadUsers(page) {
|
||||
userPage = page || 1;
|
||||
fetch(buildJavaUrl('/api/admin/users?' + buildUserListQuery(userPage)), {
|
||||
credentials: 'include',
|
||||
headers: authHeaders()
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
var tbody = document.getElementById('userListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + errMsg(res) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
var data = res.data || {};
|
||||
currentUserRole = data.current_user_role || 'admin';
|
||||
adminsList = data.admins || [];
|
||||
var items = data.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">暂无用户</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function(u) {
|
||||
return '<tr><td>' + u.id + '</td><td>' + (u.username || '') + '</td><td>' +
|
||||
roleLabel(u.role || 'normal') + '</td><td>' + (u.creator_username || '-') + '</td><td>' + (u.created_at || '') + '</td><td>' +
|
||||
'<button class="btn btn-sm" data-edit="' + u.id + '" data-user="' + (JSON.stringify(u).replace(/"/g, '"')) + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-delete="' + u.id + '" data-name="' + (u.username || '').replace(/"/g, '"') + '">删除</button>' +
|
||||
'</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
renderPagination('userPagination', data.total, data.page, data.page_size, loadUsers);
|
||||
bindUserActions();
|
||||
updateCreateFormByRole();
|
||||
updateUserFilterByRole();
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('userListBody').innerHTML = '<tr><td colspan="6" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
function updateUserFilterByRole() {
|
||||
var grp = document.getElementById('filterCreatedByGroup');
|
||||
var sel = document.getElementById('filterCreatedBy');
|
||||
if (currentUserRole === 'super_admin') {
|
||||
grp.style.display = 'block';
|
||||
var cur = sel.value;
|
||||
sel.innerHTML = '<option value="">全部</option>';
|
||||
adminsList.forEach(function(a) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.username;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.value = cur || '';
|
||||
} else {
|
||||
grp.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function updateCreateFormByRole() {
|
||||
var roleSel = document.getElementById('createRole');
|
||||
var optAdmin = document.getElementById('optAdmin');
|
||||
var formCreatedBy = document.getElementById('formGroupCreatedBy');
|
||||
var selCreatedBy = document.getElementById('createCreatedBy');
|
||||
if (currentUserRole === 'super_admin') {
|
||||
if (optAdmin) optAdmin.style.display = '';
|
||||
formCreatedBy.style.display = (roleSel.value === 'normal') ? 'block' : 'none';
|
||||
selCreatedBy.innerHTML = '<option value="">请选择管理员</option>';
|
||||
adminsList.forEach(function(a) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.username;
|
||||
selCreatedBy.appendChild(opt);
|
||||
});
|
||||
} else {
|
||||
if (optAdmin) optAdmin.style.display = 'none';
|
||||
roleSel.value = 'normal';
|
||||
formCreatedBy.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function bindUserActions() {
|
||||
document.querySelectorAll('[data-edit]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
var raw = (btn.getAttribute('data-user') || '{}').replace(/"/g, '"');
|
||||
var u;
|
||||
try { u = JSON.parse(raw); } catch (e) { u = {}; }
|
||||
document.getElementById('editUserId').value = u.id || '';
|
||||
document.getElementById('editUsername').value = u.username || '';
|
||||
document.getElementById('editPassword').value = '';
|
||||
var editRole = document.getElementById('editRole');
|
||||
var editFormGroupRole = document.getElementById('editFormGroupRole');
|
||||
var editFormGroupCreator = document.getElementById('editFormGroupCreator');
|
||||
var editCreatorName = document.getElementById('editCreatorName');
|
||||
editFormGroupRole.style.display = (currentUserRole === 'super_admin' && u.role !== 'super_admin') ? 'block' : 'none';
|
||||
editFormGroupCreator.style.display = (u.role === 'normal' && u.creator_username) ? 'block' : 'none';
|
||||
editCreatorName.value = u.creator_username || '';
|
||||
if (u.role !== 'super_admin') { editRole.value = u.role || 'normal'; }
|
||||
document.getElementById('msgEdit').textContent = '';
|
||||
document.getElementById('editUserModal').classList.add('show');
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('[data-delete]').forEach(function(btn) {
|
||||
btn.onclick = function() {
|
||||
if (!confirm('确定删除用户 "' + (btn.dataset.name || '') + '" 吗?')) return;
|
||||
fetch(buildJavaUrl('/api/admin/user/' + btn.dataset.delete), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: authHeaders()
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
if (res.success) { loadUsers(userPage); }
|
||||
else { alert(errMsg(res)); }
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btnSearchUsers').onclick = function() { loadUsers(1); };
|
||||
document.getElementById('createRole').onchange = function() { updateCreateFormByRole(); };
|
||||
document.getElementById('btnCreate').onclick = function() {
|
||||
var username = (document.getElementById('username').value || '').trim();
|
||||
var password = document.getElementById('password').value || '';
|
||||
var role = document.getElementById('createRole').value || 'normal';
|
||||
var createdById = document.getElementById('createCreatedBy').value ? parseInt(document.getElementById('createCreatedBy').value, 10) : null;
|
||||
var msgEl = document.getElementById('msgCreate');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!username || username.length < 2) {
|
||||
msgEl.textContent = '用户名至少2个字符';
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
msgEl.textContent = '密码至少6个字符';
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
var body = { username: username, password: password, role: role };
|
||||
if (role === 'normal' && currentUserRole === 'super_admin' && createdById) body.createdById = createdById;
|
||||
fetch(buildJavaUrl('/api/admin/user'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
msgEl.textContent = res.message || '创建成功';
|
||||
msgEl.classList.add('ok');
|
||||
document.getElementById('username').value = '';
|
||||
document.getElementById('password').value = '';
|
||||
loadUsers(1);
|
||||
} else {
|
||||
msgEl.textContent = errMsg(res);
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msgEl.textContent = '请求失败';
|
||||
msgEl.classList.add('err');
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById('btnSaveUser').onclick = function() {
|
||||
var uid = document.getElementById('editUserId').value;
|
||||
var password = document.getElementById('editPassword').value;
|
||||
var editRoleEl = document.getElementById('editRole');
|
||||
var msgEl = document.getElementById('msgEdit');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
var body = {};
|
||||
if (password) body.password = password;
|
||||
if (currentUserRole === 'super_admin' && editRoleEl && editRoleEl.offsetParent !== null)
|
||||
body.role = editRoleEl.value || 'normal';
|
||||
fetch(buildJavaUrl('/api/admin/user/' + uid), {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
if (res.success) {
|
||||
msgEl.textContent = res.message || '保存成功';
|
||||
msgEl.classList.add('ok');
|
||||
document.getElementById('editUserModal').classList.remove('show');
|
||||
loadUsers(userPage);
|
||||
} else {
|
||||
msgEl.textContent = errMsg(res);
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
});
|
||||
};
|
||||
document.getElementById('btnCloseEdit').onclick = function() {
|
||||
document.getElementById('editUserModal').classList.remove('show');
|
||||
};
|
||||
|
||||
// ========== 生成记录 ==========
|
||||
var historyPage = 1, historyPageSize = 15;
|
||||
function toSqlDatetime(val) {
|
||||
if (!val) return '';
|
||||
return val.replace('T', ' ');
|
||||
}
|
||||
function buildHistoryQuery(page) {
|
||||
var q = 'page=' + (page || 1) + '&page_size=' + historyPageSize;
|
||||
var uid = document.getElementById('filterUser').value;
|
||||
var start = toSqlDatetime(document.getElementById('filterTimeStart').value);
|
||||
var end = toSqlDatetime(document.getElementById('filterTimeEnd').value);
|
||||
if (uid) q += '&user_id=' + uid;
|
||||
if (start) q += '&time_start=' + encodeURIComponent(start);
|
||||
if (end) q += '&time_end=' + encodeURIComponent(end);
|
||||
return q;
|
||||
}
|
||||
function loadHistory(page) {
|
||||
historyPage = page || 1;
|
||||
fetch(buildJavaUrl('/api/admin/history?' + buildHistoryQuery(historyPage)), {
|
||||
credentials: 'include',
|
||||
headers: authHeaders()
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
var tbody = document.getElementById('historyListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">加载失败: ' + errMsg(res) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
var data = res.data || {};
|
||||
var items = data.items || [];
|
||||
if (items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">暂无记录</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function(h) {
|
||||
var urls = (h.result_urls || []);
|
||||
var thumbUrls = (h.long_image_url ? [h.long_image_url] : []).concat(urls);
|
||||
var thumbs = thumbUrls.slice(0, 3).map(function(url) {
|
||||
return '<img src="' + (url || '').replace(/"/g, '"') + '" class="thumb" alt="">';
|
||||
}).join('');
|
||||
return '<tr><td>' + h.id + '</td><td>' + (h.username || '-') + '</td><td>' +
|
||||
(h.panel_type || '-') + '</td><td>' + (h.created_at || '') + '</td><td>' +
|
||||
'<div class="thumb-wrap">' + (thumbs || '-') + '</div></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
renderPagination('historyPagination', data.total, data.page, data.page_size, loadHistory);
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('historyListBody').innerHTML = '<tr><td colspan="5" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
function loadUserOptions() {
|
||||
fetch(buildJavaUrl('/api/admin/users?page=1&page_size=50'), {
|
||||
credentials: 'include',
|
||||
headers: authHeaders()
|
||||
})
|
||||
.then(parseJson)
|
||||
.then(function(res) {
|
||||
var sel = document.getElementById('filterUser');
|
||||
var cur = sel.value;
|
||||
sel.innerHTML = '<option value="">全部用户</option>';
|
||||
var data = res.data || {};
|
||||
(data.items || []).forEach(function(u) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = u.id;
|
||||
opt.textContent = u.username + ' (' + roleLabel(u.role || 'normal') + ')';
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.value = cur || '';
|
||||
});
|
||||
}
|
||||
document.getElementById('btnFilterHistory').onclick = function() { loadHistory(1); };
|
||||
|
||||
// ========== 分页 ==========
|
||||
function renderPagination(elId, total, page, pageSize, onPage) {
|
||||
var el = document.getElementById(elId);
|
||||
if (!el) return;
|
||||
total = total || 0;
|
||||
page = page || 1;
|
||||
pageSize = pageSize || 15;
|
||||
var totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
el.innerHTML = '<span>共 ' + total + ' 条</span>' +
|
||||
'<button ' + (page <= 1 ? 'disabled' : '') + ' data-p="' + (page - 1) + '">上一页</button>' +
|
||||
'<span>第 ' + page + ' / ' + totalPages + ' 页</span>' +
|
||||
'<button ' + (page >= totalPages ? 'disabled' : '') + ' data-p="' + (page + 1) + '">下一页</button>';
|
||||
el.querySelectorAll('[data-p]').forEach(function(b) {
|
||||
if (!b.disabled) b.onclick = function() { onPage(parseInt(b.dataset.p, 10)); };
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadUsers(1);
|
||||
loadUserOptions();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1071
app/web_source/brand - 副本.html
Normal file
1071
app/web_source/brand - 副本.html
Normal file
File diff suppressed because it is too large
Load Diff
1001
app/web_source/brand-旧.html
Normal file
1001
app/web_source/brand-旧.html
Normal file
File diff suppressed because it is too large
Load Diff
1450
app/web_source/brand.html
Normal file
1450
app/web_source/brand.html
Normal file
File diff suppressed because it is too large
Load Diff
387
app/web_source/home.html
Normal file
387
app/web_source/home.html
Normal file
@@ -0,0 +1,387 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>首页 - 数富AI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||
background: linear-gradient(rgba(255,255,255,0.5), rgba(255,255,255,0.5)),
|
||||
url("/static/bg.jpg") center/cover no-repeat;;
|
||||
/*background-image: url("/static/bg.jpg");*/
|
||||
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.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 { font-size: 18px; font-weight: 600; color: #333; }
|
||||
.header-right { display: flex; align-items: center; gap: 16px; position: relative; }
|
||||
.header-right a, .header-right span {
|
||||
font-size: 14px; color: #555; text-decoration: none;
|
||||
}
|
||||
.header-right a:hover { color: #667eea; }
|
||||
.entrances {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.entrance-btn {
|
||||
width: 160px;
|
||||
height: 100px;
|
||||
background: #c5c1c1;
|
||||
border: 4px solid #b8d4e3;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.entrance-btn:hover {
|
||||
background: #f8fbfd;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.entrance-btn.wb { cursor: pointer; }
|
||||
.entrance-btn.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.admin-link {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
.update-section {
|
||||
margin-top: 48px;
|
||||
padding: 20px 24px;
|
||||
background: rgba(255,255,255,0.85);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
max-width: 420px;
|
||||
}
|
||||
.update-section-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.update-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.update-version-text {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
.btn-check-update {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-check-update:hover {
|
||||
background: #5a6fd6;
|
||||
}
|
||||
.update-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
.btn-download-update {
|
||||
margin-top: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
background: #28a745;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-download-update:hover {
|
||||
background: #218838;
|
||||
}
|
||||
.header-update-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(102,126,234,0.12);
|
||||
color: #4c5bd4;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
padding: 0;
|
||||
}
|
||||
.header-update-btn:hover {
|
||||
background: rgba(102,126,234,0.2);
|
||||
}
|
||||
.header-update-panel {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
right: 0;
|
||||
width: 280px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255,255,255,0.98);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
z-index: 10;
|
||||
display: none;
|
||||
}
|
||||
.header-update-title {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-user-id="{{ user_id or '' }}">
|
||||
<header class="header">
|
||||
<span class="header-title">数富AI</span>
|
||||
<div class="header-right">
|
||||
{% if is_admin %}
|
||||
<!-- <a href="/admin" class="admin-link">用户管理</a>-->
|
||||
{% endif %}
|
||||
<span>{{ username }}</span>
|
||||
<button type="button" class="header-update-btn" id="homeUpdateToggle" title="检测更新">↻</button>
|
||||
<a href="/logout" onclick="return handleLogout(event);">退出</a>
|
||||
<div class="header-update-panel" id="homeUpdatePanel">
|
||||
<div class="header-update-title"><span>↻</span> 软件更新</div>
|
||||
<div class="update-block">
|
||||
<span class="update-version-text" id="homeVersionText">当前版本: {{ version }}</span>
|
||||
<button type="button" class="btn-check-update" id="homeBtnCheckUpdate"><span>↻</span> 检测</button>
|
||||
</div>
|
||||
<div class="update-hint" id="homeUpdateHint"></div>
|
||||
<div style="margin-top: 4px;">
|
||||
<button type="button" class="btn-download-update" id="homeBtnDownloadUpdate" style="display: none;">立即更新</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="entrances" id="entrances">
|
||||
<a class="entrance-btn" href="/brand" data-column-key="brand">亚马逊</a>
|
||||
<a class="entrance-btn disabled" href="javascript:;" data-msg="暂未开通,敬请期待" data-column-key="wb">wildberries</a>
|
||||
<a class="entrance-btn image" href="/image" data-column-key="image">图片</a>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
function getAppPermissionCacheKey(uid) {
|
||||
return 'app_column_permissions:' + String(uid || '');
|
||||
}
|
||||
// 权限校验:根据 Java column-permissions 接口按 column_key 显示入口
|
||||
(function() {
|
||||
localStorage.setItem("uid",{{ user_id }})
|
||||
var uid = document.body.getAttribute('data-user-id');
|
||||
if (!uid) return;
|
||||
var cacheKey = getAppPermissionCacheKey(uid);
|
||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
||||
var token = '';
|
||||
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
|
||||
if (!JAVA_API_BASE || !token) return;
|
||||
var apiUrl = JAVA_API_BASE + '/api/admin/permission-users/' + uid + '/column-permissions?menuType=app';
|
||||
fetch(apiUrl, {
|
||||
credentials: 'include',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (!res || !res.success || !Array.isArray(res.data)) return;
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(res.data));
|
||||
} catch (e) {}
|
||||
var allowedKeys = res.data.map(function(item) { return (item.columnKey || item.column_key || '').toLowerCase(); });
|
||||
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function(btn) {
|
||||
var key = (btn.getAttribute('data-column-key') || '').toLowerCase();
|
||||
btn.style.display = allowedKeys.indexOf(key) >= 0 ? '' : 'none';
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
})();
|
||||
|
||||
document.querySelectorAll('.entrance-btn.disabled').forEach(function(btn) {
|
||||
btn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
var msg = btn.getAttribute('data-msg') || '暂未开通,敬请期待';
|
||||
var t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(function() { t.classList.remove('show'); }, 2000);
|
||||
};
|
||||
});
|
||||
|
||||
function handleLogout(ev) {
|
||||
if (ev && ev.preventDefault) ev.preventDefault();
|
||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||
var JAVA_API_BASE = "{{ java_api_base or '' }}".replace(/\/+$/, '');
|
||||
var token = '';
|
||||
try { token = localStorage.getItem(AUTH_TOKEN_KEY) || ''; } catch (e) {}
|
||||
|
||||
function cleanLocal() {
|
||||
try {
|
||||
localStorage.removeItem('maixiang_api_key');
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
var uid = document.body.getAttribute('data-user-id');
|
||||
if (uid) {
|
||||
localStorage.removeItem(getAppPermissionCacheKey(uid));
|
||||
}
|
||||
} catch (e) { console.log(e); }
|
||||
}
|
||||
|
||||
function gotoPythonLogout() {
|
||||
cleanLocal();
|
||||
window.location.href = '/logout';
|
||||
}
|
||||
|
||||
if (!JAVA_API_BASE || !token) {
|
||||
gotoPythonLogout();
|
||||
return false;
|
||||
}
|
||||
// 先让 Java 端清 cookie/会话,再走 Python /logout 清 Python cookie
|
||||
var headers = { 'Authorization': 'Bearer ' + token };
|
||||
try {
|
||||
fetch(JAVA_API_BASE + '/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: headers
|
||||
}).then(gotoPythonLogout).catch(gotoPythonLogout);
|
||||
} catch (e) {
|
||||
gotoPythonLogout();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 软件更新(与 index 设置中逻辑一致)
|
||||
(function() {
|
||||
const versionText = document.getElementById('homeVersionText');
|
||||
const updateHint = document.getElementById('homeUpdateHint');
|
||||
const btnDownload = document.getElementById('homeBtnDownloadUpdate');
|
||||
const updatePanel = document.getElementById('homeUpdatePanel');
|
||||
const toggleBtn = document.getElementById('homeUpdateToggle');
|
||||
if (!versionText || !updateHint || !btnDownload || !updatePanel || !toggleBtn) return;
|
||||
|
||||
let currentVersion = '{{version}}';
|
||||
versionText.textContent = '当前版本: v' + currentVersion;
|
||||
|
||||
// 打开/关闭浮层
|
||||
toggleBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const isVisible = updatePanel.style.display === 'block';
|
||||
updatePanel.style.display = isVisible ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// 点击外部关闭浮层
|
||||
document.addEventListener('click', function() {
|
||||
updatePanel.style.display = 'none';
|
||||
});
|
||||
updatePanel.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
document.getElementById('homeBtnCheckUpdate').onclick = async function() {
|
||||
updateHint.textContent = '正在检测更新...';
|
||||
btnDownload.style.display = 'none';
|
||||
try {
|
||||
const resp = await fetch('/api/version', { credentials: 'same-origin' }).catch(function() { return null; });
|
||||
if (resp && resp.ok) {
|
||||
const data = await resp.json();
|
||||
currentVersion = (data.version || currentVersion).replace(/^v/i, '');
|
||||
versionText.textContent = '当前版本: v' + currentVersion;
|
||||
if (data.has_update && data.latest_version) {
|
||||
updateHint.textContent = '发现新版本 v' + data.latest_version + (data.desc ? ':' + data.desc : '');
|
||||
btnDownload.style.display = 'inline-block';
|
||||
btnDownload.textContent = '立即更新';
|
||||
btnDownload.onclick = async function() {
|
||||
if (!confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')) return;
|
||||
if (!data.file_url) {
|
||||
updateHint.textContent = '暂无下载地址,请关注官方渠道。';
|
||||
return;
|
||||
}
|
||||
updateHint.textContent = '正在下载并准备更新,程序将自动退出...';
|
||||
btnDownload.disabled = true;
|
||||
try {
|
||||
const updateResp = await fetch('/api/update/do', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_url: data.file_url })
|
||||
});
|
||||
const result = await updateResp.json().catch(function() { return {}; });
|
||||
if (result.success) {
|
||||
updateHint.textContent = '更新已启动,程序即将退出...';
|
||||
} else {
|
||||
updateHint.textContent = result.error || '更新启动失败';
|
||||
btnDownload.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
updateHint.textContent = '请求更新失败,请重试';
|
||||
btnDownload.disabled = false;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
updateHint.textContent = '已是最新版本';
|
||||
}
|
||||
} else {
|
||||
updateHint.textContent = '无法连接更新服务,请稍后重试。';
|
||||
}
|
||||
} catch (e) {
|
||||
updateHint.textContent = '检测更新失败';
|
||||
}
|
||||
};
|
||||
btnDownload.style.display = 'none';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
5833
app/web_source/index.html
Normal file
5833
app/web_source/index.html
Normal file
File diff suppressed because it is too large
Load Diff
263
app/web_source/login.html
Normal file
263
app/web_source/login.html
Normal file
@@ -0,0 +1,263 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - 南日AI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
|
||||
background: #d8e4ec;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
.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 { font-size: 18px; font-weight: 600; color: #333; }
|
||||
.login-box {
|
||||
background: #fff;
|
||||
border: 1px solid #b8d4e3;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #b8d4e3;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
background: #fff;
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: #3498db;
|
||||
}
|
||||
.error-msg {
|
||||
color: #e74c3c;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-login:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-login:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<span class="header-title">南日AI</span>
|
||||
</header>
|
||||
|
||||
<div class="login-box">
|
||||
<h1 class="login-title">登录</h1>
|
||||
{% if error %}
|
||||
<p class="error-msg">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form id="loginForm" method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" placeholder="请输入用户名" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login" id="btnLogin">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
var JAVA_API_BASE = "{{ java_api_base or '' }}";
|
||||
var AUTH_TOKEN_KEY = 'aiimage_auth_token';
|
||||
var DEVICE_ID_KEY = 'aiimage_device_id';
|
||||
|
||||
function clearAppPermissionCaches() {
|
||||
try {
|
||||
var keysToRemove = [];
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var key = localStorage.key(i);
|
||||
if (key && key.indexOf('app_column_permissions:') === 0) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(function(key) {
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function waitForPywebview(timeoutMs) {
|
||||
return new Promise(function(resolve) {
|
||||
if (window.pywebview && window.pywebview.api && window.pywebview.api.get_device_id) {
|
||||
resolve(window.pywebview.api);
|
||||
return;
|
||||
}
|
||||
var done = false;
|
||||
var finish = function() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
var api = (window.pywebview && window.pywebview.api) || null;
|
||||
resolve(api);
|
||||
};
|
||||
window.addEventListener('pywebviewready', finish, { once: true });
|
||||
setTimeout(finish, timeoutMs || 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchDeviceId() {
|
||||
return waitForPywebview(2000).then(function(api) {
|
||||
if (!api || typeof api.get_device_id !== 'function') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
var ret = api.get_device_id();
|
||||
return Promise.resolve(ret).then(function(res) {
|
||||
if (res && res.success && res.device_id) {
|
||||
try { localStorage.setItem(DEVICE_ID_KEY, res.device_id); } catch (e) {}
|
||||
return res.device_id;
|
||||
}
|
||||
return '';
|
||||
}).catch(function() { return ''; });
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildJavaUrl(path) {
|
||||
var base = (JAVA_API_BASE || '').replace(/\/+$/, '');
|
||||
return base + path;
|
||||
}
|
||||
|
||||
(function() {
|
||||
var params = new URLSearchParams(window.location.search || '');
|
||||
if (params.get('logout') === '1' || params.get('switch') === '1') {
|
||||
try { localStorage.removeItem(AUTH_TOKEN_KEY); } catch (e) {}
|
||||
}
|
||||
})();
|
||||
|
||||
document.getElementById('loginForm').onsubmit = function(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('btnLogin');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '登录中...';
|
||||
var form = e.target;
|
||||
var username = (form.username.value || '').trim();
|
||||
var password = form.password.value || '';
|
||||
|
||||
function showError(msg) {
|
||||
var errEl = document.querySelector('.error-msg');
|
||||
if (!errEl) {
|
||||
errEl = document.createElement('p');
|
||||
errEl.className = 'error-msg';
|
||||
form.insertBefore(errEl, form.firstChild);
|
||||
}
|
||||
errEl.textContent = msg || '登录失败';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '登录';
|
||||
}
|
||||
|
||||
fetchDeviceId().then(function(deviceId) {
|
||||
if (!deviceId) {
|
||||
showError('未获取到设备ID,请在桌面端打开');
|
||||
return;
|
||||
}
|
||||
var payload = { username: username, password: password, deviceId: deviceId };
|
||||
return fetch(buildJavaUrl('/login'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Device-Id': deviceId,
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(res) {
|
||||
if (!res || !res.success) {
|
||||
showError((res && res.message) || '登录失败');
|
||||
return;
|
||||
}
|
||||
var data = res.data || {};
|
||||
if (data.token) {
|
||||
try { localStorage.setItem(AUTH_TOKEN_KEY, data.token); } catch (e) {}
|
||||
}
|
||||
// 把 Java 签发的 JWT 同步到 Python 套壳同源 cookie,供后续页面跳转携带
|
||||
return fetch('/api/auth/sync', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({ token: data.token })
|
||||
})
|
||||
.then(function(r) { return r.json().catch(function() { return {}; }); })
|
||||
.then(function() {
|
||||
clearAppPermissionCaches();
|
||||
window.location.href = '/home';
|
||||
})
|
||||
.catch(function() {
|
||||
clearAppPermissionCaches();
|
||||
window.location.href = '/home';
|
||||
});
|
||||
})
|
||||
.catch(function() {
|
||||
showError('网络异常,请稍后重试');
|
||||
});
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -26,6 +26,7 @@
|
||||
<rocketmq-spring.version>2.3.5</rocketmq-spring.version>
|
||||
<minio.version>8.5.17</minio.version>
|
||||
<javassist.version>3.28.0-GA</javassist.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
@@ -104,6 +105,23 @@
|
||||
<artifactId>minio</artifactId>
|
||||
<version>${minio.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
|
||||
@@ -31,7 +31,7 @@ public class SecurityConfig {
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setExposedHeaders(List.of("Content-Disposition"));
|
||||
configuration.setAllowCredentials(false);
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.nanri.aiimage.modules.admin.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
||||
import com.nanri.aiimage.modules.admin.service.AdminUserService;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin")
|
||||
@Tag(name = "管理后台-用户", description = "用户列表、创建、更新、删除")
|
||||
public class AdminUserController {
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final AdminUserService adminUserService;
|
||||
|
||||
@GetMapping("/users")
|
||||
@Operation(summary = "分页查询用户列表")
|
||||
public ApiResponse<AdminUserListVo> listUsers(HttpServletRequest request,
|
||||
@RequestParam(required = false, defaultValue = "1") Integer page,
|
||||
@RequestParam(name = "page_size", required = false, defaultValue = "15") Integer pageSize,
|
||||
@RequestParam(required = false) String username,
|
||||
@RequestParam(name = "search", required = false) String search,
|
||||
@RequestParam(name = "created_by_id", required = false) Long createdById,
|
||||
@RequestParam(name = "admin_id", required = false) Long adminId) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
String kw = (username == null || username.isBlank()) ? search : username;
|
||||
Long filterCreatedBy = createdById != null ? createdById : adminId;
|
||||
AdminUserListVo vo = adminUserService.listUsers(currentUser, page, pageSize, kw, filterCreatedBy);
|
||||
return ApiResponse.success(vo);
|
||||
}
|
||||
|
||||
@PostMapping("/user")
|
||||
@Operation(summary = "创建用户")
|
||||
public ApiResponse<Void> createUser(HttpServletRequest request,
|
||||
@RequestBody AdminUserCreateRequest body) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
adminUserService.createUser(currentUser, body);
|
||||
return ApiResponse.success("用户创建成功", null);
|
||||
}
|
||||
|
||||
@PutMapping("/user/{uid}")
|
||||
@Operation(summary = "更新用户")
|
||||
public ApiResponse<Void> updateUser(HttpServletRequest request,
|
||||
@PathVariable Long uid,
|
||||
@RequestBody AdminUserUpdateRequest body) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
adminUserService.updateUser(currentUser, uid, body);
|
||||
return ApiResponse.success("更新成功", null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/user/{uid}")
|
||||
@Operation(summary = "删除用户")
|
||||
public ApiResponse<Void> deleteUser(HttpServletRequest request,
|
||||
@PathVariable Long uid) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request);
|
||||
adminUserService.deleteUser(currentUser, uid);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.admin.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminUserCreateRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
private String role;
|
||||
private Long createdById;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.admin.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminUserUpdateRequest {
|
||||
private String password;
|
||||
private String role;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.admin.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AdminBriefVo {
|
||||
private Long id;
|
||||
private String username;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.admin.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AdminUserItemVo {
|
||||
private Long id;
|
||||
private String username;
|
||||
@JsonProperty("is_admin")
|
||||
private boolean admin;
|
||||
private String role;
|
||||
@JsonProperty("created_by_id")
|
||||
private Long createdById;
|
||||
@JsonProperty("creator_username")
|
||||
private String creatorUsername;
|
||||
@JsonProperty("created_at")
|
||||
private String createdAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.nanri.aiimage.modules.admin.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class AdminUserListVo {
|
||||
private List<AdminUserItemVo> items;
|
||||
private Long total;
|
||||
private Integer page;
|
||||
@JsonProperty("page_size")
|
||||
private Integer pageSize;
|
||||
@JsonProperty("current_user_role")
|
||||
private String currentUserRole;
|
||||
private List<AdminBriefVo> admins;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.nanri.aiimage.modules.admin.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminBriefVo;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserItemVo;
|
||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AdminUserService {
|
||||
|
||||
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final WerkzeugPasswordEncoder passwordEncoder;
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
|
||||
public AdminUserListVo listUsers(AdminUserEntity currentUser, Integer page, Integer pageSize,
|
||||
String username, Long createdById) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
int safePage = page == null || page < 1 ? 1 : page;
|
||||
int safeSize = pageSize == null ? 15 : pageSize;
|
||||
if (safeSize < 5) safeSize = 5;
|
||||
if (safeSize > 50) safeSize = 50;
|
||||
|
||||
String kw = username == null ? "" : username.trim();
|
||||
Long filterCreatedBy = "super_admin".equals(role) ? createdById : null;
|
||||
|
||||
LambdaQueryWrapper<AdminUserEntity> query = new LambdaQueryWrapper<>();
|
||||
if ("super_admin".equals(role)) {
|
||||
if (!kw.isEmpty()) {
|
||||
query.like(AdminUserEntity::getUsername, kw);
|
||||
}
|
||||
if (filterCreatedBy != null) {
|
||||
query.eq(AdminUserEntity::getCreatedById, filterCreatedBy);
|
||||
}
|
||||
} else {
|
||||
Long adminId = currentUser.getId();
|
||||
query.and(w -> w
|
||||
.eq(AdminUserEntity::getId, adminId)
|
||||
.or(inner -> inner
|
||||
.eq(AdminUserEntity::getRole, "normal")
|
||||
.eq(AdminUserEntity::getCreatedById, adminId)));
|
||||
if (!kw.isEmpty()) {
|
||||
query.like(AdminUserEntity::getUsername, kw);
|
||||
}
|
||||
}
|
||||
query.orderByAsc(AdminUserEntity::getId);
|
||||
|
||||
Long total = adminUserMapper.selectCount(query);
|
||||
int offset = (safePage - 1) * safeSize;
|
||||
query.last("LIMIT " + safeSize + " OFFSET " + offset);
|
||||
List<AdminUserEntity> rows = adminUserMapper.selectList(query);
|
||||
|
||||
Map<Long, String> creatorMap = loadCreatorMap(rows);
|
||||
|
||||
List<AdminUserItemVo> items = rows.stream().map(r -> toItem(r, creatorMap)).toList();
|
||||
List<AdminBriefVo> admins = "super_admin".equals(role)
|
||||
? adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getRole, "admin")
|
||||
.orderByAsc(AdminUserEntity::getId)).stream()
|
||||
.map(u -> new AdminBriefVo(u.getId(), u.getUsername()))
|
||||
.toList()
|
||||
: List.of();
|
||||
|
||||
AdminUserListVo vo = new AdminUserListVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(total == null ? 0L : total);
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safeSize);
|
||||
vo.setCurrentUserRole(role);
|
||||
vo.setAdmins(admins);
|
||||
return vo;
|
||||
}
|
||||
|
||||
public void createUser(AdminUserEntity currentUser, AdminUserCreateRequest request) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
String username = request.getUsername() == null ? "" : request.getUsername().trim();
|
||||
String password = request.getPassword() == null ? "" : request.getPassword();
|
||||
String wantRole = request.getRole() == null ? "normal" : request.getRole().trim();
|
||||
if (wantRole.isEmpty()) wantRole = "normal";
|
||||
if (!"admin".equals(wantRole) && !"normal".equals(wantRole)) {
|
||||
wantRole = "normal";
|
||||
}
|
||||
if ("admin".equals(role) && "admin".equals(wantRole)) {
|
||||
throw new BusinessException("仅超级管理员可创建管理员");
|
||||
}
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
throw new BusinessException("用户名和密码不能为空");
|
||||
}
|
||||
if (username.length() < 2) {
|
||||
throw new BusinessException("用户名至少2个字符");
|
||||
}
|
||||
if (password.length() < 6) {
|
||||
throw new BusinessException("密码至少6个字符");
|
||||
}
|
||||
|
||||
Long wantCreatedBy;
|
||||
if ("admin".equals(wantRole)) {
|
||||
wantCreatedBy = currentUser.getId();
|
||||
} else if ("super_admin".equals(role)) {
|
||||
wantCreatedBy = request.getCreatedById();
|
||||
if (wantCreatedBy == null) {
|
||||
AdminUserEntity firstAdmin = adminUserMapper.selectOne(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getRole, "admin")
|
||||
.orderByAsc(AdminUserEntity::getId)
|
||||
.last("LIMIT 1"));
|
||||
wantCreatedBy = firstAdmin != null ? firstAdmin.getId() : currentUser.getId();
|
||||
}
|
||||
} else {
|
||||
wantCreatedBy = currentUser.getId();
|
||||
}
|
||||
|
||||
int isAdmin = ("super_admin".equals(wantRole) || "admin".equals(wantRole)) ? 1 : 0;
|
||||
AdminUserEntity entity = new AdminUserEntity();
|
||||
entity.setUsername(username);
|
||||
entity.setPasswordHash(passwordEncoder.hash(password));
|
||||
entity.setIsAdmin(isAdmin);
|
||||
entity.setRole(wantRole);
|
||||
entity.setCreatedById(wantCreatedBy);
|
||||
try {
|
||||
adminUserMapper.insert(entity);
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw new BusinessException("用户名已存在");
|
||||
}
|
||||
}
|
||||
|
||||
public void updateUser(AdminUserEntity currentUser, Long uid, AdminUserUpdateRequest request) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
String password = request.getPassword();
|
||||
String wantRole = request.getRole() == null ? null : request.getRole().trim();
|
||||
|
||||
if ((password == null || password.isEmpty()) && (wantRole == null || wantRole.isEmpty())) {
|
||||
throw new BusinessException("请提供要修改的内容");
|
||||
}
|
||||
|
||||
AdminUserEntity target = adminUserMapper.selectById(uid);
|
||||
if (target == null) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
String targetRole = target.getRole() == null ? "" : target.getRole();
|
||||
|
||||
if ("admin".equals(role)) {
|
||||
if (!"normal".equals(targetRole) || !currentUser.getId().equals(target.getCreatedById())) {
|
||||
throw new BusinessException(403, "只能编辑自己创建的普通用户");
|
||||
}
|
||||
wantRole = null;
|
||||
} else {
|
||||
if ("super_admin".equals(targetRole)) {
|
||||
throw new BusinessException("不能修改超级管理员");
|
||||
}
|
||||
if ("super_admin".equals(wantRole)) {
|
||||
throw new BusinessException("不能将用户设为超级管理员");
|
||||
}
|
||||
if (wantRole != null && !"admin".equals(wantRole) && !"normal".equals(wantRole) && !wantRole.isEmpty()) {
|
||||
wantRole = null;
|
||||
}
|
||||
}
|
||||
|
||||
LambdaUpdateWrapper<AdminUserEntity> update = new LambdaUpdateWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getId, uid);
|
||||
boolean dirty = false;
|
||||
if (password != null && !password.isEmpty()) {
|
||||
if (password.length() < 6) {
|
||||
throw new BusinessException("密码至少6个字符");
|
||||
}
|
||||
update.set(AdminUserEntity::getPasswordHash, passwordEncoder.hash(password));
|
||||
dirty = true;
|
||||
}
|
||||
if (wantRole != null && !wantRole.isEmpty()) {
|
||||
int isAdmin = "admin".equals(wantRole) ? 1 : 0;
|
||||
update.set(AdminUserEntity::getIsAdmin, isAdmin);
|
||||
update.set(AdminUserEntity::getRole, wantRole);
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) {
|
||||
adminUserMapper.update(null, update);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteUser(AdminUserEntity currentUser, Long uid) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
if (currentUser.getId().equals(uid)) {
|
||||
throw new BusinessException("不能删除当前登录账号");
|
||||
}
|
||||
AdminUserEntity target = adminUserMapper.selectById(uid);
|
||||
if (target == null) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
String targetRole = target.getRole() == null ? "" : target.getRole();
|
||||
if ("super_admin".equals(targetRole)) {
|
||||
throw new BusinessException("不能删除超级管理员");
|
||||
}
|
||||
if ("admin".equals(role)) {
|
||||
if (!"normal".equals(targetRole) || !currentUser.getId().equals(target.getCreatedById())) {
|
||||
throw new BusinessException(403, "只能删除自己创建的普通用户");
|
||||
}
|
||||
}
|
||||
int affected = adminUserMapper.deleteById(uid);
|
||||
if (affected == 0) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, String> loadCreatorMap(List<AdminUserEntity> rows) {
|
||||
Set<Long> creatorIds = new LinkedHashSet<>();
|
||||
for (AdminUserEntity r : rows) {
|
||||
if (r.getCreatedById() != null && r.getCreatedById() > 0) {
|
||||
creatorIds.add(r.getCreatedById());
|
||||
}
|
||||
}
|
||||
if (creatorIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<AdminUserEntity> creators = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.in(AdminUserEntity::getId, new ArrayList<>(creatorIds)));
|
||||
Map<Long, String> map = new HashMap<>(creators.size() * 2);
|
||||
for (AdminUserEntity c : creators) {
|
||||
map.put(c.getId(), c.getUsername() == null ? "" : c.getUsername());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private AdminUserItemVo toItem(AdminUserEntity entity, Map<Long, String> creatorMap) {
|
||||
AdminUserItemVo vo = new AdminUserItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setUsername(entity.getUsername());
|
||||
vo.setAdmin(entity.getIsAdmin() != null && entity.getIsAdmin() == 1);
|
||||
vo.setRole(entity.getRole() == null || entity.getRole().isEmpty() ? "normal" : entity.getRole());
|
||||
vo.setCreatedById(entity.getCreatedById());
|
||||
vo.setCreatorUsername(creatorMap.getOrDefault(entity.getCreatedById(), ""));
|
||||
LocalDateTime createdAt = entity.getCreatedAt();
|
||||
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.nanri.aiimage.modules.admin.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.auth.service.JwtService;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AdminAuthSupport {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final AuthProperties authProperties;
|
||||
|
||||
/** 解析当前请求的用户;token 缺失或无效抛 401。 */
|
||||
public AdminUserEntity requireUser(HttpServletRequest request) {
|
||||
String token = resolveToken(request);
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new BusinessException(401, "未登录");
|
||||
}
|
||||
Claims claims = jwtService.parse(token);
|
||||
if (claims == null) {
|
||||
throw new BusinessException(401, "登录已过期,请重新登录");
|
||||
}
|
||||
Long userId;
|
||||
try {
|
||||
userId = Long.parseLong(claims.getSubject());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException(401, "登录态无效");
|
||||
}
|
||||
AdminUserEntity user = adminUserMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new BusinessException(401, "用户不存在");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 当前用户必须是管理员或超级管理员,否则抛 403。 */
|
||||
public AdminUserEntity requireAdmin(HttpServletRequest request) {
|
||||
AdminUserEntity user = requireUser(request);
|
||||
String role = currentRole(user);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 计算当前用户的管理角色:super_admin / admin / null。 */
|
||||
public String currentRole(AdminUserEntity user) {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
String storedRole = user.getRole() == null ? "" : user.getRole().trim().toLowerCase();
|
||||
if ("super_admin".equals(storedRole)) {
|
||||
return "super_admin";
|
||||
}
|
||||
if ("admin".equals(storedRole)) {
|
||||
return resolveAdminRole(user);
|
||||
}
|
||||
boolean isAdminFlag = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
if (isAdminFlag) {
|
||||
return resolveAdminRole(user);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 数据库里 role='admin' 中 id 最小者视作超级管理员(与 Python 行为一致)。 */
|
||||
private String resolveAdminRole(AdminUserEntity user) {
|
||||
AdminUserEntity superAdmin = adminUserMapper.selectOne(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getRole, "admin")
|
||||
.orderByAsc(AdminUserEntity::getId)
|
||||
.last("LIMIT 1"));
|
||||
if (superAdmin != null && superAdmin.getId().equals(user.getId())) {
|
||||
return "super_admin";
|
||||
}
|
||||
return "admin";
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String t = authHeader.substring(7).trim();
|
||||
if (!t.isEmpty()) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
String cookieName = authProperties.getCookieName();
|
||||
for (Cookie cookie : cookies) {
|
||||
if (cookieName.equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,10 @@ public class AppearancePatentCozeClient {
|
||||
parameters.put("title_list", titles);
|
||||
parameters.put("url_list", urls);
|
||||
parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, titles, urls));
|
||||
parameters.put("prompt", prompt == null ? "" : prompt);
|
||||
// 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。
|
||||
if (prompt != null && !prompt.isBlank()) {
|
||||
parameters.put("prompt", prompt);
|
||||
}
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
parameters.put("api_key", apiKey.trim());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.auth.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "aiimage.auth")
|
||||
public class AuthProperties {
|
||||
|
||||
private String jwtSecret = "please-change-this-secret-please-rotate-at-least-32-bytes";
|
||||
private long jwtTtlHours = 168L;
|
||||
private String cookieName = "aiimage_token";
|
||||
private boolean cookieSecure = false;
|
||||
private String cookieSameSite = "Lax";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.nanri.aiimage.modules.auth.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
|
||||
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
|
||||
import com.nanri.aiimage.modules.auth.service.AuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "登录认证", description = "桌面端登录与会话校验")
|
||||
public class LoginController {
|
||||
|
||||
private static final String DEVICE_ID_HEADER = "X-Device-Id";
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "用户名密码登录")
|
||||
public ResponseEntity<ApiResponse<LoginResultVo>> login(@RequestBody LoginRequest request) {
|
||||
LoginResultVo result = authService.login(request);
|
||||
ResponseCookie cookie = authService.buildAuthCookie(result.getToken());
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||
.body(ApiResponse.success("登录成功", result));
|
||||
}
|
||||
|
||||
@GetMapping("/check_login")
|
||||
@Operation(summary = "校验登录态")
|
||||
public ResponseEntity<ApiResponse<LoginResultVo>> checkLogin(
|
||||
HttpServletRequest httpRequest,
|
||||
@RequestHeader(value = DEVICE_ID_HEADER, required = false) String deviceIdHeader) {
|
||||
String token = resolveToken(httpRequest);
|
||||
LoginResultVo result = authService.checkLogin(token, deviceIdHeader);
|
||||
ResponseCookie cookie = authService.buildAuthCookie(result.getToken());
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||
.body(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
@Operation(summary = "登出")
|
||||
public ResponseEntity<ApiResponse<Void>> logout() {
|
||||
ResponseCookie cookie = authService.buildClearCookie();
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||
.body(ApiResponse.success("已登出", null));
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
return authHeader.substring(7).trim();
|
||||
}
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
String cookieName = authService.cookieName();
|
||||
for (Cookie cookie : cookies) {
|
||||
if (cookieName.equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.auth.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface LoginUserMapper extends BaseMapper<LoginUserEntity> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.auth.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LoginRequest {
|
||||
|
||||
private String username;
|
||||
private String password;
|
||||
private String deviceId;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.nanri.aiimage.modules.auth.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@TableName("users")
|
||||
public class LoginUserEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String username;
|
||||
@TableField("password_hash")
|
||||
private String passwordHash;
|
||||
private String machine;
|
||||
@TableField("is_admin")
|
||||
private Integer isAdmin;
|
||||
private String role;
|
||||
@TableField("created_by_id")
|
||||
private Long createdById;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.auth.model.vo;
|
||||
|
||||
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class LoginResultVo {
|
||||
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String role;
|
||||
private Boolean isAdmin;
|
||||
private String deviceId;
|
||||
private String token;
|
||||
private Long expiresIn;
|
||||
private List<PermissionMenuItemVo> appColumns;
|
||||
private List<PermissionMenuItemVo> adminColumns;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.nanri.aiimage.modules.auth.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
import com.nanri.aiimage.modules.auth.mapper.LoginUserMapper;
|
||||
import com.nanri.aiimage.modules.auth.model.dto.LoginRequest;
|
||||
import com.nanri.aiimage.modules.auth.model.entity.LoginUserEntity;
|
||||
import com.nanri.aiimage.modules.auth.model.vo.LoginResultVo;
|
||||
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AuthService {
|
||||
|
||||
private final LoginUserMapper loginUserMapper;
|
||||
private final WerkzeugPasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final PermissionMenuService permissionMenuService;
|
||||
private final AuthProperties authProperties;
|
||||
|
||||
public LoginResultVo login(LoginRequest request) {
|
||||
String username = trim(request.getUsername());
|
||||
String password = request.getPassword() == null ? "" : request.getPassword();
|
||||
String deviceId = trim(request.getDeviceId());
|
||||
if (username.isEmpty() || password.isEmpty()) {
|
||||
throw new BusinessException("请输入用户名和密码");
|
||||
}
|
||||
if (deviceId.isEmpty()) {
|
||||
throw new BusinessException("缺少设备ID,请在桌面端打开");
|
||||
}
|
||||
|
||||
LoginUserEntity user = loginUserMapper.selectOne(new LambdaQueryWrapper<LoginUserEntity>()
|
||||
.eq(LoginUserEntity::getUsername, username)
|
||||
.last("LIMIT 1"));
|
||||
if (user == null || !passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
throw new BusinessException("用户名或密码错误");
|
||||
}
|
||||
|
||||
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
|
||||
if (stored.isEmpty()) {
|
||||
loginUserMapper.update(null, new LambdaUpdateWrapper<LoginUserEntity>()
|
||||
.eq(LoginUserEntity::getId, user.getId())
|
||||
.set(LoginUserEntity::getMachine, deviceId));
|
||||
stored = deviceId;
|
||||
log.info("[auth] first-login bind userId={} device={}", user.getId(), deviceId);
|
||||
} else if (!stored.equals(deviceId)) {
|
||||
if (isAdmin) {
|
||||
log.warn("[auth] device mismatch but admin bypass userId={} stored={} current={}",
|
||||
user.getId(), stored, deviceId);
|
||||
} else {
|
||||
log.warn("[auth] device mismatch reject userId={} stored={} current={}",
|
||||
user.getId(), stored, deviceId);
|
||||
throw new BusinessException("当前设备与首次登录设备不一致,请在原设备上登录");
|
||||
}
|
||||
} else {
|
||||
log.info("[auth] device match userId={} isAdmin={} device={}",
|
||||
user.getId(), isAdmin, deviceId);
|
||||
}
|
||||
|
||||
return buildResult(user, stored, isAdmin);
|
||||
}
|
||||
|
||||
public LoginResultVo checkLogin(String token, String currentDeviceId) {
|
||||
if (token == null || token.isBlank()) {
|
||||
throw new BusinessException(401, "未登录");
|
||||
}
|
||||
Claims claims = jwtService.parse(token);
|
||||
if (claims == null) {
|
||||
throw new BusinessException(401, "登录已过期,请重新登录");
|
||||
}
|
||||
Long userId;
|
||||
try {
|
||||
userId = Long.parseLong(claims.getSubject());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new BusinessException(401, "登录态无效");
|
||||
}
|
||||
LoginUserEntity user = loginUserMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new BusinessException(401, "用户不存在");
|
||||
}
|
||||
boolean isAdmin = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
||||
String stored = user.getMachine() == null ? "" : user.getMachine().trim();
|
||||
String device = trim(currentDeviceId);
|
||||
if (device.isEmpty()) {
|
||||
// 没传设备 ID 时,回落到 token 内 deviceId
|
||||
Object claimDevice = claims.get("deviceId");
|
||||
device = claimDevice == null ? "" : claimDevice.toString().trim();
|
||||
}
|
||||
if (!stored.isEmpty() && !device.isEmpty() && !stored.equals(device)) {
|
||||
if (isAdmin) {
|
||||
log.warn("[auth] check_login device mismatch but admin bypass userId={} stored={} current={}",
|
||||
user.getId(), stored, device);
|
||||
} else {
|
||||
log.warn("[auth] check_login device mismatch reject userId={} stored={} current={}",
|
||||
user.getId(), stored, device);
|
||||
throw new BusinessException(401, "当前设备与首次登录设备不一致");
|
||||
}
|
||||
}
|
||||
return buildResult(user, stored.isEmpty() ? device : stored, isAdmin);
|
||||
}
|
||||
|
||||
public ResponseCookie buildAuthCookie(String token) {
|
||||
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(authProperties.getCookieName(), token)
|
||||
.httpOnly(true)
|
||||
.path("/")
|
||||
.maxAge(Duration.ofSeconds(jwtService.ttlSeconds()))
|
||||
.sameSite(authProperties.getCookieSameSite());
|
||||
if (authProperties.isCookieSecure()) {
|
||||
builder.secure(true);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public ResponseCookie buildClearCookie() {
|
||||
return ResponseCookie.from(authProperties.getCookieName(), "")
|
||||
.httpOnly(true)
|
||||
.path("/")
|
||||
.maxAge(Duration.ZERO)
|
||||
.sameSite(authProperties.getCookieSameSite())
|
||||
.secure(authProperties.isCookieSecure())
|
||||
.build();
|
||||
}
|
||||
|
||||
public String cookieName() {
|
||||
return authProperties.getCookieName();
|
||||
}
|
||||
|
||||
private LoginResultVo buildResult(LoginUserEntity user, String deviceId, boolean isAdmin) {
|
||||
String token = jwtService.issue(user.getId(), user.getUsername(), deviceId);
|
||||
LoginResultVo vo = new LoginResultVo();
|
||||
vo.setUserId(user.getId());
|
||||
vo.setUsername(user.getUsername());
|
||||
vo.setRole(user.getRole());
|
||||
vo.setIsAdmin(isAdmin);
|
||||
vo.setDeviceId(deviceId);
|
||||
vo.setToken(token);
|
||||
vo.setExpiresIn(jwtService.ttlSeconds());
|
||||
vo.setAppColumns(permissionMenuService.getUserColumnPermissions(user.getId(), PermissionMenuService.MENU_TYPE_APP));
|
||||
vo.setAdminColumns(permissionMenuService.getUserColumnPermissions(user.getId(), PermissionMenuService.MENU_TYPE_ADMIN));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.nanri.aiimage.modules.auth.service;
|
||||
|
||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JwtService {
|
||||
|
||||
private final AuthProperties props;
|
||||
|
||||
private SecretKey signingKey() {
|
||||
byte[] keyBytes = props.getJwtSecret().getBytes(StandardCharsets.UTF_8);
|
||||
if (keyBytes.length < 32) {
|
||||
byte[] padded = new byte[32];
|
||||
System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length);
|
||||
keyBytes = padded;
|
||||
}
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
|
||||
public String issue(Long userId, String username, String deviceId) {
|
||||
Instant now = Instant.now();
|
||||
Instant exp = now.plus(Duration.ofHours(props.getJwtTtlHours()));
|
||||
return Jwts.builder()
|
||||
.subject(String.valueOf(userId))
|
||||
.claim("username", username)
|
||||
.claim("deviceId", deviceId)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(exp))
|
||||
.signWith(signingKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
try {
|
||||
return Jwts.parser()
|
||||
.verifyWith(signingKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
log.debug("[auth] jwt parse failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public long ttlSeconds() {
|
||||
return Duration.ofHours(props.getJwtTtlHours()).toSeconds();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.nanri.aiimage.modules.auth.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* 兼容 Werkzeug `generate_password_hash` 的 PBKDF2-HMAC-SHA256 输出格式。
|
||||
* 形如:pbkdf2:sha256:iterations$salt$hexdigest(Werkzeug 默认 600000 轮、salt 16 位)。
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class WerkzeugPasswordEncoder {
|
||||
|
||||
private static final int DK_BITS = 32 * 8;
|
||||
private static final int DEFAULT_ITERATIONS = 600_000;
|
||||
private static final int SALT_LENGTH = 16;
|
||||
private static final String SALT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
public String hash(String rawPassword) {
|
||||
if (rawPassword == null) {
|
||||
throw new IllegalArgumentException("password cannot be null");
|
||||
}
|
||||
String salt = generateSalt();
|
||||
try {
|
||||
byte[] derived = pbkdf2Sha256(rawPassword.toCharArray(),
|
||||
salt.getBytes(StandardCharsets.UTF_8), DEFAULT_ITERATIONS, DK_BITS);
|
||||
String hex = HexFormat.of().formatHex(derived);
|
||||
return "pbkdf2:sha256:" + DEFAULT_ITERATIONS + "$" + salt + "$" + hex;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("生成密码摘要失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean matches(String rawPassword, String storedHash) {
|
||||
if (rawPassword == null || storedHash == null || storedHash.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
int firstSep = storedHash.indexOf('$');
|
||||
int secondSep = storedHash.indexOf('$', firstSep + 1);
|
||||
if (firstSep < 0 || secondSep < 0) {
|
||||
return false;
|
||||
}
|
||||
String method = storedHash.substring(0, firstSep);
|
||||
String salt = storedHash.substring(firstSep + 1, secondSep);
|
||||
String expectedHex = storedHash.substring(secondSep + 1);
|
||||
if (!method.startsWith("pbkdf2:sha256")) {
|
||||
log.warn("[auth] unsupported password hash scheme: {}", method);
|
||||
return false;
|
||||
}
|
||||
String[] parts = method.split(":");
|
||||
int iterations = parts.length >= 3 ? Integer.parseInt(parts[2]) : 600_000;
|
||||
byte[] derived = pbkdf2Sha256(rawPassword.toCharArray(), salt.getBytes(StandardCharsets.UTF_8), iterations, DK_BITS);
|
||||
String actualHex = HexFormat.of().formatHex(derived);
|
||||
return constantTimeEquals(actualHex, expectedHex);
|
||||
} catch (Exception e) {
|
||||
log.warn("[auth] verify password failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] pbkdf2Sha256(char[] password, byte[] salt, int iterations, int keyBits) throws Exception {
|
||||
PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyBits);
|
||||
try {
|
||||
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
|
||||
} finally {
|
||||
spec.clearPassword();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean constantTimeEquals(String a, String b) {
|
||||
if (a == null || b == null || a.length() != b.length()) {
|
||||
return false;
|
||||
}
|
||||
int diff = 0;
|
||||
for (int i = 0; i < a.length(); i++) {
|
||||
diff |= a.charAt(i) ^ b.charAt(i);
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
private static String generateSalt() {
|
||||
StringBuilder sb = new StringBuilder(SALT_LENGTH);
|
||||
for (int i = 0; i < SALT_LENGTH; i++) {
|
||||
sb.append(SALT_ALPHABET.charAt(RANDOM.nextInt(SALT_ALPHABET.length())));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.vo.ImageHistoryListVo;
|
||||
import com.nanri.aiimage.modules.imagehistory.service.ImageHistoryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin")
|
||||
@Tag(name = "管理后台-生成历史", description = "管理员查看所有用户的生成历史")
|
||||
public class ImageHistoryController {
|
||||
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final ImageHistoryService imageHistoryService;
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "分页查询所有用户生成历史")
|
||||
public ApiResponse<ImageHistoryListVo> listHistory(HttpServletRequest request,
|
||||
@RequestParam(required = false, defaultValue = "1") Integer page,
|
||||
@RequestParam(name = "page_size", required = false, defaultValue = "15") Integer pageSize,
|
||||
@RequestParam(name = "user_id", required = false) Long userId,
|
||||
@RequestParam(name = "time_start", required = false) String timeStart,
|
||||
@RequestParam(name = "time_end", required = false) String timeEnd) {
|
||||
adminAuthSupport.requireAdmin(request);
|
||||
ImageHistoryListVo vo = imageHistoryService.listHistory(page, pageSize, userId, timeStart, timeEnd);
|
||||
return ApiResponse.success(vo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.entity.ImageHistoryEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ImageHistoryMapper extends BaseMapper<ImageHistoryEntity> {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("image_history")
|
||||
public class ImageHistoryEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
@TableField("user_id")
|
||||
private Long userId;
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
@TableField("panel_type")
|
||||
private String panelType;
|
||||
/** JSON 字段,按字符串读出后由 service 解析。 */
|
||||
@TableField("original_urls")
|
||||
private String originalUrls;
|
||||
private String params;
|
||||
@TableField("result_urls")
|
||||
private String resultUrls;
|
||||
@TableField("long_image_url")
|
||||
private String longImageUrl;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class ImageHistoryItemVo {
|
||||
private Long id;
|
||||
@JsonProperty("user_id")
|
||||
private Long userId;
|
||||
private String username;
|
||||
@JsonProperty("created_at")
|
||||
private String createdAt;
|
||||
@JsonProperty("panel_type")
|
||||
private String panelType;
|
||||
@JsonProperty("original_urls")
|
||||
private List<String> originalUrls;
|
||||
private Map<String, Object> params;
|
||||
@JsonProperty("result_urls")
|
||||
private List<String> resultUrls;
|
||||
@JsonProperty("long_image_url")
|
||||
private String longImageUrl;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class ImageHistoryListVo {
|
||||
private List<ImageHistoryItemVo> items;
|
||||
private Long total;
|
||||
private Integer page;
|
||||
@JsonProperty("page_size")
|
||||
private Integer pageSize;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.nanri.aiimage.modules.imagehistory.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.imagehistory.mapper.ImageHistoryMapper;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.entity.ImageHistoryEntity;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.vo.ImageHistoryItemVo;
|
||||
import com.nanri.aiimage.modules.imagehistory.model.vo.ImageHistoryListVo;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ImageHistoryService {
|
||||
|
||||
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
private static final DateTimeFormatter ISO_DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final DateTimeFormatter ISO_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
private static final TypeReference<List<String>> LIST_STRING_REF = new TypeReference<>() {
|
||||
};
|
||||
private static final TypeReference<Map<String, Object>> MAP_REF = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private final ImageHistoryMapper imageHistoryMapper;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ImageHistoryListVo listHistory(Integer page, Integer pageSize, Long userId,
|
||||
String timeStart, String timeEnd) {
|
||||
int safePage = page == null || page < 1 ? 1 : page;
|
||||
int safeSize = pageSize == null ? 15 : pageSize;
|
||||
if (safeSize < 10) safeSize = 10;
|
||||
if (safeSize > 50) safeSize = 50;
|
||||
|
||||
LambdaQueryWrapper<ImageHistoryEntity> query = new LambdaQueryWrapper<>();
|
||||
if (userId != null && userId > 0) {
|
||||
query.eq(ImageHistoryEntity::getUserId, userId);
|
||||
}
|
||||
LocalDateTime startDt = parseDateTime(timeStart, false);
|
||||
if (startDt != null) {
|
||||
query.ge(ImageHistoryEntity::getCreatedAt, startDt);
|
||||
}
|
||||
LocalDateTime endDt = parseDateTime(timeEnd, true);
|
||||
if (endDt != null) {
|
||||
query.le(ImageHistoryEntity::getCreatedAt, endDt);
|
||||
}
|
||||
|
||||
Long total = imageHistoryMapper.selectCount(query);
|
||||
query.orderByDesc(ImageHistoryEntity::getCreatedAt);
|
||||
int offset = (safePage - 1) * safeSize;
|
||||
query.last("LIMIT " + safeSize + " OFFSET " + offset);
|
||||
List<ImageHistoryEntity> rows = imageHistoryMapper.selectList(query);
|
||||
|
||||
Map<Long, String> usernameMap = loadUsernameMap(rows);
|
||||
|
||||
List<ImageHistoryItemVo> items = new ArrayList<>(rows.size());
|
||||
for (ImageHistoryEntity r : rows) {
|
||||
items.add(toItem(r, usernameMap));
|
||||
}
|
||||
|
||||
ImageHistoryListVo vo = new ImageHistoryListVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(total == null ? 0L : total);
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safeSize);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Map<Long, String> loadUsernameMap(List<ImageHistoryEntity> rows) {
|
||||
Set<Long> userIds = new LinkedHashSet<>();
|
||||
for (ImageHistoryEntity r : rows) {
|
||||
if (r.getUserId() != null && r.getUserId() > 0) {
|
||||
userIds.add(r.getUserId());
|
||||
}
|
||||
}
|
||||
if (userIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<AdminUserEntity> users = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.in(AdminUserEntity::getId, new ArrayList<>(userIds)));
|
||||
Map<Long, String> map = new HashMap<>(users.size() * 2);
|
||||
for (AdminUserEntity u : users) {
|
||||
map.put(u.getId(), u.getUsername() == null ? "" : u.getUsername());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private ImageHistoryItemVo toItem(ImageHistoryEntity entity, Map<Long, String> usernameMap) {
|
||||
ImageHistoryItemVo vo = new ImageHistoryItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setUserId(entity.getUserId());
|
||||
String username = usernameMap.get(entity.getUserId());
|
||||
vo.setUsername(username == null || username.isEmpty() ? "-" : username);
|
||||
LocalDateTime createdAt = entity.getCreatedAt();
|
||||
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
|
||||
vo.setPanelType(entity.getPanelType() == null ? "" : entity.getPanelType());
|
||||
vo.setOriginalUrls(parseList(entity.getOriginalUrls()));
|
||||
vo.setParams(parseMap(entity.getParams()));
|
||||
vo.setResultUrls(parseList(entity.getResultUrls()));
|
||||
String longImageUrl = entity.getLongImageUrl();
|
||||
vo.setLongImageUrl(longImageUrl == null || longImageUrl.trim().isEmpty() ? null : longImageUrl.trim());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private List<String> parseList(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
List<String> v = objectMapper.readValue(json, LIST_STRING_REF);
|
||||
return v == null ? Collections.emptyList() : v;
|
||||
} catch (Exception e) {
|
||||
log.warn("[image-history] 解析 JSON 列表失败: {}", e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseMap(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
Map<String, Object> v = objectMapper.readValue(json, MAP_REF);
|
||||
return v == null ? Collections.emptyMap() : v;
|
||||
} catch (Exception e) {
|
||||
log.warn("[image-history] 解析 JSON 对象失败: {}", e.getMessage());
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDateTime parseDateTime(String text, boolean endOfDay) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
String t = text.trim();
|
||||
if (t.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (t.length() <= 10) {
|
||||
LocalDateTime base = java.time.LocalDate.parse(t, ISO_DATE_FORMATTER).atStartOfDay();
|
||||
return endOfDay ? base.withHour(23).withMinute(59).withSecond(59) : base;
|
||||
}
|
||||
return LocalDateTime.parse(t.replace('T', ' '), ISO_DATETIME_FORMATTER);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("时间格式不正确: " + text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("users")
|
||||
public class AdminUserEntity {
|
||||
@@ -13,7 +15,14 @@ public class AdminUserEntity {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String username;
|
||||
@TableField("password_hash")
|
||||
private String passwordHash;
|
||||
@TableField("is_admin")
|
||||
private Integer isAdmin;
|
||||
private String role;
|
||||
@TableField("created_by_id")
|
||||
private Long createdById;
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
private String machine;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.nanri.aiimage.modules.permission.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -9,9 +10,14 @@ public class PermissionMenuItemVo {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
@JsonProperty("column_key")
|
||||
private String columnKey;
|
||||
@JsonProperty("menu_type")
|
||||
private String menuType;
|
||||
@JsonProperty("route_path")
|
||||
private String routePath;
|
||||
@JsonProperty("sort_order")
|
||||
private Integer sortOrder;
|
||||
@JsonProperty("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -355,9 +355,14 @@ public class SimilarAsinCozeClient {
|
||||
List<String> urls = rows.stream().map(this::primaryImageUrl).toList();
|
||||
List<List<String>> urlLists = rows.stream().map(this::imageUrls).toList();
|
||||
|
||||
List<Map<String, Object>> items = buildItemObjects(asins, titles, skus, urls, urlLists);
|
||||
logCozeItemsDiff(rows, items);
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("items", buildItemObjects(asins, titles, skus, urls, urlLists));
|
||||
parameters.put("prompt", prompt == null ? "" : prompt);
|
||||
parameters.put("items", items);
|
||||
// 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。
|
||||
if (prompt != null && !prompt.isBlank()) {
|
||||
parameters.put("prompt", prompt);
|
||||
}
|
||||
// legacy flag:当线上 coze 工作流回退到老契约时,把环境变量
|
||||
// AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=true 即可重新塞 api_key。
|
||||
boolean includeLegacyApiKey = properties.isCozeIncludeLegacyApiKey();
|
||||
@@ -367,6 +372,51 @@ public class SimilarAsinCozeClient {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印每一行 row 在送入 Coze 前后的关键字段,便于排查 Python -> Java -> Coze 的字段是否被改写。
|
||||
* 对照点:asin / url / urls(size+样例) / title / sku;以及组装到 Coze 后的 url / target_urls。
|
||||
*/
|
||||
private void logCozeItemsDiff(List<SimilarAsinResultRowDto> rows, List<Map<String, Object>> items) {
|
||||
if (rows == null || items == null) {
|
||||
return;
|
||||
}
|
||||
int size = Math.min(rows.size(), items.size());
|
||||
log.info("[similar-asin] coze items diff start batchSize={}", size);
|
||||
for (int i = 0; i < size; i++) {
|
||||
SimilarAsinResultRowDto row = rows.get(i);
|
||||
Map<String, Object> item = items.get(i);
|
||||
if (row == null || item == null) {
|
||||
continue;
|
||||
}
|
||||
List<String> rowUrls = safeUrls(row.getUrls());
|
||||
String rowUrl = safeText(row.getUrl());
|
||||
Object outUrl = item.get("url");
|
||||
Object outTargetUrls = item.get("target_urls");
|
||||
int outTargetSize = (outTargetUrls instanceof List<?> list) ? list.size() : 0;
|
||||
boolean urlMatch = String.valueOf(outUrl == null ? "" : outUrl).equals(rowUrl);
|
||||
boolean targetMatch = (outTargetUrls instanceof List<?> outList)
|
||||
&& outList.size() == rowUrls.size()
|
||||
&& outList.equals(rowUrls);
|
||||
log.info("[similar-asin] coze item idx={} asin={} title={} sku={} rowUrl={} rowUrlsSize={} rowUrlsHead={} rowUrlsTail={} outUrl={} outTargetSize={} outTargetHead={} outTargetTail={} urlMatch={} targetUrlsMatch={}",
|
||||
i,
|
||||
safeText(row.getAsin()),
|
||||
abbreviate(safeText(row.getTitle()), 80),
|
||||
safeText(row.getSku()),
|
||||
abbreviate(rowUrl, 200),
|
||||
rowUrls.size(),
|
||||
rowUrls.isEmpty() ? "" : abbreviate(rowUrls.get(0), 200),
|
||||
rowUrls.size() <= 1 ? "" : abbreviate(rowUrls.get(rowUrls.size() - 1), 200),
|
||||
abbreviate(String.valueOf(outUrl == null ? "" : outUrl), 200),
|
||||
outTargetSize,
|
||||
(outTargetUrls instanceof List<?> headList && !headList.isEmpty())
|
||||
? abbreviate(String.valueOf(headList.get(0)), 200) : "",
|
||||
(outTargetUrls instanceof List<?> tailList && tailList.size() > 1)
|
||||
? abbreviate(String.valueOf(tailList.get(tailList.size() - 1)), 200) : "",
|
||||
urlMatch,
|
||||
targetMatch);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> maskCozeRequestBody(Map<String, Object> body) {
|
||||
Map<String, Object> masked = new LinkedHashMap<>(body);
|
||||
|
||||
@@ -9,14 +9,11 @@ import lombok.Data;
|
||||
import java.lang.reflect.Array;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
@Schema(description = "相似ASIN检测单行商品数据")
|
||||
public class SimilarAsinResultRowDto {
|
||||
private static final int MAX_IMAGE_URLS = 24;
|
||||
|
||||
@Schema(description = "来源文件 key。多文件回传时用于避免行结果串到别的文件。", example = "uploads/20260426/similar_asin_17.xlsx")
|
||||
private String sourceFileKey;
|
||||
@@ -47,10 +44,10 @@ public class SimilarAsinResultRowDto {
|
||||
@Schema(description = "商品价格。来自 Excel 解析或 Python 回传。", example = "12.29")
|
||||
private String price;
|
||||
|
||||
@Schema(description = "兼容旧链路的首个图片 URL。新链路允许 url 传字符串或数组;数组会同步写入 urls。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||
@Schema(description = "商品主图 URL。Python 端解析的代表图。允许传字符串或数组(数组取首个非空),与 urls 互不影响。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "商品图片 URL 列表,最多保留 24 个非空地址。请求中也可以直接把 url 传成数组。", example = "[\"https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg\"]")
|
||||
@Schema(description = "同类商品图 URL 列表。Python 端原样回传,后端不再去重/截断/与 url 互写。", example = "[\"https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg\"]")
|
||||
private List<String> urls = new ArrayList<>();
|
||||
|
||||
@Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual")
|
||||
@@ -125,11 +122,7 @@ public class SimilarAsinResultRowDto {
|
||||
private String puzzleImg2;
|
||||
|
||||
public String getUrl() {
|
||||
if (url != null && !url.isBlank()) {
|
||||
return url;
|
||||
}
|
||||
List<String> normalized = getUrls();
|
||||
return normalized.isEmpty() ? "" : normalized.get(0);
|
||||
return url == null ? "" : url;
|
||||
}
|
||||
|
||||
@JsonSetter("url")
|
||||
@@ -140,20 +133,24 @@ public class SimilarAsinResultRowDto {
|
||||
"image", "img", "pic", "picture", "link", "imageLink", "image_link"
|
||||
})
|
||||
public void setUrl(Object value) {
|
||||
List<String> normalized = normalizeUrls(value);
|
||||
this.urls = normalized;
|
||||
// 仅写 url 字段;不再回写 urls,遵循"url 主图 / urls 同类图"语义。
|
||||
// 兼容历史输入:若传入数组/集合,仅取第一个非空值作为 url,不影响 urls。
|
||||
if (value == null) {
|
||||
this.url = "";
|
||||
return;
|
||||
}
|
||||
if (value instanceof String text) {
|
||||
this.url = text.trim();
|
||||
return;
|
||||
}
|
||||
List<String> normalized = new ArrayList<>();
|
||||
appendUrls(normalized, value);
|
||||
this.url = normalized.isEmpty() ? "" : normalized.get(0);
|
||||
}
|
||||
|
||||
public List<String> getUrls() {
|
||||
List<String> normalized = normalizeUrls(urls);
|
||||
if (url != null && !url.isBlank() && normalized.stream().noneMatch(url.trim()::equals)) {
|
||||
List<String> combined = new ArrayList<>(normalized.size() + 1);
|
||||
combined.add(url.trim());
|
||||
combined.addAll(normalized);
|
||||
return limitUrls(combined);
|
||||
}
|
||||
return normalized;
|
||||
// 直接返回 Python 回传的 urls,不再把 url 强推到首位、不去重、不截断。
|
||||
return cleanUrls(urls);
|
||||
}
|
||||
|
||||
@JsonSetter("urls")
|
||||
@@ -166,19 +163,18 @@ public class SimilarAsinResultRowDto {
|
||||
"图片链接", "商品图片", "商品主图", "主图", "主图链接"
|
||||
})
|
||||
public void setUrls(Object urls) {
|
||||
List<String> normalized = normalizeUrls(urls);
|
||||
// 仅写 urls 字段;不再回写 url,避免 url/urls 交叉污染。
|
||||
List<String> normalized = new ArrayList<>();
|
||||
appendUrls(normalized, urls);
|
||||
this.urls = normalized;
|
||||
this.url = normalized.isEmpty() ? "" : normalized.get(0);
|
||||
}
|
||||
|
||||
public boolean hasImageUrl() {
|
||||
return !getUrls().isEmpty();
|
||||
}
|
||||
|
||||
private static List<String> normalizeUrls(Object value) {
|
||||
List<String> result = new ArrayList<>();
|
||||
appendUrls(result, value);
|
||||
return limitUrls(result);
|
||||
// url(主图)和 urls(同类商品图)任一存在即可作为可送 Coze 的素材。
|
||||
if (url != null && !url.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
return !cleanUrls(urls).isEmpty();
|
||||
}
|
||||
|
||||
private static void appendUrls(List<String> result, Object value) {
|
||||
@@ -214,19 +210,21 @@ public class SimilarAsinResultRowDto {
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> limitUrls(List<String> values) {
|
||||
Set<String> deduplicated = new LinkedHashSet<>();
|
||||
if (values != null) {
|
||||
for (String value : values) {
|
||||
if (value == null || value.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
deduplicated.add(value.trim());
|
||||
if (deduplicated.size() >= MAX_IMAGE_URLS) {
|
||||
break;
|
||||
}
|
||||
private static List<String> cleanUrls(List<String> values) {
|
||||
// 仅做空值过滤与 trim,不去重、不截断,原样保留 Python 回传顺序与数量。
|
||||
if (values == null || values.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<String> result = new ArrayList<>(values.size());
|
||||
for (String value : values) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (!trimmed.isBlank()) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(deduplicated);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1301,11 +1301,56 @@ public class SimilarAsinTaskService {
|
||||
rows.add(row);
|
||||
}
|
||||
}
|
||||
logPythonInboundRows(rows);
|
||||
return rows;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印 Python 端回传给 Java 的每一行 row 关键字段,确认 url(主图)/ urls(同类商品图)/ title / sku
|
||||
* 是否按预期到达。该日志与 SimilarAsinCozeClient 的 coze items diff 日志成对,
|
||||
* 便于排查"Python 回传了什么、Java 又把什么发到 Coze"。
|
||||
*/
|
||||
private void logPythonInboundRows(List<SimilarAsinResultRowDto> rows) {
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
log.info("[similar-asin] python inbound start size={}", rows.size());
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
SimilarAsinResultRowDto row = rows.get(i);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String url = row.getUrl();
|
||||
List<String> urls = row.getUrls();
|
||||
log.info("[similar-asin] python inbound idx={} groupKey={} rowToken={} id={} asin={} country={} title={} sku={} url={} urlsSize={} urlsHead={} urlsTail={}",
|
||||
i,
|
||||
normalize(row.getGroupKey()),
|
||||
normalize(row.getRowToken()),
|
||||
normalize(row.getId()),
|
||||
normalize(row.getAsin()),
|
||||
normalize(row.getCountry()),
|
||||
abbreviateForLog(row.getTitle(), 80),
|
||||
normalize(row.getSku()),
|
||||
abbreviateForLog(url, 200),
|
||||
urls == null ? 0 : urls.size(),
|
||||
urls == null || urls.isEmpty() ? "" : abbreviateForLog(urls.get(0), 200),
|
||||
urls == null || urls.size() <= 1 ? "" : abbreviateForLog(urls.get(urls.size() - 1), 200));
|
||||
}
|
||||
}
|
||||
|
||||
private String abbreviateForLog(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (maxLength <= 0 || trimmed.length() <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
private int allRowCount(FileTaskEntity task) {
|
||||
try {
|
||||
SimilarAsinParsedPayloadDto payload = readParsedPayload(task);
|
||||
@@ -1634,13 +1679,8 @@ public class SimilarAsinTaskService {
|
||||
if (parsedRow == null) {
|
||||
return row;
|
||||
}
|
||||
if (normalize(row.getUrl()).isBlank()) {
|
||||
String parsedUrl = firstNonBlank(parsedRow.getUrl(), readValueByHeader(parsedRow,
|
||||
"url", "rul", "image", "img", "pic", "picture", "link"));
|
||||
if (!normalize(parsedUrl).isBlank()) {
|
||||
row.setUrl(parsedUrl);
|
||||
}
|
||||
}
|
||||
// 不再回填 url:Python 端会同时回传 url(主图)与 urls(同类商品图),
|
||||
// 缺失场景应在 Python 侧定位,Java 不再合成新的 url 字段以避免覆盖原始数据。
|
||||
if (normalize(row.getTitle()).isBlank()) {
|
||||
row.setTitle(parsedRow.getTitle());
|
||||
}
|
||||
|
||||
@@ -58,11 +58,13 @@ public class SimilarAsinImageEmbedder {
|
||||
static final int DOWNLOAD_TIMEOUT_SECONDS = 5;
|
||||
static final int DOWNLOAD_MAX_RETRY = 1;
|
||||
static final int DOWNLOAD_POOL_SIZE = 8;
|
||||
public static final float IMAGE_ROW_HEIGHT_POINTS = 150f;
|
||||
public static final int IMAGE_COL_WIDTH_CHARS = 30;
|
||||
static final int TARGET_LONG_EDGE_PX = 300;
|
||||
static final float JPEG_QUALITY = 0.85f;
|
||||
static final int MAX_THUMB_SIZE_BYTES = 40 * 1024;
|
||||
// B 方案:单元格放大到 Excel 上限附近(255 char ≈ 1785 px / 409.5 pt ≈ 546 px),保持 16:9 显示空间。
|
||||
public static final float IMAGE_ROW_HEIGHT_POINTS = 409f;
|
||||
public static final int IMAGE_COL_WIDTH_CHARS = 80;
|
||||
// 源图长边 1280 px 给 Excel 缩放/打印留余量;q=0.75 + 300KB 出口硬上限主动控制单图体积。
|
||||
static final int TARGET_LONG_EDGE_PX = 1280;
|
||||
static final float JPEG_QUALITY = 0.75f;
|
||||
static final int MAX_THUMB_SIZE_BYTES = 300 * 1024;
|
||||
static final int MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024;
|
||||
static final int MAX_DECODE_PIXELS = 6000 * 6000;
|
||||
|
||||
@@ -150,6 +152,9 @@ public class SimilarAsinImageEmbedder {
|
||||
anchor.setRow2(rowIdx + 1);
|
||||
anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
|
||||
patriarch.createPicture(anchor, picIdx);
|
||||
// POI 已经把缩略图字节复制进 picture pool(B 副本),此时移除 taskImageCache 里的 A 副本可立刻释放。
|
||||
// 同 URL 行内重复出现概率较低,且 cache 仍承担同任务跨行去重收益(首次 miss 时已经 put 过)。
|
||||
taskImageCache.remove(trimmedUrl);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
|
||||
row.createCell(colIdx).setCellValue(trimmedUrl);
|
||||
@@ -440,8 +445,10 @@ public class SimilarAsinImageEmbedder {
|
||||
}
|
||||
|
||||
/**
|
||||
* resize 出口字节硬上限保护:worst case 6000 行 × 3 列 × 40KB × 2 副本 = 1440MB;
|
||||
* 因此线上限制建议结合 `taskImageCache` 命中率与单任务行数评估,超出预算需缩小 MAX_THUMB_SIZE_BYTES 或限制行数。
|
||||
* resize 出口字节硬上限保护:B 方案下单图最坏 ~300KB;POI picture pool 在 SXSSFWorkbook.dispose() 前不会 spill 到临时文件,
|
||||
* 因此 N 行 × 3 列 × 300KB 全量驻留堆。配合 -Xmx2048M:≈1500 行 × 3 列 ≈ 1.32GB picture pool,已逼近安全水位;
|
||||
* embed() 成功后会立刻 taskImageCache.remove() 释放 A 副本,B 副本(picture pool)仍随 workbook 生命周期驻留。
|
||||
* 超过 ~1500 行需要降低 MAX_THUMB_SIZE_BYTES 或调小 TARGET_LONG_EDGE_PX,必要时再考虑拆任务。
|
||||
*/
|
||||
public static class ResizeOversizeException extends ResizeException {
|
||||
private final String url;
|
||||
|
||||
@@ -179,6 +179,12 @@ aiimage:
|
||||
security:
|
||||
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
|
||||
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}
|
||||
auth:
|
||||
jwt-secret: ${AIIMAGE_JWT_SECRET:please-change-this-secret-please-rotate-at-least-32-bytes}
|
||||
jwt-ttl-hours: ${AIIMAGE_AUTH_JWT_TTL_HOURS:168}
|
||||
cookie-name: ${AIIMAGE_AUTH_COOKIE_NAME:aiimage_token}
|
||||
cookie-secure: ${AIIMAGE_AUTH_COOKIE_SECURE:false}
|
||||
cookie-same-site: ${AIIMAGE_AUTH_COOKIE_SAME_SITE:Lax}
|
||||
ziniao:
|
||||
enabled: ${AIIMAGE_ZINIAO_ENABLED:false}
|
||||
base-url: ${AIIMAGE_ZINIAO_BASE_URL:https://sbappstoreapi.ziniao.com/openapi-router}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<div class="prompt-card">
|
||||
<div class="section-title">新增条件要求</div>
|
||||
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,仅在激活任务时传递这里填写的 prompt" />
|
||||
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,留空则不向 Coze 传 prompt 字段;填写后将作为 prompt 透传" />
|
||||
</div>
|
||||
|
||||
<div class="run-row">
|
||||
@@ -167,12 +167,7 @@ const uploadedFiles = ref<UploadFileVo[]>([])
|
||||
const parseResult = ref<AppearancePatentParseVo | null>(null)
|
||||
type TaskSummary = Pick<AppearancePatentParseVo, 'taskId' | 'totalRows' | 'acceptedRows' | 'groupCount' | 'droppedRows'>
|
||||
const queuedTaskSummary = ref<TaskSummary | null>(null)
|
||||
const defaultAiPrompt = `请帮我排查以下亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。
|
||||
请直接给出明确结论(有侵权风险 或 未发现明显侵权风险),并严格按照以下三个维度提供精简的排查理由:
|
||||
外观维度: 评估产品外形、图案设计是否与欧洲/英国常见外观专利雷同。
|
||||
专利维度: 评估产品的核心技术或物理结构是否存在侵权可能。
|
||||
标题维度: 排查标题中是否包含大牌商标、敏感词或版权保护词汇。
|
||||
结论:XX`
|
||||
// 不再注入默认 prompt:用户未填则一律空字符串,由后端决定是否给 Coze 透传 prompt 字段。
|
||||
const aiPrompt = ref('')
|
||||
const parsing = ref(false)
|
||||
const pushing = ref(false)
|
||||
@@ -253,9 +248,8 @@ function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
|
||||
}
|
||||
|
||||
function effectiveAiPrompt() {
|
||||
const extraPrompt = aiPrompt.value.trim()
|
||||
if (!extraPrompt) return defaultAiPrompt
|
||||
return `${defaultAiPrompt}\n\n新增条件要求:\n${extraPrompt}`
|
||||
// 用户没在文本框填东西就送空字符串,后端会跳过 prompt 字段。
|
||||
return aiPrompt.value.trim()
|
||||
}
|
||||
|
||||
function queueAiPrompt() {
|
||||
|
||||
Reference in New Issue
Block a user