From 1e087c1aaecfd211b93c172868794c30579e9ae5 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Sat, 23 May 2026 18:32:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E7=99=BB=E5=BD=95=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.env | 3 + app/.env.example | 5 + app/app.py | 9 +- app/app_common.py | 121 +-- app/blueprints/admin.py | 396 +--------- app/blueprints/auth.py | 162 ++-- app/blueprints/brand.py | 10 +- app/blueprints/brand_备份.py | 726 ------------------ app/blueprints/image.py | 13 +- app/blueprints/main.py | 254 +++--- app/config.py | 1 - app/jwt_util.py | 83 ++ app/main.py | 10 +- app/requirements.txt | 1 + app/web_source/admin.html | 102 ++- app/web_source/brand.html | 28 +- app/web_source/home.html | 83 +- app/web_source/login.html | 149 +++- backend-java/pom.xml | 18 + .../nanri/aiimage/config/SecurityConfig.java | 2 +- .../admin/controller/AdminUserController.java | 76 ++ .../model/dto/AdminUserCreateRequest.java | 11 + .../model/dto/AdminUserUpdateRequest.java | 9 + .../modules/admin/model/vo/AdminBriefVo.java | 13 + .../admin/model/vo/AdminUserItemVo.java | 19 + .../admin/model/vo/AdminUserListVo.java | 18 + .../admin/service/AdminUserService.java | 269 +++++++ .../admin/support/AdminAuthSupport.java | 109 +++ .../client/AppearancePatentCozeClient.java | 5 +- .../modules/auth/config/AuthProperties.java | 17 + .../auth/controller/LoginController.java | 79 ++ .../modules/auth/mapper/LoginUserMapper.java | 9 + .../modules/auth/model/dto/LoginRequest.java | 11 + .../auth/model/entity/LoginUserEntity.java | 24 + .../modules/auth/model/vo/LoginResultVo.java | 20 + .../modules/auth/service/AuthService.java | 158 ++++ .../modules/auth/service/JwtService.java | 64 ++ .../auth/util/WerkzeugPasswordEncoder.java | 96 +++ .../controller/ImageHistoryController.java | 37 + .../mapper/ImageHistoryMapper.java | 9 + .../model/entity/ImageHistoryEntity.java | 31 + .../model/vo/ImageHistoryItemVo.java | 26 + .../model/vo/ImageHistoryListVo.java | 15 + .../service/ImageHistoryService.java | 166 ++++ .../model/entity/AdminUserEntity.java | 9 + .../model/vo/PermissionMenuItemVo.java | 6 + .../client/SimilarAsinCozeClient.java | 54 +- .../model/dto/SimilarAsinResultRowDto.java | 80 +- .../service/SimilarAsinTaskService.java | 54 +- .../util/SimilarAsinImageEmbedder.java | 21 +- .../src/main/resources/application.yml | 6 + .../components/BrandAppearancePatentTab.vue | 14 +- 52 files changed, 2139 insertions(+), 1572 deletions(-) delete mode 100644 app/blueprints/brand_备份.py create mode 100644 app/jwt_util.py create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminUserController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/dto/AdminUserCreateRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/dto/AdminUserUpdateRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminBriefVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserListVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupport.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/auth/config/AuthProperties.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/auth/controller/LoginController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/auth/mapper/LoginUserMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/dto/LoginRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/entity/LoginUserEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/vo/LoginResultVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/AuthService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/auth/util/WerkzeugPasswordEncoder.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/mapper/ImageHistoryMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/entity/ImageHistoryEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/vo/ImageHistoryItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/vo/ImageHistoryListVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/service/ImageHistoryService.java diff --git a/app/.env b/app/.env index 7e54df9..e5d7545 100644 --- a/app/.env +++ b/app/.env @@ -15,6 +15,9 @@ client_name=ShuFuAI java_api_base=http://127.0.0.1:18080 # java_api_base=http://121.196.149.225:18080 +# 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致 +AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes + diff --git a/app/.env.example b/app/.env.example index 7de63f3..dee2b56 100644 --- a/app/.env.example +++ b/app/.env.example @@ -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 + diff --git a/app/app.py b/app/app.py index 56f5e7c..5a53e64 100644 --- a/app/app.py +++ b/app/app.py @@ -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) diff --git a/app/app_common.py b/app/app_common.py index db0e20d..f42d3a5 100644 --- a/app/app_common.py +++ b/app/app_common.py @@ -1,5 +1,5 @@ """ -公共模块:数据库连接、初始化、会话校验、装饰器、模板渲染 +公共模块:数据库连接、初始化、JWT 鉴权装饰器、模板渲染 供各蓝图复用 """ import os @@ -8,18 +8,19 @@ from datetime import timedelta from functools import wraps import pymysql -from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string +from flask import request, redirect, url_for, jsonify, render_template, render_template_string, g from config import mysql_host, mysql_user, mysql_password, mysql_database +from jwt_util import parse_token, get_token_from_request -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -STATIC_DIR = os.path.join(BASE_DIR, 'static') -ASSETS_DIR = os.path.join(BASE_DIR, 'assets') -WEB_SOURCE_DIR = os.path.join(BASE_DIR, 'web_source') -TEMPLATE_FALLBACK_DIRS = ( - WEB_SOURCE_DIR, - os.path.join(WEB_SOURCE_DIR, 'templates_backup'), -) +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +STATIC_DIR = os.path.join(BASE_DIR, 'static') +ASSETS_DIR = os.path.join(BASE_DIR, 'assets') +WEB_SOURCE_DIR = os.path.join(BASE_DIR, 'web_source') +TEMPLATE_FALLBACK_DIRS = ( + WEB_SOURCE_DIR, + os.path.join(WEB_SOURCE_DIR, 'templates_backup'), +) def get_db(): @@ -35,13 +36,13 @@ def get_db(): def _render_html(template_name: str, **context): """读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。""" - path = next( - (candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS) - if os.path.isfile(candidate)), - None, - ) - if path is None: - return render_template(template_name, **context) + path = next( + (candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS) + if os.path.isfile(candidate)), + None, + ) + if path is None: + return render_template(template_name, **context) with open(path, "rb") as f: raw = f.read() try: @@ -54,13 +55,13 @@ def _render_html(template_name: str, **context): def _render_html_new(template_name: str, **context): """读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。""" - path = next( - (candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS) - if os.path.isfile(candidate)), - None, - ) - if path is None: - return render_template(template_name, **context) + path = next( + (candidate for candidate in (os.path.join(base_path, template_name) for base_path in TEMPLATE_FALLBACK_DIRS) + if os.path.isfile(candidate)), + None, + ) + if path is None: + return render_template(template_name, **context) with open(path, "rb") as f: raw = f.read() try: @@ -72,9 +73,36 @@ def _render_html_new(template_name: str, **context): +def _resolve_jwt_user(): + """从 Authorization/cookie 解析 JWT,命中后写入 flask.g 缓存。无效返回 None。""" + cached = getattr(g, "_aiimage_user", None) + if cached is not None: + return cached or None + token = get_token_from_request() + payload = parse_token(token) if token else None + if not payload: + g._aiimage_user = False + return None + g._aiimage_user = payload + g.aiimage_user_id = payload.get("user_id") + g.aiimage_username = payload.get("username") or "" + g.aiimage_device_id = payload.get("device_id") or "" + return payload + + +def current_user_id(): + user = _resolve_jwt_user() + return user.get("user_id") if user else None + + +def current_username(): + user = _resolve_jwt_user() + return user.get("username") if user else "" + + def _is_session_user_valid(): - """校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False""" - uid = session.get('user_id') + """兼容旧调用:判断当前 JWT 用户是否仍存在于数据库。""" + uid = current_user_id() if not uid: return False try: @@ -83,23 +111,22 @@ def _is_session_user_valid(): cur.execute("SELECT id FROM users WHERE id = %s", (uid,)) row = cur.fetchone() conn.close() - if not row: - session.clear() - return False - return True + return bool(row) except Exception: - session.clear() return False def _get_current_admin_role(): """获取当前登录用户的管理角色:super_admin / admin / None(非管理员)""" + uid = current_user_id() + if not uid: + return None, None try: conn = get_db() with conn.cursor() as cur: cur.execute( "SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s", - (session['user_id'],) + (uid,) ) row = cur.fetchone() conn.close() @@ -110,13 +137,19 @@ def _get_current_admin_role(): return None, None +def _unauthorized_response(): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \ + or request.path.startswith('/api/') \ + or 'application/json' in (request.headers.get('Accept') or ''): + return jsonify({'success': False, 'error': '未登录'}), 401 + return redirect(url_for('auth.login')) + + def login_required(f): @wraps(f) def decorated(*args, **kwargs): - if not session.get('user_id') or not _is_session_user_valid(): - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'error': '未登录'}), 401 - return redirect(url_for('auth.login')) + if not current_user_id(): + return _unauthorized_response() return f(*args, **kwargs) return decorated @@ -124,22 +157,24 @@ def login_required(f): def admin_required(f): @wraps(f) def decorated(*args, **kwargs): - if not session.get('user_id'): - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'error': '未登录'}), 401 - return redirect(url_for('auth.login')) + uid = current_user_id() + if not uid: + return _unauthorized_response() try: conn = get_db() with conn.cursor() as cur: - cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],)) + cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (uid,)) row = cur.fetchone() conn.close() if not row or not row.get('is_admin'): - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \ + or request.path.startswith('/api/') \ + or 'application/json' in (request.headers.get('Accept') or ''): return jsonify({'success': False, 'error': '需要管理员权限'}), 403 return redirect(url_for('main.home')) except Exception as e: - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + if request.headers.get('X-Requested-With') == 'XMLHttpRequest' \ + or request.path.startswith('/api/'): return jsonify({'success': False, 'error': str(e)}), 500 return redirect(url_for('main.home')) return f(*args, **kwargs) diff --git a/app/blueprints/admin.py b/app/blueprints/admin.py index 542c53d..1492881 100644 --- a/app/blueprints/admin.py +++ b/app/blueprints/admin.py @@ -1,379 +1,17 @@ -""" -管理员蓝图:用户管理(列表/创建/更新/删除)、生成历史、管理页 -""" -import json -import pymysql -from flask import Blueprint, request, jsonify -from werkzeug.security import generate_password_hash - -from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required -from flask import session - -admin_bp = Blueprint('admin', __name__) - - -def _parse_json(val, default=None): - if val is None: - return default if default is not None else [] - if isinstance(val, (list, dict)): - return val - try: - return json.loads(val) - except Exception: - return default if default is not None else [] - - -@admin_bp.route('/admin') -@login_required -@admin_required -def admin_page(): - return _render_html('admin.html') - - -@admin_bp.route('/api/admin/users') -@admin_required -def admin_list_users(): - """分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选""" - role, current_row = _get_current_admin_role() - if not role: - return jsonify({'success': False, 'error': '需要管理员权限'}), 403 - page = max(1, int(request.args.get('page', 1))) - page_size = min(50, max(5, int(request.args.get('page_size', 15)))) - offset = (page - 1) * page_size - search_username = (request.args.get('username') or request.args.get('search') or '').strip() - created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id') - created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None - if role != 'super_admin': - created_by_id = None - try: - conn = get_db() - with conn.cursor() as cur: - if role == 'super_admin': - where_parts = ["1=1"] - params = [] - if search_username: - where_parts.append("u.username LIKE %s") - params.append("%" + search_username + "%") - if created_by_id is not None: - where_parts.append("u.created_by_id = %s") - params.append(created_by_id) - where_sql = " AND ".join(where_parts) - cur.execute( - """SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id, - creator.username AS creator_username - FROM users u - LEFT JOIN users creator ON creator.id = u.created_by_id - WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""", - tuple(params) + (page_size, offset), - ) - rows = cur.fetchall() - cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params)) - total = cur.fetchone()['total'] - cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id") - admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()] - else: - admin_id = current_row['id'] - where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"] - params = [admin_id, admin_id] - if search_username: - where_parts.append("u.username LIKE %s") - params.append("%" + search_username + "%") - where_sql = " AND ".join(where_parts) - cur.execute( - """SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id, - creator.username AS creator_username - FROM users u - LEFT JOIN users creator ON creator.id = u.created_by_id - WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""", - tuple(params) + (page_size, offset), - ) - rows = cur.fetchall() - cur.execute( - "SELECT COUNT(*) as total FROM users u WHERE " + where_sql, - tuple(params), - ) - total = cur.fetchone()['total'] - admins = [] - items = [ - { - 'id': r['id'], - 'username': r['username'], - 'is_admin': bool(r.get('is_admin')), - 'role': r.get('role') or 'normal', - 'created_by_id': r.get('created_by_id'), - 'creator_username': r.get('creator_username') or '', - 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '', - } - for r in rows - ] - conn.close() - payload = { - 'success': True, - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'current_user_role': role, - 'admins': admins, - } - return jsonify(payload) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) - - -@admin_bp.route('/api/admin/user', methods=['POST']) -@admin_required -def admin_create_user(): - data = request.get_json() or {} - username = (data.get('username') or '').strip() - password = data.get('password') or '' - role, current_row = _get_current_admin_role() - if not role: - return jsonify({'success': False, 'error': '需要管理员权限'}), 403 - want_role = (data.get('role') or 'normal').strip() or 'normal' - if want_role not in ('admin', 'normal'): - want_role = 'normal' - if role == 'admin' and want_role == 'admin': - return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'}) - if want_role == 'admin': - want_created_by = current_row['id'] - elif role == 'super_admin': - want_created_by = data.get('created_by_id') - else: - want_created_by = current_row['id'] - if not username or not password: - return jsonify({'success': False, 'error': '用户名和密码不能为空'}) - if len(username) < 2: - return jsonify({'success': False, 'error': '用户名至少2个字符'}) - if len(password) < 6: - return jsonify({'success': False, 'error': '密码至少6个字符'}) - if want_role == 'normal' and role == 'super_admin' and want_created_by is None: - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1") - r = cur.fetchone() - conn.close() - want_created_by = r['id'] if r else current_row['id'] - except Exception: - want_created_by = current_row['id'] - if want_role == 'normal' and want_created_by is None: - want_created_by = current_row['id'] - is_admin = 1 if want_role in ('super_admin', 'admin') else 0 - pwd_hash = generate_password_hash(password, method='pbkdf2:sha256') - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute( - "INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)", - (username, pwd_hash, is_admin, want_role, want_created_by), - ) - conn.commit() - conn.close() - return jsonify({'success': True, 'msg': '用户创建成功'}) - except pymysql.IntegrityError: - return jsonify({'success': False, 'error': '用户名已存在'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) - - -@admin_bp.route('/api/admin/user/', 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/', 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//column-permissions') -@login_required -def admin_user_column_permissions(uid): - """获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。""" - current_uid = session.get('user_id') - menu_type = (request.args.get('menu_type') or '').strip().lower() - if current_uid != uid: - role, _ = _get_current_admin_role() - if not role: - return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403 - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT role FROM users WHERE id = %s", (uid,)) - user_row = cur.fetchone() - valid_menu_type = menu_type if menu_type in ('app', 'admin') else '' - if user_row and (user_row.get('role') or '').strip() == 'super_admin': - sql = """ - SELECT id, name, column_key, menu_type, route_path, sort_order, created_at - FROM columns - """ - params = [] - if valid_menu_type: - sql += " WHERE menu_type = %s" - params.append(valid_menu_type) - sql += " ORDER BY sort_order ASC, id ASC" - cur.execute(sql, tuple(params)) - rows = cur.fetchall() - else: - sql = """ - SELECT c.id, c.name, c.column_key, c.menu_type, c.route_path, c.sort_order, c.created_at - FROM columns c - INNER JOIN user_column_permission ucp ON ucp.column_id = c.id - WHERE ucp.user_id = %s - """ - params = [uid] - if valid_menu_type: - sql += " AND c.menu_type = %s" - params.append(valid_menu_type) - sql += " ORDER BY c.sort_order ASC, c.id ASC" - cur.execute(sql, tuple(params)) - rows = cur.fetchall() - conn.close() - items = [ - { - 'id': r['id'], - 'name': r['name'], - 'column_key': r['column_key'], - 'menu_type': r.get('menu_type') or 'app', - 'route_path': r.get('route_path') or '', - 'sort_order': r.get('sort_order') or 0, - 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '', - } - for r in rows - ] - return jsonify({'success': True, 'items': items}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) - - -@admin_bp.route('/api/admin/history') -@admin_required -def admin_history(): - """管理员分页获取所有生成记录,支持按用户和时间筛选""" - page = max(1, int(request.args.get('page', 1))) - page_size = min(50, max(10, int(request.args.get('page_size', 15)))) - offset = (page - 1) * page_size - user_id = request.args.get('user_id', type=int) - time_start = (request.args.get('time_start') or '').strip() - time_end = (request.args.get('time_end') or '').strip() - conditions, params = [], [] - if user_id: - conditions.append("h.user_id = %s") - params.append(user_id) - if time_start: - conditions.append("h.created_at >= %s") - params.append(time_start) - if time_end: - conditions.append("h.created_at <= %s") - params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end) - where_clause = " AND ".join(conditions) if conditions else "1=1" - params_count = params[:] - params.extend([page_size, offset]) - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute( - """SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls, - h.long_image_url, u.username - FROM image_history h - LEFT JOIN users u ON h.user_id = u.id - WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""", - params, - ) - rows = cur.fetchall() - cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count) - total = cur.fetchone()['total'] - conn.close() - items = [] - for r in rows: - items.append({ - 'id': r['id'], - 'user_id': r['user_id'], - 'username': r.get('username') or '-', - 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '', - 'panel_type': r['panel_type'] or '', - 'original_urls': _parse_json(r['original_urls'], []), - 'params': _parse_json(r['params'], {}), - 'result_urls': _parse_json(r['result_urls'], []), - 'long_image_url': (r.get('long_image_url') or '').strip() or None, - }) - return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) +""" +管理员蓝图:仅保留 /admin 页面渲染。 +用户管理、栏目权限、生成历史等接口已全部迁移至 Java 端。 +""" +from flask import Blueprint + +from app_common import _render_html, admin_required, login_required +from config import JAVA_API_BASE + +admin_bp = Blueprint('admin', __name__) + + +@admin_bp.route('/admin') +@login_required +@admin_required +def admin_page(): + return _render_html('admin.html', java_api_base=JAVA_API_BASE) diff --git a/app/blueprints/auth.py b/app/blueprints/auth.py index 7b003e0..d1ced64 100644 --- a/app/blueprints/auth.py +++ b/app/blueprints/auth.py @@ -1,107 +1,55 @@ -""" -认证蓝图:登录、登出、登录状态校验 -""" -from flask import Blueprint, request, redirect, url_for, session, jsonify -from werkzeug.security import check_password_hash - -from app_common import ( - get_db, - _render_html, - _is_session_user_valid, - login_required, - BASE_DIR, -) -from tool.devices import DeviceIDGenerator - -auth_bp = Blueprint('auth', __name__) - - -@auth_bp.route('/login', methods=['GET', 'POST']) -def login(): - if session.get('user_id') and _is_session_user_valid(): - return redirect(url_for('main.home')) - if request.method == 'POST': - data = request.get_json() if request.is_json else request.form - username = (data.get('username') or '').strip() - password = data.get('password') or '' - if not username or not password: - if request.is_json: - return jsonify({'success': False, 'error': '请输入用户名和密码'}) - return _render_html('login.html', error='请输入用户名和密码') - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute( - "SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s", - (username,) - ) - row = cur.fetchone() - if row and check_password_hash(row['password_hash'], password): - current_machine = DeviceIDGenerator().get_device_id() - stored_machine = (row.get('machine') or '').strip() - if not stored_machine: - with conn.cursor() as cur: - cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id'])) - conn.commit() - conn.close() - session.permanent = True - session['user_id'] = row['id'] - session['username'] = username - if request.is_json: - return jsonify({'success': True, 'redirect': url_for('main.home')}) - return redirect(url_for('main.home')) - print("验证设备",stored_machine) - print("当前设备",current_machine) - if stored_machine != current_machine and row.get("is_admin") != 1: - conn.close() - err_msg = '当前设备与首次登录设备不一致,请在原设备上登录' - if request.is_json: - return jsonify({'success': False, 'error': err_msg}) - return _render_html('login.html', error=err_msg) - conn.close() - session.permanent = True - session['user_id'] = row['id'] - session['username'] = username - if request.is_json: - return jsonify({'success': True, 'redirect': url_for('main.home')}) - return redirect(url_for('main.home')) - conn.close() - except Exception as e: - if request.is_json: - return jsonify({'success': False, 'error': str(e)}) - return _render_html('login.html', error='登录失败,请稍后重试') - if request.is_json: - return jsonify({'success': False, 'error': '用户名或密码错误'}) - return _render_html('login.html', error='用户名或密码错误') - return _render_html('login.html') - - -@auth_bp.route('/api/auth/check') -@login_required -def api_auth_check(): - """校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致""" - if not session.get('user_id'): - return jsonify({'logged_in': False}) - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],)) - row = cur.fetchone() - conn.close() - if not row: - return jsonify({'logged_in': False}) - stored_machine = (row.get('machine') or '').strip() - if stored_machine: - current_machine = DeviceIDGenerator().get_device_id() - if stored_machine != current_machine and row.get("is_admin") != 1: - session.clear() - return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'}) - except Exception: - return jsonify({'logged_in': False}) - return jsonify({'logged_in': True, 'redirect': url_for('main.home')}) - - -@auth_bp.route('/logout') -def logout(): - session.clear() - return redirect(url_for('auth.login')) +""" +认证蓝图:登录页(仅 GET 渲染模板)+ 登出(清 JWT cookie)+ token 同步 +登录表单提交已经直连 Java 后端 /login,Python 这边只负责: +1. 渲染登录页模板 +2. 把 Java 签发的 JWT 从前端写到 Python 同源 cookie,供后续页面跳转携带 +3. 登出:清 cookie 跳回登录页 +""" +import sys + +from flask import Blueprint, redirect, url_for, make_response, request, jsonify + +from app_common import _render_html, current_user_id +from config import JAVA_API_BASE +from jwt_util import COOKIE_NAME, parse_token_with_reason + +auth_bp = Blueprint('auth', __name__) + + +@auth_bp.route('/login', methods=['GET']) +def login(): + if current_user_id(): + return redirect(url_for('main.home')) + return _render_html('login.html', java_api_base=JAVA_API_BASE) + + +@auth_bp.route('/api/auth/sync', methods=['POST']) +def api_auth_sync(): + """前端拿到 Java 返回的 JWT 后调用,把 token 写进 Python 同源 cookie。""" + data = request.get_json(silent=True) or {} + token = (data.get('token') or '').strip() + if not token: + return jsonify({'success': False, 'error': '缺少 token'}), 400 + payload, reason = parse_token_with_reason(token) + if not payload: + # 把根因打到服务端日志,并回传给前端,便于现场排查 + print(f"[auth] /api/auth/sync 校验失败: {reason}", file=sys.stderr) + return jsonify({'success': False, 'error': f'token 无效: {reason}'}), 401 + resp = make_response(jsonify({'success': True})) + resp.set_cookie( + COOKIE_NAME, + token, + max_age=7 * 24 * 3600, + path='/', + httponly=True, + samesite='Lax', + ) + return resp + + +@auth_bp.route('/logout') +def logout(): + resp = make_response(redirect(url_for('auth.login'))) + resp.delete_cookie(COOKIE_NAME, path='/') + return resp + diff --git a/app/blueprints/brand.py b/app/blueprints/brand.py index 0405bbd..fadc2c2 100644 --- a/app/blueprints/brand.py +++ b/app/blueprints/brand.py @@ -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(): diff --git a/app/blueprints/brand_备份.py b/app/blueprints/brand_备份.py deleted file mode 100644 index 7336ef3..0000000 --- a/app/blueprints/brand_备份.py +++ /dev/null @@ -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/') -# @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//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//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//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/', 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/') -@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 - - diff --git a/app/blueprints/image.py b/app/blueprints/image.py index c454d54..6ba3ffe 100644 --- a/app/blueprints/image.py +++ b/app/blueprints/image.py @@ -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) diff --git a/app/blueprints/main.py b/app/blueprints/main.py index ad4498b..432ce9a 100644 --- a/app/blueprints/main.py +++ b/app/blueprints/main.py @@ -1,23 +1,24 @@ -""" -主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo -""" +""" +主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo +""" import os from flask import Blueprint, send_file, render_template_string - -from app_common import ( - get_db, - _render_html, - _is_session_user_valid, - login_required, - admin_required, - STATIC_DIR, - BASE_DIR, - ASSETS_DIR -) -from flask import redirect, url_for, session -from flask import Flask, request, Response, stream_with_context -import requests - + +from app_common import ( + get_db, + _render_html, + login_required, + admin_required, + current_user_id, + current_username, + STATIC_DIR, + BASE_DIR, + ASSETS_DIR +) +from flask import redirect, url_for +from flask import Flask, request, Response, stream_with_context +import requests + from config import base_url,version,JAVA_API_BASE main_bp = Blueprint('main', __name__) @@ -63,35 +64,36 @@ def _resolve_asset_filename(filename): return matches[0] return safe_name - - -@main_bp.route('/') -def index(): - if session.get('user_id') and _is_session_user_valid(): - return redirect(url_for('main.home')) - return redirect(url_for('auth.login')) - - -@main_bp.route('/home') -@login_required -def home(): - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],)) - row = cur.fetchone() - conn.close() - return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version) - except Exception: - return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version) - - -@main_bp.route('/image') -@login_required -def wb(): - return _render_html('index.html') - - + + +@main_bp.route('/') +def index(): + if current_user_id(): + return redirect(url_for('main.home')) + return redirect(url_for('auth.login')) + + +@main_bp.route('/home') +@login_required +def home(): + uid = current_user_id() + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (uid,)) + row = cur.fetchone() + conn.close() + return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=uid, baseUrl=base_url, version=version, java_api_base=JAVA_API_BASE) + except Exception: + return _render_html('home.html', username=current_username(), is_admin=False, user_id=uid, baseUrl=base_url, version=version, java_api_base=JAVA_API_BASE) + + +@main_bp.route('/image') +@login_required +def wb(): + return _render_html('index.html') + + @main_bp.route('/brand') @login_required def brand_page(): @@ -112,41 +114,41 @@ def brand_page(): if raw[:7] == b'gAAAAAB': raise content = raw.decode('utf-8', errors='replace') - return render_template_string(content, user_id=session.get('user_id')) + return render_template_string(content, user_id=current_user_id(), java_api_base=JAVA_API_BASE) except Exception: continue - return _render_html('brand.html', user_id=session.get('user_id')) - - -@main_bp.route('/brand/legacy') -@login_required -def brand_page_legacy(): - legacy_path = os.path.join(BASE_DIR, 'web_source', 'templates_backup', 'brand.html') - if not os.path.isfile(legacy_path): - return '', 404 - with open(legacy_path, 'rb') as f: - content = f.read().decode('utf-8', errors='replace') - content = content.replace( - '
', - '
', - 1, + return _render_html('brand.html', user_id=current_user_id(), java_api_base=JAVA_API_BASE) + + +@main_bp.route('/brand/legacy') +@login_required +def brand_page_legacy(): + legacy_path = os.path.join(BASE_DIR, 'web_source', 'templates_backup', 'brand.html') + if not os.path.isfile(legacy_path): + return '', 404 + with open(legacy_path, 'rb') as f: + content = f.read().decode('utf-8', errors='replace') + content = content.replace( + '
', + '
', + 1, ) content = content.replace('height: calc(100vh - 56px);', 'height: 100vh;', 1) - return render_template_string(content, user_id=session.get('user_id')) - - - - -@main_bp.route('/static/') -def serve_static(filename): - """提供 static 目录及子目录下的静态文件访问。""" - filepath = os.path.normpath(os.path.join(STATIC_DIR, filename)) - static_abs = os.path.abspath(STATIC_DIR) - file_abs = os.path.abspath(filepath) - if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs): - return '', 404 - return send_file(file_abs, as_attachment=False) - + return render_template_string(content, user_id=current_user_id()) + + + + +@main_bp.route('/static/') +def serve_static(filename): + """提供 static 目录及子目录下的静态文件访问。""" + filepath = os.path.normpath(os.path.join(STATIC_DIR, filename)) + static_abs = os.path.abspath(STATIC_DIR) + file_abs = os.path.abspath(filepath) + if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs): + return '', 404 + return send_file(file_abs, as_attachment=False) + @main_bp.route('/assets/') def serve_assets(filename): @@ -165,8 +167,8 @@ def serve_assets(filename): if os.path.isfile(file_abs): return send_file(file_abs, as_attachment=False) return '', 404 - - + + @main_bp.route('/new_web_source/') @login_required def serve_new_web_source(filename): @@ -179,7 +181,7 @@ def serve_new_web_source(filename): if filename.lower().endswith('.html'): with open(file_abs, 'rb') as f: content = f.read().decode('utf-8', errors='replace') - raw_uid = session.get('user_id') + raw_uid = current_user_id() uid_value = str(int(raw_uid)) if str(raw_uid or '').isdigit() else '""' uid_script = ( "\n diff --git a/app/web_source/login.html b/app/web_source/login.html index 8df057f..f04ca64 100644 --- a/app/web_source/login.html +++ b/app/web_source/login.html @@ -117,6 +117,10 @@ diff --git a/backend-java/pom.xml b/backend-java/pom.xml index 2420199..c7edf68 100644 --- a/backend-java/pom.xml +++ b/backend-java/pom.xml @@ -26,6 +26,7 @@ 2.3.5 8.5.17 3.28.0-GA + 0.12.6 @@ -104,6 +105,23 @@ minio ${minio.version} + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + org.projectlombok lombok diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java index ec84041..06a8da1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java @@ -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(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminUserController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminUserController.java new file mode 100644 index 0000000..5060d49 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/controller/AdminUserController.java @@ -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 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 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 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 deleteUser(HttpServletRequest request, + @PathVariable Long uid) { + AdminUserEntity currentUser = adminAuthSupport.requireAdmin(request); + adminUserService.deleteUser(currentUser, uid); + return ApiResponse.success("删除成功", null); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/dto/AdminUserCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/dto/AdminUserCreateRequest.java new file mode 100644 index 0000000..e4ff19a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/dto/AdminUserCreateRequest.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/dto/AdminUserUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/dto/AdminUserUpdateRequest.java new file mode 100644 index 0000000..7db1e22 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/dto/AdminUserUpdateRequest.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminBriefVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminBriefVo.java new file mode 100644 index 0000000..d275d10 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminBriefVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserItemVo.java new file mode 100644 index 0000000..a01d39a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserItemVo.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserListVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserListVo.java new file mode 100644 index 0000000..412c1ee --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserListVo.java @@ -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 items; + private Long total; + private Integer page; + @JsonProperty("page_size") + private Integer pageSize; + @JsonProperty("current_user_role") + private String currentUserRole; + private List admins; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java new file mode 100644 index 0000000..e775f1a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java @@ -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 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 rows = adminUserMapper.selectList(query); + + Map creatorMap = loadCreatorMap(rows); + + List items = rows.stream().map(r -> toItem(r, creatorMap)).toList(); + List admins = "super_admin".equals(role) + ? adminUserMapper.selectList(new LambdaQueryWrapper() + .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() + .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 update = new LambdaUpdateWrapper() + .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 loadCreatorMap(List rows) { + Set 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 creators = adminUserMapper.selectList(new LambdaQueryWrapper() + .in(AdminUserEntity::getId, new ArrayList<>(creatorIds))); + Map 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 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; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupport.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupport.java new file mode 100644 index 0000000..e5905c2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/support/AdminAuthSupport.java @@ -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() + .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; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java index 5a33543..60582b4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java @@ -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()); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/config/AuthProperties.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/config/AuthProperties.java new file mode 100644 index 0000000..e3ee404 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/config/AuthProperties.java @@ -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"; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/controller/LoginController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/controller/LoginController.java new file mode 100644 index 0000000..a262e0f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/controller/LoginController.java @@ -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> 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> 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> 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; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/mapper/LoginUserMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/mapper/LoginUserMapper.java new file mode 100644 index 0000000..ff8a9f4 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/mapper/LoginUserMapper.java @@ -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 { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/dto/LoginRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/dto/LoginRequest.java new file mode 100644 index 0000000..04f828d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/dto/LoginRequest.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/entity/LoginUserEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/entity/LoginUserEntity.java new file mode 100644 index 0000000..dc70fcc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/entity/LoginUserEntity.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/vo/LoginResultVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/vo/LoginResultVo.java new file mode 100644 index 0000000..68ecdb2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/model/vo/LoginResultVo.java @@ -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 appColumns; + private List adminColumns; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/AuthService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/AuthService.java new file mode 100644 index 0000000..bd252cd --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/AuthService.java @@ -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() + .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() + .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(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java new file mode 100644 index 0000000..dab9047 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/service/JwtService.java @@ -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(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/auth/util/WerkzeugPasswordEncoder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/util/WerkzeugPasswordEncoder.java new file mode 100644 index 0000000..0d736b8 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/auth/util/WerkzeugPasswordEncoder.java @@ -0,0 +1,96 @@ +package com.nanri.aiimage.modules.auth.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.HexFormat; + +/** + * 兼容 Werkzeug `generate_password_hash` 的 PBKDF2-HMAC-SHA256 输出格式。 + * 形如:pbkdf2:sha256:iterations$salt$hexdigest(Werkzeug 默认 600000 轮、salt 16 位)。 + */ +@Component +@Slf4j +public class WerkzeugPasswordEncoder { + + private static final int DK_BITS = 32 * 8; + private static final int DEFAULT_ITERATIONS = 600_000; + private static final int SALT_LENGTH = 16; + private static final String SALT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final SecureRandom RANDOM = new SecureRandom(); + + public String hash(String rawPassword) { + if (rawPassword == null) { + throw new IllegalArgumentException("password cannot be null"); + } + String salt = generateSalt(); + try { + byte[] derived = pbkdf2Sha256(rawPassword.toCharArray(), + salt.getBytes(StandardCharsets.UTF_8), DEFAULT_ITERATIONS, DK_BITS); + String hex = HexFormat.of().formatHex(derived); + return "pbkdf2:sha256:" + DEFAULT_ITERATIONS + "$" + salt + "$" + hex; + } catch (Exception e) { + throw new IllegalStateException("生成密码摘要失败: " + e.getMessage(), e); + } + } + + public boolean matches(String rawPassword, String storedHash) { + if (rawPassword == null || storedHash == null || storedHash.isBlank()) { + return false; + } + try { + int firstSep = storedHash.indexOf('$'); + int secondSep = storedHash.indexOf('$', firstSep + 1); + if (firstSep < 0 || secondSep < 0) { + return false; + } + String method = storedHash.substring(0, firstSep); + String salt = storedHash.substring(firstSep + 1, secondSep); + String expectedHex = storedHash.substring(secondSep + 1); + if (!method.startsWith("pbkdf2:sha256")) { + log.warn("[auth] unsupported password hash scheme: {}", method); + return false; + } + String[] parts = method.split(":"); + int iterations = parts.length >= 3 ? Integer.parseInt(parts[2]) : 600_000; + byte[] derived = pbkdf2Sha256(rawPassword.toCharArray(), salt.getBytes(StandardCharsets.UTF_8), iterations, DK_BITS); + String actualHex = HexFormat.of().formatHex(derived); + return constantTimeEquals(actualHex, expectedHex); + } catch (Exception e) { + log.warn("[auth] verify password failed: {}", e.getMessage()); + return false; + } + } + + private static byte[] pbkdf2Sha256(char[] password, byte[] salt, int iterations, int keyBits) throws Exception { + PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, keyBits); + try { + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded(); + } finally { + spec.clearPassword(); + } + } + + private static boolean constantTimeEquals(String a, String b) { + if (a == null || b == null || a.length() != b.length()) { + return false; + } + int diff = 0; + for (int i = 0; i < a.length(); i++) { + diff |= a.charAt(i) ^ b.charAt(i); + } + return diff == 0; + } + + private static String generateSalt() { + StringBuilder sb = new StringBuilder(SALT_LENGTH); + for (int i = 0; i < SALT_LENGTH; i++) { + sb.append(SALT_ALPHABET.charAt(RANDOM.nextInt(SALT_ALPHABET.length()))); + } + return sb.toString(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java new file mode 100644 index 0000000..027d1dd --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java @@ -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 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); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/mapper/ImageHistoryMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/mapper/ImageHistoryMapper.java new file mode 100644 index 0000000..e060230 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/mapper/ImageHistoryMapper.java @@ -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 { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/entity/ImageHistoryEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/entity/ImageHistoryEntity.java new file mode 100644 index 0000000..0cf680d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/entity/ImageHistoryEntity.java @@ -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; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/vo/ImageHistoryItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/vo/ImageHistoryItemVo.java new file mode 100644 index 0000000..de31f92 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/vo/ImageHistoryItemVo.java @@ -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 originalUrls; + private Map params; + @JsonProperty("result_urls") + private List resultUrls; + @JsonProperty("long_image_url") + private String longImageUrl; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/vo/ImageHistoryListVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/vo/ImageHistoryListVo.java new file mode 100644 index 0000000..5fe5dd7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/model/vo/ImageHistoryListVo.java @@ -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 items; + private Long total; + private Integer page; + @JsonProperty("page_size") + private Integer pageSize; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/service/ImageHistoryService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/service/ImageHistoryService.java new file mode 100644 index 0000000..f2a34ca --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/service/ImageHistoryService.java @@ -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_REF = new TypeReference<>() { + }; + private static final TypeReference> 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 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 rows = imageHistoryMapper.selectList(query); + + Map usernameMap = loadUsernameMap(rows); + + List 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 loadUsernameMap(List rows) { + Set 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 users = adminUserMapper.selectList(new LambdaQueryWrapper() + .in(AdminUserEntity::getId, new ArrayList<>(userIds))); + Map 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 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 parseList(String json) { + if (json == null || json.isBlank()) { + return Collections.emptyList(); + } + try { + List 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 parseMap(String json) { + if (json == null || json.isBlank()) { + return Collections.emptyMap(); + } + try { + Map 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); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/entity/AdminUserEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/entity/AdminUserEntity.java index 9ac7b4e..99fed96 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/entity/AdminUserEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/entity/AdminUserEntity.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java index 2db9e38..46c6d49 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java index cbc73ec..38b6a8b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java @@ -355,9 +355,14 @@ public class SimilarAsinCozeClient { List urls = rows.stream().map(this::primaryImageUrl).toList(); List> urlLists = rows.stream().map(this::imageUrls).toList(); + List> items = buildItemObjects(asins, titles, skus, urls, urlLists); + logCozeItemsDiff(rows, items); Map 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 rows, List> 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 item = items.get(i); + if (row == null || item == null) { + continue; + } + List 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 maskCozeRequestBody(Map body) { Map masked = new LinkedHashMap<>(body); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java index b0fd34e..f6cc6e1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java @@ -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 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 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 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 normalized = new ArrayList<>(); + appendUrls(normalized, value); this.url = normalized.isEmpty() ? "" : normalized.get(0); } public List getUrls() { - List normalized = normalizeUrls(urls); - if (url != null && !url.isBlank() && normalized.stream().noneMatch(url.trim()::equals)) { - List 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 normalized = normalizeUrls(urls); + // 仅写 urls 字段;不再回写 url,避免 url/urls 交叉污染。 + List normalized = new ArrayList<>(); + appendUrls(normalized, urls); this.urls = normalized; - this.url = normalized.isEmpty() ? "" : normalized.get(0); } public boolean hasImageUrl() { - return !getUrls().isEmpty(); - } - - private static List normalizeUrls(Object value) { - List result = new ArrayList<>(); - appendUrls(result, value); - return limitUrls(result); + // url(主图)和 urls(同类商品图)任一存在即可作为可送 Coze 的素材。 + if (url != null && !url.isBlank()) { + return true; + } + return !cleanUrls(urls).isEmpty(); } private static void appendUrls(List result, Object value) { @@ -214,19 +210,21 @@ public class SimilarAsinResultRowDto { } } - private static List limitUrls(List values) { - Set deduplicated = new LinkedHashSet<>(); - if (values != null) { - for (String value : values) { - if (value == null || value.isBlank()) { - continue; - } - deduplicated.add(value.trim()); - if (deduplicated.size() >= MAX_IMAGE_URLS) { - break; - } + private static List cleanUrls(List values) { + // 仅做空值过滤与 trim,不去重、不截断,原样保留 Python 回传顺序与数量。 + if (values == null || values.isEmpty()) { + return new ArrayList<>(); + } + List result = new ArrayList<>(values.size()); + for (String value : values) { + if (value == null) { + continue; + } + String trimmed = value.trim(); + if (!trimmed.isBlank()) { + result.add(trimmed); } } - return new ArrayList<>(deduplicated); + return result; } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 7633e5f..59fa4cd 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -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 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 urls = row.getUrls(); + log.info("[similar-asin] python inbound idx={} groupKey={} rowToken={} id={} asin={} country={} title={} sku={} url={} urlsSize={} urlsHead={} urlsTail={}", + i, + normalize(row.getGroupKey()), + normalize(row.getRowToken()), + normalize(row.getId()), + normalize(row.getAsin()), + normalize(row.getCountry()), + abbreviateForLog(row.getTitle(), 80), + normalize(row.getSku()), + abbreviateForLog(url, 200), + urls == null ? 0 : urls.size(), + urls == null || urls.isEmpty() ? "" : abbreviateForLog(urls.get(0), 200), + urls == null || urls.size() <= 1 ? "" : abbreviateForLog(urls.get(urls.size() - 1), 200)); + } + } + + private String abbreviateForLog(String value, int maxLength) { + if (value == null) { + return ""; + } + String trimmed = value.trim(); + if (maxLength <= 0 || trimmed.length() <= maxLength) { + return trimmed; + } + return trimmed.substring(0, maxLength) + "..."; + } + private int allRowCount(FileTaskEntity task) { try { SimilarAsinParsedPayloadDto payload = readParsedPayload(task); @@ -1634,13 +1679,8 @@ public class SimilarAsinTaskService { if (parsedRow == null) { return row; } - if (normalize(row.getUrl()).isBlank()) { - String parsedUrl = firstNonBlank(parsedRow.getUrl(), readValueByHeader(parsedRow, - "url", "rul", "image", "img", "pic", "picture", "link")); - if (!normalize(parsedUrl).isBlank()) { - row.setUrl(parsedUrl); - } - } + // 不再回填 url:Python 端会同时回传 url(主图)与 urls(同类商品图), + // 缺失场景应在 Python 侧定位,Java 不再合成新的 url 字段以避免覆盖原始数据。 if (normalize(row.getTitle()).isBlank()) { row.setTitle(parsedRow.getTitle()); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java index 5ced792..2465101 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java @@ -58,11 +58,13 @@ public class SimilarAsinImageEmbedder { static final int DOWNLOAD_TIMEOUT_SECONDS = 5; static final int DOWNLOAD_MAX_RETRY = 1; static final int DOWNLOAD_POOL_SIZE = 8; - public static final float IMAGE_ROW_HEIGHT_POINTS = 150f; - public static final int IMAGE_COL_WIDTH_CHARS = 30; - static final int TARGET_LONG_EDGE_PX = 300; - static final float JPEG_QUALITY = 0.85f; - static final int MAX_THUMB_SIZE_BYTES = 40 * 1024; + // B 方案:单元格放大到 Excel 上限附近(255 char ≈ 1785 px / 409.5 pt ≈ 546 px),保持 16:9 显示空间。 + public static final float IMAGE_ROW_HEIGHT_POINTS = 409f; + public static final int IMAGE_COL_WIDTH_CHARS = 80; + // 源图长边 1280 px 给 Excel 缩放/打印留余量;q=0.75 + 300KB 出口硬上限主动控制单图体积。 + static final int TARGET_LONG_EDGE_PX = 1280; + static final float JPEG_QUALITY = 0.75f; + static final int MAX_THUMB_SIZE_BYTES = 300 * 1024; static final int MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024; static final int MAX_DECODE_PIXELS = 6000 * 6000; @@ -150,6 +152,9 @@ public class SimilarAsinImageEmbedder { anchor.setRow2(rowIdx + 1); anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE); patriarch.createPicture(anchor, picIdx); + // POI 已经把缩略图字节复制进 picture pool(B 副本),此时移除 taskImageCache 里的 A 副本可立刻释放。 + // 同 URL 行内重复出现概率较低,且 cache 仍承担同任务跨行去重收益(首次 miss 时已经 put 过)。 + taskImageCache.remove(trimmedUrl); } catch (RuntimeException ex) { log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage()); row.createCell(colIdx).setCellValue(trimmedUrl); @@ -440,8 +445,10 @@ public class SimilarAsinImageEmbedder { } /** - * resize 出口字节硬上限保护:worst case 6000 行 × 3 列 × 40KB × 2 副本 = 1440MB; - * 因此线上限制建议结合 `taskImageCache` 命中率与单任务行数评估,超出预算需缩小 MAX_THUMB_SIZE_BYTES 或限制行数。 + * resize 出口字节硬上限保护:B 方案下单图最坏 ~300KB;POI picture pool 在 SXSSFWorkbook.dispose() 前不会 spill 到临时文件, + * 因此 N 行 × 3 列 × 300KB 全量驻留堆。配合 -Xmx2048M:≈1500 行 × 3 列 ≈ 1.32GB picture pool,已逼近安全水位; + * embed() 成功后会立刻 taskImageCache.remove() 释放 A 副本,B 副本(picture pool)仍随 workbook 生命周期驻留。 + * 超过 ~1500 行需要降低 MAX_THUMB_SIZE_BYTES 或调小 TARGET_LONG_EDGE_PX,必要时再考虑拆任务。 */ public static class ResizeOversizeException extends ResizeException { private final String url; diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 2fa1a2e..e4e0884 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -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} diff --git a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue index 9ed6306..353d7b2 100644 --- a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue @@ -19,7 +19,7 @@
新增条件要求
-