提交登录修改

This commit is contained in:
super
2026-05-23 18:32:45 +08:00
parent 2e2de02476
commit 1e087c1aae
52 changed files with 2139 additions and 1572 deletions

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
"""
公共模块:数据库连接、初始化、会话校验、装饰器、模板渲染
公共模块:数据库连接、初始化、JWT 鉴权装饰器、模板渲染
供各蓝图复用
"""
import os
@@ -8,9 +8,10 @@ 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')
@@ -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)

View File

@@ -1,379 +1,17 @@
"""
管理员蓝图:用户管理(列表/创建/更新/删除)、生成历史、管理页
管理员蓝图:仅保留 /admin 页面渲染。
用户管理、栏目权限、生成历史等接口已全部迁移至 Java 端。
"""
import json
import pymysql
from flask import Blueprint, request, jsonify
from werkzeug.security import generate_password_hash
from flask import Blueprint
from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required
from flask import session
from app_common import _render_html, admin_required, login_required
from config import JAVA_API_BASE
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)})
return _render_html('admin.html', java_api_base=JAVA_API_BASE)

View File

@@ -1,107 +1,55 @@
"""
认证蓝图:登录、登出、登录状态校验
认证蓝图:登录页(仅 GET 渲染模板)+ 登出(清 JWT cookie+ token 同步
登录表单提交已经直连 Java 后端 /loginPython 这边只负责:
1. 渲染登录页模板
2. 把 Java 签发的 JWT 从前端写到 Python 同源 cookie供后续页面跳转携带
3. 登出:清 cookie 跳回登录页
"""
from flask import Blueprint, request, redirect, url_for, session, jsonify
from werkzeug.security import check_password_hash
import sys
from app_common import (
get_db,
_render_html,
_is_session_user_valid,
login_required,
BASE_DIR,
)
from tool.devices import DeviceIDGenerator
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', 'POST'])
@auth_bp.route('/login', methods=['GET'])
def login():
if session.get('user_id') and _is_session_user_valid():
if current_user_id():
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,)
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',
)
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')})
return resp
@auth_bp.route('/logout')
def logout():
session.clear()
return redirect(url_for('auth.login'))
resp = make_response(redirect(url_for('auth.login')))
resp.delete_cookie(COOKIE_NAME, path='/')
return resp

View File

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

View File

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

View File

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

View File

@@ -7,14 +7,15 @@ 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,
current_user_id,
current_username,
STATIC_DIR,
BASE_DIR,
ASSETS_DIR
)
from flask import redirect, url_for, session
from flask import redirect, url_for
from flask import Flask, request, Response, stream_with_context
import requests
@@ -67,7 +68,7 @@ def _resolve_asset_filename(filename):
@main_bp.route('/')
def index():
if session.get('user_id') and _is_session_user_valid():
if current_user_id():
return redirect(url_for('main.home'))
return redirect(url_for('auth.login'))
@@ -75,15 +76,16 @@ def index():
@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", (session['user_id'],))
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=session.get('user_id'),baseUrl=base_url,version=version)
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=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version)
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')
@@ -112,10 +114,10 @@ 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'))
return _render_html('brand.html', user_id=current_user_id(), java_api_base=JAVA_API_BASE)
@main_bp.route('/brand/legacy')
@@ -132,7 +134,7 @@ def brand_page_legacy():
1,
)
content = content.replace('height: calc(100vh - 56px);', 'height: 100vh;', 1)
return render_template_string(content, user_id=session.get('user_id'))
return render_template_string(content, user_id=current_user_id())
@@ -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>"

View File

@@ -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
View 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 为 Noneerror_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()

View File

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

View File

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

View File

@@ -236,6 +236,29 @@
<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() {
@@ -268,17 +291,21 @@
}
function loadUsers(page) {
userPage = page || 1;
fetch('/api/admin/users?' + buildUserListQuery(userPage))
.then(function(r) { return r.json(); })
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">加载失败: ' + (res.error || '') + '</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="empty-tip">加载失败: ' + errMsg(res) + '</td></tr>';
return;
}
currentUserRole = res.current_user_role || 'admin';
adminsList = res.admins || [];
var items = res.items || [];
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 {
@@ -290,7 +317,7 @@
'</td></tr>';
}).join('');
}
renderPagination('userPagination', res.total, res.page, res.page_size, loadUsers);
renderPagination('userPagination', data.total, data.page, data.page_size, loadUsers);
bindUserActions();
updateCreateFormByRole();
updateUserFilterByRole();
@@ -362,11 +389,15 @@
document.querySelectorAll('[data-delete]').forEach(function(btn) {
btn.onclick = function() {
if (!confirm('确定删除用户 "' + (btn.dataset.name || '') + '" 吗?')) return;
fetch('/api/admin/user/' + btn.dataset.delete, { method: 'DELETE' })
.then(function(r) { return r.json(); })
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(res.error || '删除失败'); }
else { alert(errMsg(res)); }
});
};
});
@@ -393,22 +424,23 @@
return;
}
var body = { username: username, password: password, role: role };
if (role === 'normal' && currentUserRole === 'super_admin' && createdById) body.created_by_id = createdById;
fetch('/api/admin/user', {
if (role === 'normal' && currentUserRole === 'super_admin' && createdById) body.createdById = createdById;
fetch(buildJavaUrl('/api/admin/user'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
headers: jsonHeaders(),
body: JSON.stringify(body)
})
.then(function(r) { return r.json(); })
.then(parseJson)
.then(function(res) {
if (res.success) {
msgEl.textContent = res.msg || '创建成功';
msgEl.textContent = res.message || '创建成功';
msgEl.classList.add('ok');
document.getElementById('username').value = '';
document.getElementById('password').value = '';
loadUsers(1);
} else {
msgEl.textContent = res.error || '创建失败';
msgEl.textContent = errMsg(res);
msgEl.classList.add('err');
}
})
@@ -429,20 +461,21 @@
if (password) body.password = password;
if (currentUserRole === 'super_admin' && editRoleEl && editRoleEl.offsetParent !== null)
body.role = editRoleEl.value || 'normal';
fetch('/api/admin/user/' + uid, {
fetch(buildJavaUrl('/api/admin/user/' + uid), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
headers: jsonHeaders(),
body: JSON.stringify(body)
})
.then(function(r) { return r.json(); })
.then(parseJson)
.then(function(res) {
if (res.success) {
msgEl.textContent = res.msg || '保存成功';
msgEl.textContent = res.message || '保存成功';
msgEl.classList.add('ok');
document.getElementById('editUserModal').classList.remove('show');
loadUsers(userPage);
} else {
msgEl.textContent = res.error || '保存失败';
msgEl.textContent = errMsg(res);
msgEl.classList.add('err');
}
});
@@ -469,15 +502,19 @@
}
function loadHistory(page) {
historyPage = page || 1;
fetch('/api/admin/history?' + buildHistoryQuery(historyPage))
.then(function(r) { return r.json(); })
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">加载失败: ' + (res.error || '') + '</td></tr>';
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">加载失败: ' + errMsg(res) + '</td></tr>';
return;
}
var items = res.items || [];
var data = res.data || {};
var items = data.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">暂无记录</td></tr>';
} else {
@@ -492,20 +529,24 @@
'<div class="thumb-wrap">' + (thumbs || '-') + '</div></td></tr>';
}).join('');
}
renderPagination('historyPagination', res.total, res.page, res.page_size, loadHistory);
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('/api/admin/users?page=1&page_size=999')
.then(function(r) { return r.json(); })
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>';
(res.items || []).forEach(function(u) {
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') + ')';
@@ -520,6 +561,9 @@
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>' +

View File

@@ -818,6 +818,15 @@
return localStorage.getItem('uid') || '';
}
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 withUidHeaders(headers) {
headers = headers || {};
headers.uid = getUid();
@@ -871,19 +880,28 @@
return;
}
} catch (e) { }
fetch('/api/admin/user/' + uid + '/column-permissions?menu_type=app', { credentials: 'same-origin' })
var token = getAuthToken();
if (!JAVA_API_BASE || !token) {
showAllNavSections();
finishNavPermissionLoading();
return;
}
fetch(buildJavaUrl('/api/admin/permission-users/' + uid + '/column-permissions?menuType=app'), {
credentials: 'include',
headers: { 'Authorization': 'Bearer ' + token }
})
.then(function (r) { return r.json(); })
.then(function (res) {
showAllNavSections();
if (!res || !res.success || !Array.isArray(res.items)) {
if (!res || !res.success || !Array.isArray(res.data)) {
finishNavPermissionLoading();
return;
}
try {
localStorage.setItem(cacheKey, JSON.stringify(res.items));
localStorage.setItem(cacheKey, JSON.stringify(res.data));
} catch (e) { }
var allowedKeys = res.items.map(function (item) {
return (item.column_key || '').toLowerCase();
var allowedKeys = res.data.map(function (item) {
return (item.column_key || item.columnKey || '').toLowerCase();
});
document.querySelectorAll('.nav-section[data-column-key]').forEach(function (section) {
var key = (section.getAttribute('data-column-key') || '').toLowerCase();

View File

@@ -193,7 +193,7 @@
{% endif %}
<span>{{ username }}</span>
<button type="button" class="header-update-btn" id="homeUpdateToggle" title="检测更新"></button>
<a href="/logout" onclick="handleLogout()">退出</a>
<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">
@@ -220,22 +220,29 @@
function getAppPermissionCacheKey(uid) {
return 'app_column_permissions:' + String(uid || '');
}
// 权限校验:根据 column-permissions 接口按 column_key 显示入口
// 权限校验:根据 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 baseUrl = window.location.origin;
var apiUrl = baseUrl + '/api/admin/user/' + uid + '/column-permissions';
fetch(apiUrl, { credentials: 'same-origin' })
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.items)) return;
if (!res || !res.success || !Array.isArray(res.data)) return;
try {
localStorage.setItem(cacheKey, JSON.stringify(res.items));
localStorage.setItem(cacheKey, JSON.stringify(res.data));
} catch (e) {}
var allowedKeys = res.items.map(function(item) { return (item.column_key || '').toLowerCase(); });
var allowedKeys = res.data.map(function(item) { return (item.columnKey || item.column_key || '').toLowerCase(); });
document.querySelectorAll('.entrances .entrance-btn[data-column-key]').forEach(function(btn) {
var key = (btn.getAttribute('data-column-key') || '').toLowerCase();
btn.style.display = allowedKeys.indexOf(key) >= 0 ? '' : 'none';
@@ -255,16 +262,45 @@
};
});
function handleLogout() {
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)
} 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 设置中逻辑一致)
@@ -346,19 +382,6 @@
};
btnDownload.style.display = 'none';
})();
// (function() {
// fetch('/api/auth/check', { credentials: 'same-origin' })
// .then(function(r) { return r.json(); })
// .then(function(res) {
// if (res.logged_in && res.redirect) {
// window.location.href = res.redirect;
// }else{
// window.location.href = "/login"
// // handleLogout()
// }
// })
// .catch(function() {});
// })();
</script>
</body>
</html>

View File

@@ -117,6 +117,10 @@
</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 = [];
@@ -132,55 +136,126 @@
} 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') {
return;
try { localStorage.removeItem(AUTH_TOKEN_KEY); } catch (e) {}
}
fetch('/api/auth/check', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.logged_in && res.redirect) {
window.location.href = res.redirect;
}
})
.catch(function() {});
})();
document.getElementById('loginForm').onsubmit = function(e) {
e.preventDefault();
var btn = document.getElementById('btnLogin');
btn.disabled = true;
btn.textContent = '登录中...';
var form = e.target;
var fd = new FormData(form);
fetch(form.action, {
method: 'POST',
body: fd,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.success) {
<<<<<<<< HEAD:backend/web_source/login.html
window.location.href = res.redirect || '/admin';
========
clearAppPermissionCaches();
window.location.href = res.redirect || '/home';
>>>>>>>> gitee/master:app/web_source/login.html
} else {
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 = res.error || '登录失败';
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() {
form.submit();
clearAppPermissionCaches();
window.location.href = '/home';
});
})
.catch(function() {
showError('网络异常,请稍后重试');
});
});
};
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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$hexdigestWerkzeug 默认 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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
// url主图和 urls同类商品图任一存在即可作为可送 Coze 的素材。
if (url != null && !url.isBlank()) {
return true;
}
private static List<String> normalizeUrls(Object value) {
List<String> result = new ArrayList<>();
appendUrls(result, value);
return limitUrls(result);
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) {
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 || value.isBlank()) {
if (value == null) {
continue;
}
deduplicated.add(value.trim());
if (deduplicated.size() >= MAX_IMAGE_URLS) {
break;
String trimmed = value.trim();
if (!trimmed.isBlank()) {
result.add(trimmed);
}
}
}
return new ArrayList<>(deduplicated);
return result;
}
}

View File

@@ -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);
}
}
// 不再回填 urlPython 端会同时回传 url主图与 urls同类商品图
// 缺失场景应在 Python 侧定位Java 不再合成新的 url 字段以避免覆盖原始数据。
if (normalize(row.getTitle()).isBlank()) {
row.setTitle(parsedRow.getTitle());
}

View File

@@ -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 poolB 副本),此时移除 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 方案下单图最坏 ~300KBPOI 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;

View File

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

View File

@@ -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() {