Files
crawler-plugin/backend/blueprints/admin_api.py
2026-07-21 19:53:47 +08:00

3730 lines
139 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
管理员 API 蓝图:用户管理、生成历史、版本管理(后台)
"""
import json
import os
import re
import tempfile
import threading
import zipfile
from datetime import datetime
from urllib.parse import quote
import requests
from requests.adapters import HTTPAdapter
from flask import Blueprint, request, jsonify, session, current_app, g, Response, send_file
import pymysql
from werkzeug.security import generate_password_hash
from utils.db import get_db
from utils.auth import admin_required, login_required, get_current_admin_role
ALLOWED_DEDUPE_TOTAL_DATA_ADMIN_USERNAMES = {'刘丽泓'}
def _can_access_dedupe_total_data(role, current_row):
_, _, denied = _ensure_admin_menu_access('dedupe-total-data')
return denied is None
if role == 'super_admin':
return True
if role != 'admin' or not current_row:
return False
return (current_row.get('username') or '').strip() in ALLOWED_DEDUPE_TOTAL_DATA_ADMIN_USERNAMES
def _ensure_dedupe_total_data_access():
return _ensure_backend_menu_access('dedupe-total-data')
role, current_row = get_current_admin_role()
if not _can_access_dedupe_total_data(role, current_row):
return None, None, (jsonify({'success': False, 'error': '无权访问数据去重汇总数据'}), 403)
return role, current_row, None
from ali_oss import upload_file as oss_upload_file
try:
from config import bucket_path, backend_java_base_url
except ImportError:
bucket_path = os.environ.get('BUCKET_PATH', 'nanri-image/')
backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://api.aishufu.top:18080/').rstrip('/')
admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin')
_backend_java_session_local = threading.local()
_column_sort_schema_checked = False
_column_sort_schema_lock = threading.Lock()
_product_category_schema_checked = False
_product_category_schema_lock = threading.Lock()
IMAGE_VIDEO_PERMISSION_KEY = 'admin_image_video_tasks'
IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_task_data'
ADMIN_MENU_ACCESS_CONFIG = {
'dedupe-total-data': {
'column_key': 'admin_dedupe_total_data',
'route_path': 'dedupe-total-data',
'error': '无权访问数据去重汇总数据',
},
'shop-manage': {
'column_key': 'admin_shop_manage',
'route_path': 'shop-manage',
'error': '无权访问店铺管理模块',
},
'skip-price-asin': {
'column_key': 'admin_skip_price_asin',
'route_path': 'skip-price-asin',
'error': '无权访问跳过跟价 ASIN 模块',
},
'query-asin': {
'column_key': 'admin_query_asin',
'route_path': 'query-asin',
'error': '无权访问查询 ASIN 模块',
},
'product-categories': {
'column_key': 'admin_product_categories',
'route_path': 'product-categories',
'error': '无权访问商品类目模块',
},
'invalid-asin-data': {
'column_key': 'admin_invalid_asin_data',
'route_path': 'invalid-asin-data',
'error': '无权访问不符合ASIN数据模块',
},
'image-video-tasks': {
'column_key': 'admin_image_video_tasks',
'route_path': 'image-video-tasks',
'error': '无权访问视频任务管理模块',
},
}
ADMIN_MENU_ACCESS_CONFIG.update({
'users': {
'column_key': 'admin_users',
'route_path': 'users',
'error': '无权访问用户管理模块',
},
'columns': {
'column_key': 'admin_columns',
'route_path': 'columns',
'error': '无权访问栏目权限配置模块',
},
'shop-keys': {
'column_key': 'admin_shop_keys',
'route_path': 'shop-keys',
'error': '无权访问店铺密钥管理模块',
},
'history': {
'column_key': 'admin_history',
'route_path': 'history',
'error': '无权访问查看生成记录模块',
},
'version': {
'column_key': 'admin_version',
'route_path': 'version',
'error': '无权访问版本管理模块',
},
'digital-human-version': {
'column_key': 'digital_human_version',
'route_path': 'digital-human-version',
'error': '无权访问数字人版本管理模块',
},
})
def _safe_version_key(version):
"""将版本号转换为安全的 OSS 对象名片段"""
s = (version or '').strip()
s = re.sub(r'[^\w.\-]', '_', s)
return s or 'unknown'
def _get_backend_java_session():
http_session = getattr(_backend_java_session_local, 'session', None)
if http_session is None:
http_session = requests.Session()
adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0)
http_session.mount('http://', adapter)
http_session.mount('https://', adapter)
_backend_java_session_local.session = http_session
return http_session
def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None, data=None, timeout=10):
url = f"{backend_java_base_url}{path}"
try:
requester = requests if files else _get_backend_java_session()
resp = requester.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=timeout)
except requests.RequestException:
return None, jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
try:
data = resp.json()
except ValueError:
data = None
if resp.status_code >= 400:
if isinstance(data, dict):
return data, jsonify({'success': False, 'error': data.get('message') or data.get('error') or 'backend-java 请求失败'}), resp.status_code
return None, jsonify({'success': False, 'error': 'backend-java 请求失败'}), resp.status_code
if not isinstance(data, dict):
return None, jsonify({'success': False, 'error': 'backend-java 返回格式错误'}), 502
if not data.get('success'):
return data, jsonify({'success': False, 'error': data.get('message') or '操作失败'}), 200
return data, None, 200
def _format_permission_item(item):
created_at = item.get('createdAt')
if created_at in (None, ''):
created_at = item.get('created_at')
if hasattr(created_at, 'strftime'):
created_at_text = created_at.strftime('%Y-%m-%d %H:%M')
else:
created_at_text = (str(created_at).replace('T', ' ')[:16]) if created_at else ''
sort_order = item.get('sortOrder')
if sort_order is None:
sort_order = item.get('sort_order')
return {
'id': item.get('id'),
'name': item.get('name') or '',
'column_key': item.get('columnKey') or item.get('column_key') or '',
'menu_type': item.get('menuType') or item.get('menu_type') or 'app',
'route_path': item.get('routePath') or item.get('route_path') or '',
'sort_order': sort_order if sort_order is not None else 0,
'created_at': created_at_text,
}
def _parse_optional_int(value):
if value is None:
return None
text = str(value).strip()
if text == '':
return None
return int(text)
_IMAGE_VIDEO_SECRET_KEYS = {
'api_key', 'apikey', 'token', 'coze_token', 'cozetoken', 't8_key', 't8key',
't8_video_key', 't8videokey', 't8star_key', 't8starkey',
'ai_conductor_key', 'aiconductorkey', 'copy_api_key', 'copyapikey',
'voice_api_key', 'voiceapikey',
}
def _parse_json_value(value, fallback=None):
if value in (None, ''):
return fallback
if isinstance(value, (dict, list)):
return value
try:
return json.loads(value)
except (TypeError, ValueError):
return value
def _mask_image_video_secrets(value):
if isinstance(value, dict):
masked = {}
for key, item in value.items():
canonical = re.sub(r'[^a-z0-9]', '', str(key).lower())
configured_keys = {re.sub(r'[^a-z0-9]', '', item) for item in _IMAGE_VIDEO_SECRET_KEYS}
masked[key] = '******' if canonical in configured_keys and item not in (None, '') else _mask_image_video_secrets(item)
return masked
if isinstance(value, list):
return [_mask_image_video_secrets(item) for item in value]
return value
def _image_video_mode(request_value):
payload = _parse_json_value(request_value, {})
try:
mode = str(payload.get('parameters', {}).get('video_info', {}).get('mode', '')).strip()
except AttributeError:
mode = ''
if mode == '1':
return '图生视频'
if mode == '2':
return '视频复刻'
return '未知'
def _image_video_access_sql(role, current_row, alias='t'):
if role == 'super_admin':
return '1=1', []
admin_id = int(current_row['id'])
return (
f"({alias}.user_id = %s OR {alias}.user_id IN "
"(SELECT id FROM users WHERE role = 'normal' AND created_by_id = %s))",
[admin_id, admin_id],
)
def _format_admin_datetime(value):
if hasattr(value, 'strftime'):
return value.strftime('%Y-%m-%d %H:%M:%S')
return str(value).replace('T', ' ')[:19] if value else ''
def _image_video_urls(row):
originals = _parse_json_value(row.get('video_urls_json'), [])
originals = originals if isinstance(originals, list) else []
archived = _parse_json_value(row.get('archived_videos_json'), [])
archived = archived if isinstance(archived, list) else []
archived_by_source = {
str(item.get('sourceUrl') or ''): item
for item in archived if isinstance(item, dict)
}
items = []
for source_url in originals:
source_url = str(source_url or '')
stored = archived_by_source.get(source_url) or {}
items.append({
'source_url': source_url,
'archived_url': stored.get('url') or '',
'object_key': stored.get('objectKey') or '',
'archive_status': stored.get('status') or '',
'archive_error': stored.get('error') or '',
'display_url': stored.get('url') or source_url,
})
return items
def _image_video_admin_item(row, include_json=False):
videos = _image_video_urls(row)
item = {
'task_id': row.get('id'),
'user_id': row.get('user_id'),
'username': row.get('username') or '',
'group_name': row.get('group_name') or '',
'mode': _image_video_mode(row.get('request_json')),
'status': row.get('status') or '',
'coze_status': row.get('coze_status') or '',
'coze_execute_id': row.get('coze_execute_id') or '',
'videos': videos,
'video_url': videos[0]['display_url'] if videos else '',
'debug_url': row.get('debug_url') or '',
'archive_status': row.get('archive_status') or '',
'archive_error': row.get('archive_error') or '',
'archive_attempt_count': row.get('archive_attempt_count') or 0,
'submitted_at': _format_admin_datetime(row.get('submitted_at')),
'completed_at': _format_admin_datetime(row.get('completed_at')),
}
if include_json:
item.update({
'error_message': row.get('error_message') or '',
'archived_at': _format_admin_datetime(row.get('archived_at')),
'request': _mask_image_video_secrets(_parse_json_value(row.get('request_json'), {})),
'submit_response': _parse_json_value(row.get('submit_response_json'), {}),
'result': _parse_json_value(row.get('result_json'), {}),
})
return item
def _ensure_column_sort_schema():
global _column_sort_schema_checked
if _column_sort_schema_checked:
return
with _column_sort_schema_lock:
if _column_sort_schema_checked:
return
conn = get_db()
try:
with conn.cursor() as cur:
try:
cur.execute("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER route_path")
except Exception:
pass
try:
cur.execute("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0")
except Exception:
pass
conn.commit()
_column_sort_schema_checked = True
finally:
conn.close()
return
conn = get_db()
try:
with conn.cursor() as cur:
try:
cur.execute("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER route_path")
except Exception:
pass
try:
cur.execute("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0")
except Exception:
pass
conn.commit()
finally:
conn.close()
def _load_local_column_sort_map():
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute("SELECT id, sort_order FROM columns")
rows = cur.fetchall()
return {int(row['id']): int(row.get('sort_order') or 0) for row in rows if row.get('id') is not None}
finally:
conn.close()
def _load_local_columns(menu_type=None):
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
sql = """
SELECT id, name, column_key, menu_type, route_path, sort_order, created_at
FROM columns
"""
params = []
if menu_type:
sql += " WHERE menu_type = %s"
params.append(menu_type)
sql += " ORDER BY sort_order ASC, id ASC"
cur.execute(sql, params)
return cur.fetchall()
finally:
conn.close()
def _sync_local_columns_from_backend(items):
if not items:
return
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
for item in items:
item_id = item.get('id')
if item_id is None:
continue
sort_order = item.get('sort_order')
cur.execute(
"""
INSERT INTO columns (id, name, column_key, menu_type, route_path, sort_order, created_at)
VALUES (%s, %s, %s, %s, %s, %s, COALESCE(%s, NOW()))
ON DUPLICATE KEY UPDATE
name = VALUES(name),
column_key = VALUES(column_key),
menu_type = VALUES(menu_type),
route_path = VALUES(route_path),
sort_order = CASE
WHEN columns.sort_order IS NULL OR columns.sort_order = 0 THEN VALUES(sort_order)
ELSE columns.sort_order
END
""",
(
int(item_id),
item.get('name') or '',
item.get('column_key') or '',
item.get('menu_type') or 'app',
item.get('route_path') or '',
int(sort_order if sort_order not in (None, '') else item_id),
item.get('created_at') or None,
),
)
conn.commit()
finally:
conn.close()
def _set_local_column_sort_order(column_id, sort_order):
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE columns SET sort_order = %s WHERE id = %s",
(int(sort_order), int(column_id)),
)
conn.commit()
finally:
conn.close()
def _get_next_local_column_sort_order():
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute("SELECT COALESCE(MAX(sort_order), 0) AS max_sort FROM columns")
row = cur.fetchone() or {}
return int(row.get('max_sort') or 0) + 1
finally:
conn.close()
PRODUCT_CATEGORY_DEFAULT_TREE = [
{
'name': '护肤品',
'key': 'skincare',
'children': [
('面霜', 'skincare_cream'),
('精华', 'skincare_essence'),
('面膜', 'skincare_mask'),
('爽肤水', 'skincare_toner'),
('身体乳', 'skincare_body_lotion'),
],
},
{
'name': '彩妆',
'key': 'makeup',
'children': [
('口红', 'makeup_lipstick'),
('粉底', 'makeup_foundation'),
('眼影', 'makeup_eyeshadow'),
('睫毛', 'makeup_mascara'),
],
},
{
'name': '洗护',
'key': 'haircare',
'children': [
('洗发水', 'haircare_shampoo'),
('护发素', 'haircare_conditioner'),
('染发剂', 'haircare_hair_dye'),
('烫发膏', 'haircare_perm_cream'),
],
},
{
'name': '功效类',
'key': 'functional',
'children': [
('防晒', 'functional_sunscreen'),
('祛斑', 'functional_spot_removal'),
('美白', 'functional_whitening'),
('祛痘产品', 'functional_acne_care'),
],
},
{
'name': '香氛',
'key': 'fragrance',
'children': [
('香水', 'fragrance_perfume'),
('香薰精油(高酒精属于危险品)', 'fragrance_essential_oil_hazardous'),
],
},
]
def _ensure_local_admin_menu_item(name, column_key, route_path, sort_order):
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO columns (name, column_key, menu_type, route_path, sort_order)
VALUES (%s, %s, 'admin', %s, %s)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
menu_type = 'admin',
route_path = VALUES(route_path),
sort_order = VALUES(sort_order)
""",
(name, column_key, route_path, int(sort_order)),
)
conn.commit()
finally:
conn.close()
def _ensure_product_category_schema():
global _product_category_schema_checked
if _product_category_schema_checked:
return
with _product_category_schema_lock:
if _product_category_schema_checked:
return
_ensure_local_admin_menu_item('商品类目', 'admin_product_categories', 'product-categories', 66)
_product_category_schema_checked = True
def _swap_local_column_sort_order(column_id, target_id):
_ensure_column_sort_schema()
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute("SELECT id, sort_order FROM columns WHERE id IN (%s, %s) ORDER BY id", (int(column_id), int(target_id)))
rows = cur.fetchall()
if len(rows) != 2:
raise ValueError('菜单不存在')
by_id = {int(row['id']): int(row.get('sort_order') or 0) for row in rows}
left_sort = by_id.get(int(column_id), 0) or int(column_id)
right_sort = by_id.get(int(target_id), 0) or int(target_id)
cur.execute("UPDATE columns SET sort_order = %s WHERE id = %s", (right_sort, int(column_id)))
cur.execute("UPDATE columns SET sort_order = %s WHERE id = %s", (left_sort, int(target_id)))
conn.commit()
finally:
conn.close()
def _sync_user_column_permissions(user_id, column_ids):
_, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/permission-users/{user_id}/columns',
json_data={'columnIds': [int(x) for x in (column_ids or []) if str(x).isdigit()]},
)
return error_response, status
def _get_current_admin_id(current_row=None):
if current_row and current_row.get('id'):
return current_row.get('id')
return session.get('user_id')
def _get_current_admin_permission_sets():
user_id = _get_current_admin_id()
if not user_id:
return set(), set()
cache_key = f'_admin_permission_sets_{user_id}'
cached = getattr(g, cache_key, None)
if cached is not None:
return cached
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
"""SELECT c.column_key, c.route_path
FROM user_column_permission ucp
INNER JOIN columns c ON c.id = ucp.column_id
WHERE ucp.user_id = %s AND c.menu_type = 'admin'""",
(user_id,),
)
rows = cur.fetchall()
finally:
conn.close()
key_set = {(row.get('column_key') or '').strip() for row in rows if (row.get('column_key') or '').strip()}
route_set = {(row.get('route_path') or '').strip() for row in rows if (row.get('route_path') or '').strip()}
cached = (key_set, route_set)
setattr(g, cache_key, cached)
return cached
def _ensure_admin_menu_access(*menu_names):
role, current_row = get_current_admin_role()
if role == 'super_admin':
return role, current_row, None
if role != 'admin' or not current_row:
return role, current_row, (jsonify({'success': False, 'error': '需要管理员权限'}), 403)
key_set, route_set = _get_current_admin_permission_sets()
for menu_name in menu_names:
config = ADMIN_MENU_ACCESS_CONFIG.get(menu_name) or {}
column_key = (config.get('column_key') or '').strip()
route_path = (config.get('route_path') or '').strip()
if (column_key and column_key in key_set) or (route_path and route_path in route_set):
return role, current_row, None
fallback_menu = menu_names[0] if menu_names else ''
fallback_config = ADMIN_MENU_ACCESS_CONFIG.get(fallback_menu) or {}
error_message = fallback_config.get('error') or '无权访问当前模块'
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
def _ensure_dedupe_total_data_access():
return _ensure_backend_menu_access('dedupe-total-data')
def _load_current_admin_menu_items():
role, current_row = get_current_admin_role()
if not role or not current_row:
return None, None, None, (jsonify({'success': False, 'error': '需要管理员权限'}), 403)
if role == 'super_admin':
items = [
_format_permission_item(item)
for item in _load_local_columns('admin')
if (item.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY
]
return role, current_row, items, None
user_id = _get_current_admin_id(current_row)
if not user_id:
return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400)
items = [_format_permission_item(item) for item in _get_local_user_column_permission_items(user_id, 'admin')]
return role, current_row, items, None
def _load_current_backend_menu_items():
role, current_row = get_current_admin_role()
if not role or not current_row:
return None, None, None, (jsonify({'success': False, 'error': '需要登录'}), 403)
if role == 'super_admin':
items = [
_format_permission_item(item)
for item in _load_local_columns('admin')
if (item.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY
]
return role, current_row, items, None
user_id = _get_current_admin_id(current_row)
if not user_id:
return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400)
items = [_format_permission_item(item) for item in _get_local_user_column_permission_items(user_id, 'admin')]
return role, current_row, items, None
def _ensure_backend_menu_access(*menu_names):
role, current_row = get_current_admin_role()
if role == 'super_admin':
return role, current_row, None
if not role or not current_row:
return role, current_row, (jsonify({'success': False, 'error': '需要登录'}), 403)
key_set, route_set = _get_current_admin_permission_sets()
for menu_name in menu_names:
config = ADMIN_MENU_ACCESS_CONFIG.get(menu_name) or {}
column_key = (config.get('column_key') or '').strip()
route_path = (config.get('route_path') or '').strip()
if (column_key and column_key in key_set) or (route_path and route_path in route_set):
return role, current_row, None
fallback_menu = menu_names[0] if menu_names else ''
fallback_config = ADMIN_MENU_ACCESS_CONFIG.get(fallback_menu) or {}
error_message = fallback_config.get('error') or '无权访问当前模块'
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
def _ensure_image_video_data_access():
role, current_row = get_current_admin_role()
if role == 'super_admin':
return role, current_row, None
if not role or not current_row:
return role, current_row, (jsonify({'success': False, 'error': '需要登录'}), 403)
conn = get_db()
try:
with conn.cursor() as cur:
data_column_id = _image_video_permission_column_id(cur)
cur.execute(
"SELECT 1 FROM user_column_permission ucp "
"WHERE ucp.user_id = %s AND ucp.column_id = %s LIMIT 1",
(int(current_row['id']), data_column_id),
)
granted = cur.fetchone()
conn.commit()
finally:
conn.close()
if granted:
return role, current_row, None
return role, current_row, (jsonify({'success': False, 'error': '无权查看视频任务数据'}), 403)
def _ensure_product_category_access():
role, current_row, items, denied = _load_current_backend_menu_items()
if denied:
return role, current_row, denied
if role == 'super_admin':
return role, current_row, None
for item in items or []:
if (item.get('column_key') or '').strip() == 'admin_product_categories':
return role, current_row, None
if (item.get('route_path') or '').strip() == 'product-categories':
return role, current_row, None
return role, current_row, (jsonify({'success': False, 'error': '无权访问商品类目模块'}), 403)
def _get_column_ids_by_route_paths(route_paths, menu_type='admin'):
normalized_paths = [(path or '').strip() for path in (route_paths or []) if (path or '').strip()]
if not normalized_paths:
return []
conn = get_db()
try:
with conn.cursor() as cur:
placeholders = ','.join(['%s'] * len(normalized_paths))
cur.execute(
f"SELECT id FROM columns WHERE menu_type = %s AND route_path IN ({placeholders}) ORDER BY id ASC",
[menu_type] + normalized_paths,
)
rows = cur.fetchall()
return [int(row['id']) for row in rows if row.get('id') is not None]
finally:
conn.close()
def _grant_backend_menu_permissions(user_ids, route_paths):
normalized_user_ids = [int(uid) for uid in (user_ids or []) if str(uid).isdigit() and int(uid) > 0]
column_ids = _get_column_ids_by_route_paths(route_paths, 'admin')
if not normalized_user_ids or not column_ids:
return
conn = get_db()
try:
with conn.cursor() as cur:
for user_id in normalized_user_ids:
for column_id in column_ids:
cur.execute(
"INSERT IGNORE INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
(user_id, column_id),
)
conn.commit()
finally:
conn.close()
@admin_api.route('/current-user')
@login_required
def get_admin_current_user():
role, current_row = get_current_admin_role()
if not role or not current_row:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
return jsonify({
'success': True,
'item': {
'id': current_row.get('id'),
'username': current_row.get('username') or session.get('username') or '',
'role': role,
}
})
@admin_api.route('/current-user/menus')
@login_required
def get_admin_current_user_menus():
_ensure_product_category_schema()
_, _, items, denied = _load_current_backend_menu_items()
if denied:
return denied
return jsonify({'success': True, 'items': items})
@admin_api.route('/logout', methods=['POST'])
@login_required
def admin_logout():
session.clear()
response = jsonify({'success': True, 'msg': '退出成功', 'redirect': '/login?logout=1'})
response.delete_cookie(current_app.config.get('SESSION_COOKIE_NAME', 'session'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
# ---------- 用户管理 ----------
@admin_api.route('/users')
@login_required
def 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)))
_, _, denied = _ensure_backend_menu_access(
'users', 'history', 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data'
)
if denied:
return denied
page_size = min(999, 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
# 用户范围只由角色决定,用户名不能绕过权限模型。
can_view_all_users = role == 'super_admin'
can_view_child_users = role == 'admin' and not can_view_all_users
if not can_view_all_users:
created_by_id = None
try:
conn = get_db()
with conn.cursor() as cur:
if can_view_all_users:
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()]
elif can_view_child_users:
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 = []
else:
where_parts = ["u.id = %s"]
params = [current_row['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()
return jsonify({
'success': True,
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'current_user_id': session.get('user_id'),
'current_user_role': role,
'current_user_username': current_row.get('username') or '',
'admins': admins,
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_api.route('/user', methods=['POST'])
@admin_required
def create_user():
_, _, denied = _ensure_admin_menu_access('users')
if denied:
return denied
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')
column_ids = data.get('column_ids') or []
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),
)
new_uid = cur.lastrowid
_validate_admin_granted_columns(cur, role, current_row, column_ids)
_set_user_column_permissions(
cur,
new_uid,
column_ids,
preserve_column_keys=(IMAGE_VIDEO_DATA_PERMISSION_KEY,),
)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '用户创建成功'})
except PermissionError as exc:
try:
conn.rollback()
conn.close()
except Exception:
pass
return jsonify({'success': False, 'error': str(exc)}), 403
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_api.route('/user/<int:uid>', methods=['PUT'])
@admin_required
def update_user(uid):
"""更新用户(密码、角色、栏目权限)"""
data = request.get_json() or {}
_, _, denied = _ensure_admin_menu_access('users')
if denied:
return denied
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 and 'column_ids' not in data:
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),
)
if 'column_ids' in data:
_validate_admin_granted_columns(cur, role, current_row, data.get('column_ids'))
_set_user_column_permissions(
cur,
uid,
data.get('column_ids'),
preserve_column_keys=(IMAGE_VIDEO_DATA_PERMISSION_KEY,),
)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '更新成功'})
except PermissionError as exc:
try:
conn.rollback()
conn.close()
except Exception:
pass
return jsonify({'success': False, 'error': str(exc)}), 403
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_api.route('/user/<int:uid>', methods=['DELETE'])
@admin_required
def delete_user(uid):
"""删除用户"""
_, _, denied = _ensure_admin_menu_access('users')
if denied:
return denied
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_api.route('/history')
@admin_required
def history():
"""管理员分页获取所有生成记录"""
_, _, denied = _ensure_admin_menu_access('history')
if denied:
return denied
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()
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 []
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)})
# ---------- 栏目权限配置 ----------
_IMAGE_VIDEO_ADMIN_COLUMNS = """
t.id, t.user_id, t.status, t.request_json, t.submit_response_json, t.result_json,
t.video_urls_json, t.debug_url, t.archived_videos_json, t.archive_status,
t.archive_error, t.archive_attempt_count, t.archived_at, t.error_message,
t.coze_execute_id, t.coze_status, t.submitted_at, t.completed_at, u.username,
COALESCE((
SELECT GROUP_CONCAT(DISTINCT g.group_name ORDER BY g.id SEPARATOR '')
FROM biz_shop_manage_group g
LEFT JOIN biz_shop_manage_group_member gm ON gm.group_id = g.id
WHERE g.created_by_id = t.user_id
OR g.user_id = t.user_id
OR gm.user_id = t.user_id
), '') AS group_name
"""
def _parse_admin_datetime_arg(name):
value = (request.args.get(name) or '').strip()
if not value:
return None
try:
return datetime.fromisoformat(value.replace('Z', '+00:00')).replace(tzinfo=None)
except ValueError as exc:
raise ValueError(f'{name} 时间格式无效') from exc
def _image_video_permission_column_id(cur):
cur.execute("SELECT id FROM columns WHERE column_key = %s LIMIT 1", (IMAGE_VIDEO_DATA_PERMISSION_KEY,))
row = cur.fetchone()
if not row or row.get('id') is None:
# 兼容 V74 尚未执行的旧库:创建数据权限栏目,并继承已有菜单授权。
cur.execute(
"INSERT INTO columns (name, column_key, menu_type, route_path, sort_order) "
"SELECT %s, %s, 'internal', 'image-video-task-data', 0 "
"WHERE NOT EXISTS (SELECT 1 FROM columns WHERE column_key = %s)",
('视频任务数据查看', IMAGE_VIDEO_DATA_PERMISSION_KEY, IMAGE_VIDEO_DATA_PERMISSION_KEY),
)
cur.execute("SELECT id FROM columns WHERE column_key = %s LIMIT 1", (IMAGE_VIDEO_DATA_PERMISSION_KEY,))
row = cur.fetchone()
if not row or row.get('id') is None:
raise ValueError('视频任务数据权限栏目不存在')
cur.execute(
"INSERT IGNORE INTO user_column_permission (user_id, column_id) "
"SELECT old_perm.user_id, %s FROM user_column_permission old_perm "
"INNER JOIN columns old_col ON old_col.id = old_perm.column_id "
"WHERE old_col.column_key = %s",
(int(row['id']), IMAGE_VIDEO_PERMISSION_KEY),
)
return int(row['id'])
def _is_stored_super_admin_sql(alias='u'):
return (
f"(LOWER(COALESCE({alias}.role, '')) = 'super_admin' OR "
f"((COALESCE({alias}.role, '') = '') AND {alias}.is_admin = 1 AND {alias}.created_by_id IS NULL))"
)
@admin_api.route('/image-video-task-permissions', methods=['GET', 'PUT'])
@login_required
def manage_image_video_task_permissions():
role, _ = get_current_admin_role()
if role != 'super_admin':
return jsonify({'success': False, 'error': '仅超级管理员可以配置视频任务权限'}), 403
conn = get_db()
try:
with conn.cursor() as cur:
column_id = _image_video_permission_column_id(cur)
if request.method == 'GET':
cur.execute(
"SELECT u.id, u.username, u.role, u.is_admin, u.created_by_id, "
"CASE WHEN ucp.user_id IS NULL THEN 0 ELSE 1 END AS granted "
"FROM users u LEFT JOIN user_column_permission ucp "
"ON ucp.user_id = u.id AND ucp.column_id = %s "
f"WHERE NOT {_is_stored_super_admin_sql('u')} "
"ORDER BY u.username ASC, u.id ASC",
(column_id,),
)
items = []
for row in cur.fetchall():
stored_role = (row.get('role') or '').strip().lower()
effective_role = stored_role or ('admin' if row.get('is_admin') else 'normal')
items.append({
'id': int(row['id']),
'username': row.get('username') or '',
'role': effective_role,
'granted': bool(row.get('granted')),
})
return jsonify({'success': True, 'items': items})
data = request.get_json(silent=True) or {}
raw_user_ids = data.get('user_ids')
if not isinstance(raw_user_ids, list):
return jsonify({'success': False, 'error': 'user_ids 必须是数组'}), 400
try:
user_ids = sorted({int(value) for value in raw_user_ids if not isinstance(value, bool) and int(value) > 0})
except (TypeError, ValueError):
return jsonify({'success': False, 'error': 'user_ids 包含无效用户 ID'}), 400
if len(user_ids) != len(raw_user_ids):
return jsonify({'success': False, 'error': 'user_ids 包含重复或无效用户 ID'}), 400
if user_ids:
placeholders = ','.join(['%s'] * len(user_ids))
cur.execute(
f"SELECT id FROM users u WHERE id IN ({placeholders}) AND NOT {_is_stored_super_admin_sql('u')}",
tuple(user_ids),
)
valid_ids = {int(row['id']) for row in cur.fetchall()}
if valid_ids != set(user_ids):
return jsonify({'success': False, 'error': '包含不存在或不可授权的用户'}), 400
cur.execute(
"DELETE ucp FROM user_column_permission ucp "
"INNER JOIN users u ON u.id = ucp.user_id "
f"WHERE ucp.column_id = %s AND NOT {_is_stored_super_admin_sql('u')}",
(column_id,),
)
for user_id in user_ids:
cur.execute(
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
(user_id, column_id),
)
conn.commit()
return jsonify({'success': True, 'granted_count': len(user_ids), 'msg': '视频任务权限已更新'})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
if request.method == 'PUT':
conn.rollback()
return jsonify({'success': False, 'error': str(exc)}), 500
finally:
conn.close()
@admin_api.route('/image-video-tasks/download-zip', methods=['POST'])
@login_required
def download_image_video_tasks_zip():
_, _, denied = _ensure_backend_menu_access('image-video-tasks')
if not denied:
_, _, denied = _ensure_image_video_data_access()
if denied:
return denied
data = request.get_json(silent=True) or {}
raw_items = data.get('items')
if not isinstance(raw_items, list) or not raw_items:
return jsonify({'success': False, 'error': '请至少选择一个视频'}), 400
if len(raw_items) > 100:
return jsonify({'success': False, 'error': '单次最多打包 100 个视频'}), 400
selections = []
seen = set()
try:
for item in raw_items:
if not isinstance(item, dict):
raise ValueError
task_id = int(item.get('task_id'))
video_index = int(item.get('video_index'))
if task_id <= 0 or video_index < 0:
raise ValueError
key = (task_id, video_index)
if key not in seen:
seen.add(key)
selections.append(key)
except (TypeError, ValueError):
return jsonify({'success': False, 'error': '视频选择参数无效'}), 400
task_ids = sorted({task_id for task_id, _ in selections})
conn = get_db()
try:
with conn.cursor() as cur:
placeholders = ','.join(['%s'] * len(task_ids))
cur.execute(
'SELECT ' + _IMAGE_VIDEO_ADMIN_COLUMNS +
' FROM biz_image_video_async_task t LEFT JOIN users u ON u.id = t.user_id '
f"WHERE t.task_type = 'IMAGE_VIDEO_WORKFLOW' "
f"AND t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY) "
f"AND t.id IN ({placeholders})",
tuple(task_ids),
)
rows = cur.fetchall()
finally:
conn.close()
tasks = {int(row['id']): row for row in rows}
archive = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024, mode='w+b')
errors = []
file_count = 0
try:
with zipfile.ZipFile(archive, mode='w', compression=zipfile.ZIP_STORED, allowZip64=True) as output_zip:
for task_id, video_index in selections:
row = tasks.get(task_id)
videos = _image_video_urls(row) if row else []
if video_index >= len(videos) or not videos[video_index].get('display_url'):
errors.append(f'task-{task_id}-video-{video_index + 1}: 视频地址不存在')
continue
url = videos[video_index]['display_url']
extension_match = re.search(r'\.([a-z0-9]{2,5})(?:[?#]|$)', url, re.IGNORECASE)
extension = extension_match.group(1).lower() if extension_match else 'mp4'
filename = f'task-{task_id}-video-{video_index + 1}.{extension}'
remote_response = None
try:
remote_response = requests.get(url, stream=True, timeout=(10, 120))
remote_response.raise_for_status()
with output_zip.open(filename, mode='w', force_zip64=True) as target:
for chunk in remote_response.iter_content(chunk_size=1024 * 1024):
if chunk:
target.write(chunk)
file_count += 1
except requests.RequestException as exc:
errors.append(f'{filename}: 下载失败 ({exc})')
finally:
if remote_response is not None:
remote_response.close()
if errors:
output_zip.writestr('download-errors.txt', '\n'.join(errors).encode('utf-8'))
archive.seek(0)
response = send_file(
archive,
mimetype='application/zip',
as_attachment=True,
download_name=f"video-tasks-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip",
max_age=0,
)
response.headers['X-Archive-File-Count'] = str(file_count)
response.headers['X-Archive-Error-Count'] = str(len(errors))
response.call_on_close(archive.close)
return response
except Exception:
archive.close()
raise
@admin_api.route('/image-video-tasks')
@login_required
def list_image_video_tasks():
_, _, denied = _ensure_backend_menu_access('image-video-tasks')
if not denied:
_, _, denied = _ensure_image_video_data_access()
if denied:
return denied
try:
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(10, int(request.args.get('page_size', 20))))
user_id = request.args.get('user_id', type=int)
username = (request.args.get('username') or '').strip()
status = (request.args.get('status') or '').strip().upper()
execute_id = (request.args.get('coze_execute_id') or '').strip()
submitted_from = _parse_admin_datetime_arg('submitted_from')
submitted_to = _parse_admin_datetime_arg('submitted_to')
params = []
conditions = [
"t.task_type = 'IMAGE_VIDEO_WORKFLOW'",
"t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)",
]
if user_id:
conditions.append('t.user_id = %s')
params.append(user_id)
if username:
conditions.append('u.username LIKE %s')
params.append('%' + username + '%')
if status:
conditions.append('t.status = %s')
params.append(status)
if execute_id:
conditions.append('t.coze_execute_id LIKE %s')
params.append('%' + execute_id + '%')
if submitted_from:
conditions.append('t.submitted_at >= %s')
params.append(submitted_from)
if submitted_to:
conditions.append('t.submitted_at <= %s')
params.append(submitted_to)
where_sql = ' AND '.join(conditions)
offset = (page - 1) * page_size
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
'SELECT ' + _IMAGE_VIDEO_ADMIN_COLUMNS +
' FROM biz_image_video_async_task t LEFT JOIN users u ON u.id = t.user_id WHERE ' +
where_sql + ' ORDER BY t.submitted_at DESC, t.id DESC LIMIT %s OFFSET %s',
tuple(params + [page_size, offset]),
)
rows = cur.fetchall()
cur.execute(
'SELECT COUNT(*) AS total FROM biz_image_video_async_task t '
'LEFT JOIN users u ON u.id = t.user_id WHERE ' + where_sql,
tuple(params),
)
total = int((cur.fetchone() or {}).get('total') or 0)
finally:
conn.close()
return jsonify({
'success': True,
'items': [_image_video_admin_item(row) for row in rows],
'total': total,
'page': page,
'page_size': page_size,
})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
return jsonify({'success': False, 'error': str(exc)}), 500
@admin_api.route('/image-video-tasks/<int:task_id>')
@login_required
def get_image_video_task(task_id):
_, _, denied = _ensure_backend_menu_access('image-video-tasks')
if not denied:
_, _, denied = _ensure_image_video_data_access()
if denied:
return denied
conditions = [
"t.task_type = 'IMAGE_VIDEO_WORKFLOW'",
"t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)",
't.id = %s',
]
params = [task_id]
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
'SELECT ' + _IMAGE_VIDEO_ADMIN_COLUMNS +
' FROM biz_image_video_async_task t LEFT JOIN users u ON u.id = t.user_id WHERE ' +
' AND '.join(conditions) + ' LIMIT 1',
tuple(params),
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return jsonify({'success': False, 'error': '视频任务不存在或无权访问'}), 404
return jsonify({'success': True, 'item': _image_video_admin_item(row, include_json=True)})
@admin_api.route('/columns')
@admin_required
def list_columns():
_, _, denied = _ensure_admin_menu_access('columns', 'users')
if denied:
return denied
_ensure_product_category_schema()
menu_type = (request.args.get('menu_type') or '').strip()
local_rows = _load_local_columns(menu_type or None)
items = [
{
'id': row.get('id'),
'name': row.get('name') or '',
'column_key': row.get('column_key') or '',
'menu_type': row.get('menu_type') or 'app',
'route_path': row.get('route_path') or '',
'sort_order': int(row.get('sort_order') or 0),
'created_at': str(row.get('created_at') or '')[:16].replace('T', ' '),
}
for row in local_rows
if (row.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY
]
return jsonify({'success': True, 'items': items})
@admin_api.route('/column', methods=['POST'])
@admin_required
def create_column():
_, _, denied = _ensure_admin_menu_access('columns')
if denied:
return denied
data = request.get_json() or {}
name = (data.get('name') or '').strip()
column_key = (data.get('column_key') or '').strip()
menu_type = (data.get('menu_type') or '').strip() or 'admin'
route_path = (data.get('route_path') or '').strip()
try:
sort_order = _parse_optional_int(data.get('sort_order'))
except (TypeError, ValueError):
return jsonify({'success': False, 'error': '排序必须是数字'})
if not name:
return jsonify({'success': False, 'error': '栏目名不能为空'})
if not column_key:
return jsonify({'success': False, 'error': '栏目标识不能为空'})
if not route_path:
return jsonify({'success': False, 'error': '菜单路由不能为空'})
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/permission-menus',
json_data={
'name': name,
'columnKey': column_key,
'menuType': menu_type,
'routePath': route_path,
},
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
_sync_local_columns_from_backend([_format_permission_item(item)])
if item.get('id') is not None:
_set_local_column_sort_order(item.get('id'), sort_order if sort_order is not None else _get_next_local_column_sort_order())
return jsonify({'success': True, 'msg': result.get('message') or '创建成功', 'id': item.get('id')})
@admin_api.route('/column/<int:cid>', methods=['PUT'])
@admin_required
def update_column(cid):
_, _, denied = _ensure_admin_menu_access('columns')
if denied:
return denied
data = request.get_json() or {}
name = (data.get('name') or '').strip()
column_key = (data.get('column_key') or '').strip()
menu_type = (data.get('menu_type') or '').strip() or 'admin'
route_path = (data.get('route_path') or '').strip()
try:
sort_order = _parse_optional_int(data.get('sort_order'))
except (TypeError, ValueError):
return jsonify({'success': False, 'error': '排序必须是数字'})
if not name:
return jsonify({'success': False, 'error': '栏目名不能为空'})
if not column_key:
return jsonify({'success': False, 'error': '栏目标识不能为空'})
if not route_path:
return jsonify({'success': False, 'error': '菜单路由不能为空'})
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/permission-menus/{cid}',
json_data={
'name': name,
'columnKey': column_key,
'menuType': menu_type,
'routePath': route_path,
},
)
if error_response is not None:
return error_response, status
_sync_local_columns_from_backend([{
'id': cid,
'name': name,
'column_key': column_key,
'menu_type': menu_type,
'route_path': route_path,
'sort_order': sort_order if sort_order is not None else cid,
'created_at': None,
}])
if sort_order is not None:
_set_local_column_sort_order(cid, sort_order)
return jsonify({'success': True, 'msg': result.get('message') or '更新成功'})
@admin_api.route('/column/reorder', methods=['POST'])
@admin_required
def reorder_column():
_, _, denied = _ensure_admin_menu_access('columns')
if denied:
return denied
data = request.get_json() or {}
try:
column_id = int(data.get('column_id'))
target_id = int(data.get('target_id'))
except (TypeError, ValueError):
return jsonify({'success': False, 'error': '菜单重排参数无效'}), 400
if column_id == target_id:
return jsonify({'success': True, 'msg': '排序未变化'})
try:
_swap_local_column_sort_order(column_id, target_id)
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
return jsonify({'success': False, 'error': str(exc)}), 500
return jsonify({'success': True, 'msg': '排序更新成功'})
@admin_api.route('/column/<int:cid>', methods=['DELETE'])
@admin_required
def delete_column(cid):
_, _, denied = _ensure_admin_menu_access('columns')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/permission-menus/{cid}',
)
if error_response is not None:
return error_response, status
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute("DELETE FROM columns WHERE id = %s", (cid,))
conn.commit()
finally:
conn.close()
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/user/<int:uid>/columns')
@admin_required
def get_user_columns(uid):
_, _, denied = _ensure_admin_menu_access('users')
if denied:
return denied
menu_type = (request.args.get('menu_type') or '').strip()
return jsonify({'success': True, 'column_ids': _get_local_user_column_ids(uid, menu_type or None)})
@admin_api.route('/user/<int:uid>/column-permissions')
@admin_required
def get_user_column_permissions(uid):
_, _, denied = _ensure_admin_menu_access('users')
if denied:
return denied
menu_type = (request.args.get('menu_type') or '').strip()
items = _get_local_user_column_permission_items(uid, menu_type or None)
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in items]})
def _set_user_column_permissions(cur, user_id, column_ids, preserve_column_keys=()):
"""设置用户栏目权限先删后插。cur 为已打开的游标。"""
requested_ids = {int(x) for x in (column_ids or []) if x}
preserved_ids = set()
if preserve_column_keys:
placeholders = ','.join(['%s'] * len(preserve_column_keys))
cur.execute(
f"SELECT id FROM columns WHERE column_key IN ({placeholders})",
tuple(preserve_column_keys),
)
protected_ids = {int(row['id']) for row in cur.fetchall() if row.get('id') is not None}
requested_ids.difference_update(protected_ids)
if protected_ids:
id_placeholders = ','.join(['%s'] * len(protected_ids))
cur.execute(
f"SELECT column_id FROM user_column_permission WHERE user_id = %s AND column_id IN ({id_placeholders})",
(user_id, *sorted(protected_ids)),
)
preserved_ids = {int(row['column_id']) for row in cur.fetchall() if row.get('column_id') is not None}
cur.execute("DELETE FROM user_column_permission WHERE user_id = %s", (user_id,))
for cid in sorted(requested_ids | preserved_ids):
cur.execute(
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
(user_id, cid),
)
def _validate_admin_granted_columns(cur, role, current_row, column_ids):
"""普通管理员只能把自己已拥有的栏目授予其直接管理的普通用户。"""
if role != 'admin' or not current_row or not column_ids:
return
requested_ids = {int(value) for value in column_ids if str(value).isdigit() and int(value) > 0}
if not requested_ids:
return
placeholders = ','.join(['%s'] * len(requested_ids))
cur.execute(
f"SELECT column_id FROM user_column_permission "
f"WHERE user_id = %s AND column_id IN ({placeholders})",
(int(current_row['id']), *sorted(requested_ids)),
)
owned_ids = {int(row['column_id']) for row in cur.fetchall() if row.get('column_id') is not None}
denied_ids = requested_ids - owned_ids
if denied_ids:
raise PermissionError('普通管理员只能分配自己已有的菜单权限')
# ---------- 版本管理web_config ----------
# ---------- 商品类目 ----------
def _format_product_category_item(item, include_children=True):
formatted = {
'id': item.get('id'),
'parent_id': item.get('parentId'),
'name': item.get('name') or '',
'category_key': item.get('categoryKey') or '',
'sort_order': item.get('sortOrder') if item.get('sortOrder') is not None else 0,
'description': item.get('description') or '',
'is_builtin': bool(item.get('isBuiltin')),
'child_count': item.get('childCount') if item.get('childCount') is not None else 0,
'level': item.get('level') if item.get('level') is not None else 0,
'path': item.get('path') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
if include_children:
formatted['children'] = [_format_product_category_item(child) for child in (item.get('children') or [])]
return formatted
def _build_product_category_payload_for_java(data):
sort_order = data.get('sort_order') if 'sort_order' in data else data.get('sortOrder')
parent_id = data.get('parent_id') if 'parent_id' in data else data.get('parentId')
if parent_id == '':
parent_id = None
if sort_order == '':
sort_order = None
return {
'parentId': parent_id,
'name': (data.get('name') or '').strip(),
'sortOrder': sort_order,
'description': (data.get('description') or '').strip(),
}
@admin_api.route('/product-categories')
@login_required
def list_product_categories():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
keyword = (request.args.get('keyword') or '').strip()
query = f'?keyword={quote(keyword)}' if keyword else ''
result, error_response, status = _proxy_backend_java('GET', f'/api/admin/product-categories{query}')
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'tree': [_format_product_category_item(item) for item in (payload.get('tree') or [])],
'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or 1,
'page_size': payload.get('pageSize') or 0,
'has_more': bool(payload.get('hasMore')),
})
@admin_api.route('/product-categories/children')
@login_required
def list_product_category_children():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
parent_id_raw = (request.args.get('parent_id') or request.args.get('parentId') or '').strip()
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', request.args.get('pageSize', 20)))))
params = {
'page': page,
'pageSize': page_size,
}
if parent_id_raw:
params['parentId'] = parent_id_raw
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/product-categories/children',
params=params,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
'has_more': bool(payload.get('hasMore')),
})
@admin_api.route('/product-categories/search')
@login_required
def search_product_categories():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
keyword = (request.args.get('keyword') or '').strip()
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', request.args.get('pageSize', 20)))))
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/product-categories/search',
params={
'keyword': keyword,
'page': page,
'pageSize': page_size,
},
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'items': [_format_product_category_item(item, include_children=False) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
'has_more': bool(payload.get('hasMore')),
})
@admin_api.route('/product-categories/export')
@login_required
def export_product_categories():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
keyword = (request.args.get('keyword') or '').strip()
params = {}
if keyword:
params['keyword'] = keyword
url = f"{backend_java_base_url}/api/admin/product-categories/export"
try:
resp = _get_backend_java_session().get(url, params=params, timeout=60)
except requests.RequestException:
return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
if resp.status_code >= 400:
try:
data = resp.json()
error = data.get('message') or data.get('error') or '导出失败'
except ValueError:
error = '导出失败'
return jsonify({'success': False, 'error': error}), resp.status_code
headers = {}
disposition = resp.headers.get('Content-Disposition')
if disposition:
headers['Content-Disposition'] = disposition
return Response(
resp.content,
status=resp.status_code,
headers=headers,
content_type=resp.headers.get(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
)
@admin_api.route('/product-category', methods=['POST'])
@login_required
def create_product_category():
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/product-category',
json_data=_build_product_category_payload_for_java(request.get_json() or {}),
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '创建成功',
'item': _format_product_category_item(result.get('data') or {}, include_children=False),
})
@admin_api.route('/product-category/<int:item_id>', methods=['PUT'])
@login_required
def update_product_category(item_id):
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/product-category/{item_id}',
json_data=_build_product_category_payload_for_java(request.get_json() or {}),
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '保存成功',
'item': _format_product_category_item(result.get('data') or {}, include_children=False),
})
@admin_api.route('/product-category/<int:item_id>', methods=['DELETE'])
@login_required
def delete_product_category(item_id):
_ensure_product_category_schema()
_, _, denied = _ensure_product_category_access()
if denied:
return denied
result, error_response, status = _proxy_backend_java('DELETE', f'/api/admin/product-category/{item_id}')
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/versions')
@admin_required
def list_versions():
_, _, denied = _ensure_admin_menu_access('version')
if denied:
return denied
"""获取版本列表"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, version, file_url, created_at FROM web_config ORDER BY created_at DESC"
)
rows = cur.fetchall()
conn.close()
items = [
{
'id': r['id'],
'version': r['version'] or '',
'file_url': r['file_url'] or '',
'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)})
# ========== 数字人版本管理(代理到 Java 后端)==========
@admin_api.route('/digital-human-versions')
@admin_required
def list_digital_human_versions():
"""代理:查询数字人版本列表"""
_, _, denied = _ensure_admin_menu_access('digital-human-version')
if denied:
return denied
page = request.args.get('page', '1')
page_size = request.args.get('pageSize', '20')
status = request.args.get('status', '')
params = {'page': page, 'pageSize': page_size}
if status:
params['status'] = status
result, error_response, status_code = _proxy_backend_java(
'GET',
'/api/digital-human/versions',
params=params,
timeout=30
)
if error_response:
return error_response, status_code
return jsonify(result)
@admin_api.route('/digital-human-versions/latest')
@admin_required
def get_latest_digital_human_version():
"""代理:获取最新数字人版本"""
_, _, denied = _ensure_admin_menu_access('digital-human-version')
if denied:
return denied
result, error_response, status_code = _proxy_backend_java(
'GET',
'/api/digital-human/versions/latest',
timeout=10
)
if error_response:
return error_response, status_code
return jsonify(result)
@admin_api.route('/digital-human-versions/<version>/download-url')
@admin_required
def get_digital_human_version_download_url(version):
"""代理:获取数字人版本下载链接"""
_, _, denied = _ensure_admin_menu_access('digital-human-version')
if denied:
return denied
safe_version = quote((version or '').strip(), safe='')
if not safe_version:
return jsonify({'success': False, 'message': '请指定版本号'}), 400
result, error_response, status_code = _proxy_backend_java(
'GET',
f'/api/digital-human/versions/{safe_version}/download-url',
timeout=10
)
if error_response:
return error_response, status_code
return jsonify(result)
@admin_api.route('/digital-human-versions/upload', methods=['POST'])
@admin_required
def upload_digital_human_version():
"""代理:上传数字人版本"""
_, _, denied = _ensure_admin_menu_access('digital-human-version')
if denied:
return denied
version = request.form.get('version', '').strip()
changelog = request.form.get('changelog', '').strip()
min_client_version = request.form.get('minClientVersion', '').strip()
file_storage = request.files.get('file')
if not version:
return jsonify({'success': False, 'message': '请填写版本号'}), 400
if not file_storage:
return jsonify({'success': False, 'message': '请选择文件'}), 400
files = {'file': (file_storage.filename, file_storage.stream, file_storage.content_type)}
data = {'version': version}
if changelog:
data['changelog'] = changelog
if min_client_version:
data['minClientVersion'] = min_client_version
result, error_response, status_code = _proxy_backend_java(
'POST',
'/api/digital-human/versions/upload',
files=files,
data=data,
timeout=1800
)
if error_response:
return error_response, status_code
return jsonify(result)
@admin_api.route('/digital-human-versions/<version>/release', methods=['POST'])
@admin_required
def release_digital_human_version(version):
"""代理:发布数字人版本"""
_, _, denied = _ensure_admin_menu_access('digital-human-version')
if denied:
return denied
result, error_response, status_code = _proxy_backend_java(
'POST',
f'/api/digital-human/versions/{version}/release',
timeout=10
)
if error_response:
return error_response, status_code
return jsonify(result)
@admin_api.route('/digital-human-versions/<version>/set-latest', methods=['POST'])
@admin_required
def set_latest_digital_human_version(version):
"""代理:设为最新数字人版本"""
_, _, denied = _ensure_admin_menu_access('digital-human-version')
if denied:
return denied
result, error_response, status_code = _proxy_backend_java(
'POST',
f'/api/digital-human/versions/{version}/set-latest',
timeout=10
)
if error_response:
return error_response, status_code
return jsonify(result)
@admin_api.route('/digital-human-versions/<version>', methods=['DELETE'])
@admin_required
def delete_digital_human_version(version):
"""代理:删除数字人版本"""
_, _, denied = _ensure_admin_menu_access('digital-human-version')
if denied:
return denied
result, error_response, status_code = _proxy_backend_java(
'DELETE',
f'/api/digital-human/versions/{version}',
timeout=30
)
if error_response:
return error_response, status_code
return jsonify(result)
@admin_api.route('/version', methods=['POST'])
@admin_required
def upload_version():
"""接收版本号 + zip 文件,上传到 OSS写入 web_config"""
_, _, denied = _ensure_admin_menu_access('version')
if denied:
return denied
version = (request.form.get('version') or '').strip()
if not version:
return jsonify({'success': False, 'error': '请填写版本号'})
file_storage = request.files.get('file')
if not file_storage or file_storage.filename == '':
return jsonify({'success': False, 'error': '请选择要上传的 zip 压缩包'})
if not (file_storage.filename or '').lower().endswith('.zip'):
return jsonify({'success': False, 'error': '仅支持 .zip 格式'})
try:
file_content = file_storage.read()
if not file_content:
return jsonify({'success': False, 'error': '文件为空'})
safe_key = _safe_version_key(version)
key = f"{bucket_path}versions/{safe_key}.zip"
file_url = oss_upload_file(file_content, key)
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"INSERT INTO web_config (version, file_url) VALUES (%s, %s)",
(version, file_url)
)
conn.commit()
conn.close()
return jsonify({
'success': True,
'version': version,
'file_url': file_url,
'msg': '上传成功',
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
# ---------- 店铺密钥管理 ----------
@admin_api.route('/shop-keys')
@admin_required
def list_shop_keys():
_, _, denied = _ensure_admin_menu_access('shop-keys')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-keys',
params={'page': page, 'pageSize': page_size},
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
items = [
{
'id': item.get('id'),
'remark_name': item.get('remarkName') or '',
'ziniao_account_name': item.get('ziniaoAccountName') or '',
'ziniao_token': item.get('ziniaoToken') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
for item in (payload.get('items') or [])
]
return jsonify({
'success': True,
'items': items,
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
})
@admin_api.route('/shop-key', methods=['POST'])
@admin_required
def create_shop_key():
_, _, denied = _ensure_admin_menu_access('shop-keys')
if denied:
return denied
data = request.get_json() or {}
payload = {
'remarkName': (data.get('remark_name') or '').strip(),
'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(),
'ziniaoToken': (data.get('ziniao_token') or '').strip(),
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/shop-keys',
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '创建成功',
'item': {
'id': item.get('id'),
'remark_name': item.get('remarkName') or '',
'ziniao_account_name': item.get('ziniaoAccountName') or '',
'ziniao_token': item.get('ziniaoToken') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/shop-key/<int:item_id>', methods=['PUT'])
@admin_required
def update_shop_key(item_id):
_, _, denied = _ensure_admin_menu_access('shop-keys')
if denied:
return denied
data = request.get_json() or {}
payload = {
'remarkName': (data.get('remark_name') or '').strip(),
'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(),
'ziniaoToken': (data.get('ziniao_token') or '').strip(),
}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/shop-keys/{item_id}',
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '更新成功',
'item': {
'id': item.get('id'),
'remark_name': item.get('remarkName') or '',
'ziniao_account_name': item.get('ziniaoAccountName') or '',
'ziniao_token': item.get('ziniaoToken') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/shop-key/<int:item_id>', methods=['DELETE'])
@admin_required
def delete_shop_key(item_id):
_, _, denied = _ensure_admin_menu_access('shop-keys')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/shop-keys/{item_id}',
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '删除成功',
})
# ---------- 数据去重总数据 ----------
@admin_api.route('/dedupe-total-data')
@login_required
def list_dedupe_total_data():
_, current_row, denied = _ensure_dedupe_total_data_access()
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
keyword = (request.args.get('keyword') or '').strip()
username = (request.args.get('username') or '').strip()
data, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/dedupe-total-data',
params={
'page': page,
'pageSize': page_size,
'keyword': keyword,
'username': username,
'operatorId': current_row.get('id'),
},
)
if error_response is not None:
return error_response, status
payload = data.get('data') or {}
items = [
{
'id': item.get('id'),
'data_value': item.get('dataValue') or '',
'uploader_user_id': item.get('uploaderUserId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
}
for item in (payload.get('items') or [])
]
return jsonify({
'success': True,
'items': items,
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
})
@admin_api.route('/dedupe-total-data/export')
@login_required
def export_dedupe_total_data():
_, current_row, denied = _ensure_dedupe_total_data_access()
if denied:
return denied
params = {'operatorId': current_row.get('id')}
username = (request.args.get('username') or '').strip()
start_date = (request.args.get('start_date') or request.args.get('startDate') or '').strip()
end_date = (request.args.get('end_date') or request.args.get('endDate') or '').strip()
if username:
params['username'] = username
if start_date:
params['startDate'] = start_date
if end_date:
params['endDate'] = end_date
url = f"{backend_java_base_url}/api/admin/dedupe-total-data/export"
try:
resp = _get_backend_java_session().get(url, params=params, timeout=60)
except requests.RequestException:
return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
if resp.status_code >= 400:
try:
data = resp.json()
error = data.get('message') or data.get('error') or '导出失败'
except ValueError:
error = '导出失败'
return jsonify({'success': False, 'error': error}), resp.status_code
headers = {}
disposition = resp.headers.get('Content-Disposition')
if disposition:
headers['Content-Disposition'] = disposition
return Response(
resp.content,
status=resp.status_code,
headers=headers,
content_type=resp.headers.get(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
)
@admin_api.route('/dedupe-total-data/import/<import_id>')
@login_required
def dedupe_total_data_import_progress(import_id):
_, current_row, denied = _ensure_dedupe_total_data_access()
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/dedupe-total-data/import/{import_id}',
params={'operatorId': current_row.get('id')},
)
if error_response is not None:
return error_response, status
progress = result.get('data') or {}
return jsonify({
'success': True,
'progress': {
'status': progress.get('status') or 'pending',
'total_rows': progress.get('totalRows') or 0,
'processed_rows': progress.get('processedRows') or 0,
'asin_count': progress.get('asinCount') or 0,
'inserted_count': progress.get('insertedCount') or 0,
'skipped_count': progress.get('skippedCount') or 0,
'error_message': progress.get('errorMessage') or '',
},
})
@admin_api.route('/dedupe-total-data/import', methods=['POST'])
@login_required
def import_dedupe_total_data():
_, current_row, denied = _ensure_dedupe_total_data_access()
if denied:
return denied
file_storage = request.files.get('file')
if not file_storage or file_storage.filename == '':
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/dedupe-total-data/import',
files=files,
data={'operatorId': current_row.get('id')},
)
if error_response is not None:
return error_response, status
summary = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '开始导入',
'import_id': summary.get('importId') or '',
})
@admin_api.route('/dedupe-total-data/delete-import/<import_id>')
@login_required
def dedupe_total_data_delete_import_progress(import_id):
_, current_row, denied = _ensure_dedupe_total_data_access()
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/dedupe-total-data/delete-import/{import_id}',
params={'operatorId': current_row.get('id')},
)
if error_response is not None:
return error_response, status
progress = result.get('data') or {}
return jsonify({
'success': True,
'progress': {
'status': progress.get('status') or 'pending',
'total_rows': progress.get('totalRows') or 0,
'processed_rows': progress.get('processedRows') or 0,
'asin_count': progress.get('asinCount') or 0,
'deleted_count': progress.get('insertedCount') or 0,
'skipped_count': progress.get('skippedCount') or 0,
'error_message': progress.get('errorMessage') or '',
},
})
@admin_api.route('/dedupe-total-data/delete-import', methods=['POST'])
@login_required
def delete_import_dedupe_total_data():
_, current_row, denied = _ensure_dedupe_total_data_access()
if denied:
return denied
file_storage = request.files.get('file')
if not file_storage or file_storage.filename == '':
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/dedupe-total-data/delete-import',
files=files,
data={'operatorId': current_row.get('id')},
)
if error_response is not None:
return error_response, status
summary = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '开始删除',
'import_id': summary.get('importId') or '',
})
@admin_api.route('/dedupe-total-data', methods=['POST'])
@login_required
def create_dedupe_total_data():
_, current_row, denied = _ensure_dedupe_total_data_access()
if denied:
return denied
data = request.get_json() or {}
payload = {'dataValue': (data.get('data_value') or '').strip()}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/dedupe-total-data',
params={'operatorId': current_row.get('id')},
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '创建成功',
'item': {
'id': item.get('id'),
'data_value': item.get('dataValue') or '',
'uploader_user_id': item.get('uploaderUserId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/dedupe-total-data/<int:item_id>', methods=['PUT'])
@login_required
def update_dedupe_total_data(item_id):
_, current_row, denied = _ensure_dedupe_total_data_access()
if denied:
return denied
data = request.get_json() or {}
payload = {'dataValue': (data.get('data_value') or '').strip()}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/dedupe-total-data/{item_id}',
params={'operatorId': current_row.get('id')},
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '更新成功',
'item': {
'id': item.get('id'),
'data_value': item.get('dataValue') or '',
'uploader_user_id': item.get('uploaderUserId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/dedupe-total-data/<int:item_id>', methods=['DELETE'])
@login_required
def delete_dedupe_total_data(item_id):
_, current_row, denied = _ensure_dedupe_total_data_access()
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/dedupe-total-data/{item_id}',
params={'operatorId': current_row.get('id')},
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '删除成功',
})
# ---------- 不符合 ASIN 数据 ----------
def _format_invalid_asin_data_item(item):
return {
'id': item.get('id'),
'data_value': item.get('dataValue') or '',
'brand': item.get('brand') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
}
@admin_api.route('/invalid-asin-data')
@admin_required
def list_invalid_asin_data():
_, _, denied = _ensure_admin_menu_access('invalid-asin-data')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
keyword = (request.args.get('keyword') or '').strip()
data, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/invalid-asin-data',
params={'page': page, 'pageSize': page_size, 'keyword': keyword},
)
if error_response is not None:
return error_response, status
payload = data.get('data') or {}
items = [_format_invalid_asin_data_item(item) for item in (payload.get('items') or [])]
return jsonify({
'success': True,
'items': items,
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
})
@admin_api.route('/invalid-asin-data', methods=['POST'])
@admin_required
def create_invalid_asin_data():
_, _, denied = _ensure_admin_menu_access('invalid-asin-data')
if denied:
return denied
data = request.get_json() or {}
payload = {
'dataValue': (data.get('data_value') or '').strip(),
'brand': (data.get('brand') or '').strip(),
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/invalid-asin-data',
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '创建成功',
'item': _format_invalid_asin_data_item(item),
})
@admin_api.route('/invalid-asin-data/<int:item_id>', methods=['PUT'])
@admin_required
def update_invalid_asin_data(item_id):
_, _, denied = _ensure_admin_menu_access('invalid-asin-data')
if denied:
return denied
data = request.get_json() or {}
payload = {
'dataValue': (data.get('data_value') or '').strip(),
'brand': (data.get('brand') or '').strip(),
}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/invalid-asin-data/{item_id}',
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '更新成功',
'item': _format_invalid_asin_data_item(item),
})
@admin_api.route('/invalid-asin-data/<int:item_id>', methods=['DELETE'])
@admin_required
def delete_invalid_asin_data(item_id):
_, _, denied = _ensure_admin_menu_access('invalid-asin-data')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/invalid-asin-data/{item_id}',
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '删除成功',
})
# ---------- 店铺管理 ----------
def _format_shop_manage_item(item):
return {
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'mall_name': item.get('mallName') or '',
'account': item.get('account') or '',
'password': item.get('passwordMasked') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
def _format_shop_manage_group_item(item):
return {
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'leader_user_id': item.get('leaderUserId'),
'leader_username': item.get('leaderUsername') or '',
'member_user_ids': item.get('memberUserIds') or [],
'member_usernames': item.get('memberUsernames') or [],
'member_count': item.get('memberCount') or 0,
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
def _load_expanded_shop_manage_groups(role, current_row):
params = {}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages/groups',
params=params,
)
if error_response is not None:
return None, error_response, status
return result.get('data') or [], None, 200
@admin_api.route('/shop-manages')
@login_required
def list_shop_manages():
role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
group_id_raw = (request.args.get('group_id') or '').strip()
shop_name = (request.args.get('shop_name') or '').strip()
params = {'page': page, 'pageSize': page_size}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
if group_id_raw:
try:
group_id = int(group_id_raw)
if group_id > 0:
params['groupId'] = group_id
except (TypeError, ValueError):
pass
if shop_name:
params['shopName'] = shop_name
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages',
params=params,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'items': [_format_shop_manage_item(item) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
})
@admin_api.route('/shop-manage', methods=['POST'])
@login_required
def create_shop_manage():
role, current_row, denied = _ensure_backend_menu_access('shop-manage')
if denied:
return denied
data = request.get_json() or {}
payload = {
'groupId': data.get('group_id'),
'shopName': (data.get('shop_name') or '').strip(),
'mallName': (data.get('mall_name') or '').strip(),
'account': (data.get('account') or '').strip(),
'password': (data.get('password') or '').strip(),
'createdById': current_row.get('id') if current_row else None,
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/shop-manages',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '创建成功',
'item': {
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'mall_name': item.get('mallName') or '',
'account': item.get('account') or '',
'password': item.get('password') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/shop-manage/<int:item_id>', methods=['PUT'])
@login_required
def update_shop_manage(item_id):
role, current_row, denied = _ensure_backend_menu_access('shop-manage')
if denied:
return denied
data = request.get_json() or {}
payload = {
'groupId': data.get('group_id'),
'shopName': (data.get('shop_name') or '').strip(),
'mallName': (data.get('mall_name') or '').strip(),
'account': (data.get('account') or '').strip(),
'password': (data.get('password') or '').strip(),
}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/shop-manages/{item_id}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '更新成功',
'item': {
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'mall_name': item.get('mallName') or '',
'account': item.get('account') or '',
'password': item.get('password') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/shop-manage/<int:item_id>', methods=['DELETE'])
@login_required
def delete_shop_manage(item_id):
role, current_row, denied = _ensure_backend_menu_access('shop-manage')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/shop-manages/{item_id}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '删除成功',
})
@admin_api.route('/shop-manage-groups')
@login_required
def list_shop_manage_groups():
role, current_row, denied = _ensure_backend_menu_access(
'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data'
)
if denied:
return denied
groups, error_response, status = _load_expanded_shop_manage_groups(role, current_row)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'items': [_format_shop_manage_group_item(item) for item in (groups or [])]})
@admin_api.route('/shop-manage-group', methods=['POST'])
@login_required
def create_shop_manage_group():
role, current_row, denied = _ensure_backend_menu_access(
'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data'
)
if denied:
return denied
data = request.get_json() or {}
grant_menu_routes = data.get('grant_menu_routes') or []
payload = {
'groupName': (data.get('group_name') or '').strip(),
'memberUserIds': data.get('member_user_ids') or [],
'createdById': current_row.get('id') if current_row else None,
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/shop-manages/groups',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
return error_response, status
_grant_backend_menu_permissions(data.get('member_user_ids') or [], grant_menu_routes)
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '创建成功',
'item': {
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'leader_user_id': item.get('leaderUserId'),
'leader_username': item.get('leaderUsername') or '',
'member_user_ids': item.get('memberUserIds') or [],
'member_usernames': item.get('memberUsernames') or [],
'member_count': item.get('memberCount') or 0,
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT'])
@login_required
def update_shop_manage_group(item_id):
role, current_row, denied = _ensure_backend_menu_access(
'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data'
)
if denied:
return denied
data = request.get_json() or {}
grant_menu_routes = data.get('grant_menu_routes') or []
payload = {
'groupName': (data.get('group_name') or '').strip(),
'memberUserIds': data.get('member_user_ids') or [],
}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/shop-manages/groups/{item_id}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
return error_response, status
_grant_backend_menu_permissions(data.get('member_user_ids') or [], grant_menu_routes)
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '更新成功',
'item': {
'id': item.get('id'),
'group_name': item.get('groupName') or '',
'leader_user_id': item.get('leaderUserId'),
'leader_username': item.get('leaderUsername') or '',
'member_user_ids': item.get('memberUserIds') or [],
'member_usernames': item.get('memberUsernames') or [],
'member_count': item.get('memberCount') or 0,
'user_id': item.get('userId'),
'username': item.get('username') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/skip-price-asins')
@login_required
def list_skip_price_asins():
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
group_id_raw = (request.args.get('group_id') or '').strip()
shop_name = (request.args.get('shop_name') or '').strip()
asin = (request.args.get('asin') or '').strip()
params = {'page': page, 'page_size': page_size, 'pageSize': page_size}
params.update(_skip_price_asin_auth_params(role, current_row))
if group_id_raw:
try:
group_id = int(group_id_raw)
if group_id > 0:
params['group_id'] = group_id
params['groupId'] = group_id
except (TypeError, ValueError):
pass
if shop_name:
params['shop_name'] = shop_name
params['shopName'] = shop_name
if asin:
params['asin'] = asin
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/skip-price-asins',
params=params,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
items = [
{
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'asin_de': item.get('asinDe') or '',
'minimum_price_de': item.get('minimumPriceDe'),
'asin_uk': item.get('asinUk') or '',
'minimum_price_uk': item.get('minimumPriceUk'),
'asin_fr': item.get('asinFr') or '',
'minimum_price_fr': item.get('minimumPriceFr'),
'asin_it': item.get('asinIt') or '',
'minimum_price_it': item.get('minimumPriceIt'),
'asin_es': item.get('asinEs') or '',
'minimum_price_es': item.get('minimumPriceEs'),
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
for item in (payload.get('items') or [])
]
return jsonify({
'success': True,
'items': items,
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
})
def _skip_price_asin_auth_params(role, current_row):
operator_id = current_row.get('id') if current_row else None
super_admin = 'true' if role == 'super_admin' else 'false'
return {
'operator_id': operator_id,
'operatorId': operator_id,
'super_admin': super_admin,
'superAdmin': super_admin,
}
@admin_api.route('/skip-price-asins/export')
@login_required
def export_skip_price_asins():
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
params = _skip_price_asin_auth_params(role, current_row)
group_id_raw = (request.args.get('group_id') or request.args.get('groupId') or '').strip()
shop_name = (request.args.get('shop_name') or request.args.get('shopName') or '').strip()
asin = (request.args.get('asin') or '').strip()
if group_id_raw:
params['group_id'] = group_id_raw
params['groupId'] = group_id_raw
if shop_name:
params['shop_name'] = shop_name
params['shopName'] = shop_name
if asin:
params['asin'] = asin
url = f"{backend_java_base_url}/api/admin/skip-price-asins/export"
try:
resp = _get_backend_java_session().get(url, params=params, timeout=60)
except requests.RequestException:
return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
if resp.status_code >= 400:
try:
data = resp.json()
error = data.get('message') or data.get('error') or '导出失败'
except ValueError:
error = '导出失败'
return jsonify({'success': False, 'error': error}), resp.status_code
headers = {}
disposition = resp.headers.get('Content-Disposition')
if disposition:
headers['Content-Disposition'] = disposition
return Response(
resp.content,
status=resp.status_code,
headers=headers,
content_type=resp.headers.get(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
)
@admin_api.route('/skip-price-asin', methods=['POST'])
@login_required
def create_skip_price_asin():
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
data = request.get_json() or {}
asin_mappings = data.get('asin_mappings') or {}
minimum_price_mappings = data.get('minimum_price_mappings') or {}
fallback_asin = (data.get('asin') or '').strip()
if not fallback_asin and isinstance(asin_mappings, dict):
for value in asin_mappings.values():
fallback_asin = (value or '').strip()
if fallback_asin:
break
fallback_minimum_price = data.get('minimum_price')
if fallback_minimum_price in ('', None) and isinstance(minimum_price_mappings, dict):
for value in minimum_price_mappings.values():
if value not in ('', None):
fallback_minimum_price = value
break
payload = {
'groupId': data.get('group_id'),
'shopName': (data.get('shop_name') or '').strip(),
'countries': data.get('countries') or [],
'asin': fallback_asin,
'asinMappings': asin_mappings,
'minimumPrice': fallback_minimum_price,
'minimumPriceMappings': minimum_price_mappings,
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/skip-price-asins',
params=_skip_price_asin_auth_params(role, current_row),
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '保存成功',
'item': {
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'asin_de': item.get('asinDe') or '',
'minimum_price_de': item.get('minimumPriceDe'),
'asin_uk': item.get('asinUk') or '',
'minimum_price_uk': item.get('minimumPriceUk'),
'asin_fr': item.get('asinFr') or '',
'minimum_price_fr': item.get('minimumPriceFr'),
'asin_it': item.get('asinIt') or '',
'minimum_price_it': item.get('minimumPriceIt'),
'asin_es': item.get('asinEs') or '',
'minimum_price_es': item.get('minimumPriceEs'),
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['DELETE'])
@login_required
def delete_skip_price_asin_country(item_id, country):
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
params=_skip_price_asin_auth_params(role, current_row),
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE'])
@login_required
def delete_shop_manage_group(item_id):
role, current_row, denied = _ensure_backend_menu_access(
'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data'
)
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/shop-manages/groups/{item_id}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/skip-price-asin/<int:item_id>/country/<country>', methods=['PUT'])
@login_required
def update_skip_price_asin_country(item_id, country):
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
data = request.get_json() or {}
payload = {
'asin': (data.get('asin') or '').strip(),
'minimumPrice': data.get('minimum_price'),
}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/skip-price-asins/{item_id}/countries/{country}',
params=_skip_price_asin_auth_params(role, current_row),
json_data=payload,
)
if error_response is not None:
return error_response, status
item = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '保存成功',
'item': {
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'asin_de': item.get('asinDe') or '',
'minimum_price_de': item.get('minimumPriceDe'),
'asin_uk': item.get('asinUk') or '',
'minimum_price_uk': item.get('minimumPriceUk'),
'asin_fr': item.get('asinFr') or '',
'minimum_price_fr': item.get('minimumPriceFr'),
'asin_it': item.get('asinIt') or '',
'minimum_price_it': item.get('minimumPriceIt'),
'asin_es': item.get('asinEs') or '',
'minimum_price_es': item.get('minimumPriceEs'),
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/skip-price-asins/import/<import_id>')
@login_required
def skip_price_asin_import_progress(import_id):
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/skip-price-asins/import/{import_id}',
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'progress': _format_query_asin_import_progress(result.get('data') or {}),
})
@admin_api.route('/skip-price-asins/import', methods=['POST'])
@login_required
def import_skip_price_asins():
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
file_storage = request.files.get('file')
if not file_storage or file_storage.filename == '':
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
group_id_raw = (request.form.get('group_id') or '').strip()
if not group_id_raw:
return jsonify({'success': False, 'error': '请先选择分组'})
params = _skip_price_asin_auth_params(role, current_row)
params['group_id'] = group_id_raw
params['groupId'] = group_id_raw
files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/skip-price-asins/import',
params=params,
files=files,
timeout=60,
)
if error_response is not None:
return error_response, status
summary = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '开始导入',
'import_id': summary.get('importId') or '',
})
@admin_api.route('/skip-price-asins/delete-import/<import_id>')
@login_required
def skip_price_asin_delete_import_progress(import_id):
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/skip-price-asins/delete-import/{import_id}',
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'progress': _format_query_asin_import_progress(result.get('data') or {}),
})
@admin_api.route('/skip-price-asins/delete-import', methods=['POST'])
@login_required
def delete_import_skip_price_asins():
role, current_row, denied = _ensure_backend_menu_access('skip-price-asin')
if denied:
return denied
file_storage = request.files.get('file')
if not file_storage or file_storage.filename == '':
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
group_id_raw = (request.form.get('group_id') or '').strip()
if not group_id_raw:
return jsonify({'success': False, 'error': '请先选择分组'})
params = _skip_price_asin_auth_params(role, current_row)
params['group_id'] = group_id_raw
params['groupId'] = group_id_raw
files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/skip-price-asins/delete-import',
params=params,
files=files,
timeout=60,
)
if error_response is not None:
return error_response, status
summary = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '开始删除',
'import_id': summary.get('importId') or '',
})
def _format_query_asin_item(item):
return {
'id': item.get('id'),
'group_id': item.get('groupId'),
'group_name': item.get('groupName') or '',
'shop_name': item.get('shopName') or '',
'asin_de': item.get('asinDe') or '',
'asin_uk': item.get('asinUk') or '',
'asin_fr': item.get('asinFr') or '',
'asin_it': item.get('asinIt') or '',
'asin_es': item.get('asinEs') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
@admin_api.route('/query-asins')
@login_required
def list_query_asins():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
group_id_raw = (request.args.get('group_id') or '').strip()
shop_name = (request.args.get('shop_name') or '').strip()
asin = (request.args.get('asin') or '').strip()
params = {'page': page, 'pageSize': page_size}
if current_row and current_row.get('id'):
params['operatorId'] = current_row.get('id')
params['superAdmin'] = 'true' if role == 'super_admin' else 'false'
if group_id_raw:
try:
group_id = int(group_id_raw)
if group_id > 0:
params['groupId'] = group_id
except (TypeError, ValueError):
pass
if shop_name:
params['shopName'] = shop_name
if asin:
params['asin'] = asin
result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/query-asins',
params=params,
)
if error_response is not None:
return error_response, status
payload = result.get('data') or {}
return jsonify({
'success': True,
'items': [_format_query_asin_item(item) for item in (payload.get('items') or [])],
'total': payload.get('total') or 0,
'page': payload.get('page') or page,
'page_size': payload.get('pageSize') or page_size,
})
@admin_api.route('/query-asins/export')
@login_required
def export_query_asins():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
params = {
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
group_id_raw = (request.args.get('group_id') or request.args.get('groupId') or '').strip()
shop_name = (request.args.get('shop_name') or request.args.get('shopName') or '').strip()
asin = (request.args.get('asin') or '').strip()
if group_id_raw:
params['groupId'] = group_id_raw
if shop_name:
params['shopName'] = shop_name
if asin:
params['asin'] = asin
url = f"{backend_java_base_url}/api/admin/query-asins/export"
try:
resp = _get_backend_java_session().get(url, params=params, timeout=60)
except requests.RequestException:
return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502
if resp.status_code >= 400:
try:
data = resp.json()
error = data.get('message') or data.get('error') or '导出失败'
except ValueError:
error = '导出失败'
return jsonify({'success': False, 'error': error}), resp.status_code
headers = {}
disposition = resp.headers.get('Content-Disposition')
if disposition:
headers['Content-Disposition'] = disposition
return Response(
resp.content,
status=resp.status_code,
headers=headers,
content_type=resp.headers.get(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
)
def _format_query_asin_import_progress(progress):
return {
'status': progress.get('status') or 'pending',
'total_rows': progress.get('totalRows') or 0,
'processed_rows': progress.get('processedRows') or 0,
'asin_count': progress.get('asinCount') or 0,
'inserted_count': progress.get('insertedCount') or 0,
'deleted_count': progress.get('deletedCount') or 0,
'skipped_count': progress.get('skippedCount') or 0,
'error_message': progress.get('errorMessage') or '',
}
@admin_api.route('/query-asins/import/<import_id>')
@login_required
def query_asin_import_progress(import_id):
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/query-asins/import/{import_id}',
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'progress': _format_query_asin_import_progress(result.get('data') or {}),
})
@admin_api.route('/query-asins/import', methods=['POST'])
@login_required
def import_query_asins():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
file_storage = request.files.get('file')
if not file_storage or file_storage.filename == '':
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
group_id_raw = (request.form.get('group_id') or '').strip()
params = {
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
if group_id_raw:
params['groupId'] = group_id_raw
files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/query-asins/import',
params=params,
files=files,
timeout=60,
)
if error_response is not None:
return error_response, status
summary = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '开始导入',
'import_id': summary.get('importId') or '',
})
@admin_api.route('/query-asins/delete-import/<import_id>')
@login_required
def query_asin_delete_import_progress(import_id):
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/query-asins/delete-import/{import_id}',
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'progress': _format_query_asin_import_progress(result.get('data') or {}),
})
@admin_api.route('/query-asins/delete-import', methods=['POST'])
@login_required
def delete_import_query_asins():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
file_storage = request.files.get('file')
if not file_storage or file_storage.filename == '':
return jsonify({'success': False, 'error': '请选择 Excel 文件'})
group_id_raw = (request.form.get('group_id') or '').strip()
params = {
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
if group_id_raw:
params['groupId'] = group_id_raw
files = {
'file': (file_storage.filename, file_storage.stream, file_storage.mimetype or 'application/octet-stream')
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/query-asins/delete-import',
params=params,
files=files,
timeout=60,
)
if error_response is not None:
return error_response, status
summary = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '开始删除',
'import_id': summary.get('importId') or '',
})
@admin_api.route('/query-asin', methods=['POST'])
@login_required
def create_query_asin():
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
data = request.get_json() or {}
asin_mappings = data.get('asin_mappings') or {}
fallback_asin = (data.get('asin') or '').strip()
if not fallback_asin and isinstance(asin_mappings, dict):
for value in asin_mappings.values():
fallback_asin = (value or '').strip()
if fallback_asin:
break
payload = {
'groupId': data.get('group_id'),
'shopName': (data.get('shop_name') or '').strip(),
'countries': data.get('countries') or [],
'asin': fallback_asin,
'asinMappings': asin_mappings,
}
result, error_response, status = _proxy_backend_java(
'POST',
'/api/admin/query-asins',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '保存成功',
'item': _format_query_asin_item(result.get('data') or {}),
})
@admin_api.route('/query-asin/<int:item_id>/country/<country>', methods=['DELETE'])
@login_required
def delete_query_asin_country(item_id, country):
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/query-asins/{item_id}/countries/{country}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/query-asin/<int:item_id>/country/<country>', methods=['PUT'])
@login_required
def update_query_asin_country(item_id, country):
role, current_row, denied = _ensure_backend_menu_access('query-asin')
if denied:
return denied
data = request.get_json() or {}
payload = {
'asin': (data.get('asin') or '').strip(),
}
result, error_response, status = _proxy_backend_java(
'PUT',
f'/api/admin/query-asins/{item_id}/countries/{country}',
params={
'operatorId': current_row.get('id') if current_row else None,
'superAdmin': 'true' if role == 'super_admin' else 'false',
},
json_data=payload,
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '保存成功',
'item': _format_query_asin_item(result.get('data') or {}),
})
def _get_local_user_column_ids(user_id, menu_type=None):
conn = get_db()
try:
with conn.cursor() as cur:
sql = """
SELECT c.id
FROM user_column_permission ucp
INNER JOIN columns c ON c.id = ucp.column_id
WHERE ucp.user_id = %s
"""
params = [user_id]
if menu_type:
sql += " AND c.menu_type = %s"
params.append(menu_type)
sql += " ORDER BY c.sort_order ASC, c.id ASC"
cur.execute(sql, params)
rows = cur.fetchall()
return [int(row['id']) for row in rows if row.get('id') is not None]
finally:
conn.close()
def _get_local_user_column_permission_items(user_id, menu_type=None):
conn = get_db()
try:
with conn.cursor() as cur:
sql = """
SELECT c.id, c.name, c.column_key, c.menu_type, c.route_path, c.sort_order, c.created_at
FROM user_column_permission ucp
INNER JOIN columns c ON c.id = ucp.column_id
WHERE ucp.user_id = %s
"""
params = [user_id]
if menu_type:
sql += " AND c.menu_type = %s"
params.append(menu_type)
sql += " ORDER BY c.sort_order ASC, c.id ASC"
cur.execute(sql, params)
return cur.fetchall()
finally:
conn.close()