更新新增三个模块

This commit is contained in:
super
2026-03-22 11:20:09 +08:00
parent c80e1889ef
commit 497d65d41d
96 changed files with 5393 additions and 588 deletions

View File

@@ -0,0 +1,723 @@
"""
管理员 API 蓝图:用户管理、生成历史、版本管理(后台)
"""
import json
import os
import re
import requests
from flask import Blueprint, request, jsonify, session
import pymysql
from werkzeug.security import generate_password_hash
from utils.db import get_db
from utils.auth import admin_required, get_current_admin_role
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://127.0.0.1:18080').rstrip('/')
admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin')
def _safe_version_key(version):
"""将版本号转为安全的 OSS 对象名部分"""
s = (version or '').strip()
s = re.sub(r'[^\w.\-]', '_', s)
return s or 'unknown'
def _proxy_backend_java(method, path, *, params=None, json_data=None, files=None, data=None):
url = f"{backend_java_base_url}{path}"
try:
resp = requests.request(method=method, url=url, params=params, json=json_data, files=files, data=data, timeout=10)
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
# ---------- 用户管理 ----------
@admin_api.route('/users')
@admin_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)))
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
offset = (page - 1) * page_size
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
if role != 'super_admin':
created_by_id = None
try:
conn = get_db()
with conn.cursor() as cur:
if role == 'super_admin':
where_parts = ["1=1"]
params = []
if search_username:
where_parts.append("u.username LIKE %s")
params.append("%" + search_username + "%")
if created_by_id is not None:
where_parts.append("u.created_by_id = %s")
params.append(created_by_id)
where_sql = " AND ".join(where_parts)
cur.execute(
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
creator.username AS creator_username
FROM users u
LEFT JOIN users creator ON creator.id = u.created_by_id
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
tuple(params) + (page_size, offset),
)
rows = cur.fetchall()
cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params))
total = cur.fetchone()['total']
cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id")
admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()]
else:
admin_id = current_row['id']
where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"]
params = [admin_id, admin_id]
if search_username:
where_parts.append("u.username LIKE %s")
params.append("%" + search_username + "%")
where_sql = " AND ".join(where_parts)
cur.execute(
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
creator.username AS creator_username
FROM users u
LEFT JOIN users creator ON creator.id = u.created_by_id
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
tuple(params) + (page_size, offset),
)
rows = cur.fetchall()
cur.execute(
"SELECT COUNT(*) as total FROM users u WHERE " + where_sql,
tuple(params),
)
total = cur.fetchone()['total']
admins = []
items = [
{
'id': r['id'],
'username': r['username'],
'is_admin': bool(r.get('is_admin')),
'role': r.get('role') or 'normal',
'created_by_id': r.get('created_by_id'),
'creator_username': r.get('creator_username') or '',
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
}
for r in rows
]
conn.close()
return jsonify({
'success': True,
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'current_user_role': role,
'admins': admins,
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_api.route('/user', methods=['POST'])
@admin_required
def create_user():
data = request.get_json() or {}
username = (data.get('username') or '').strip()
password = data.get('password') or ''
role, current_row = get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
want_role = (data.get('role') or 'normal').strip() or 'normal'
if want_role not in ('admin', 'normal'):
want_role = 'normal'
if role == 'admin' and want_role == 'admin':
return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'})
if want_role == 'admin':
want_created_by = current_row['id']
elif role == 'super_admin':
want_created_by = data.get('created_by_id')
else:
want_created_by = current_row['id']
if not username or not password:
return jsonify({'success': False, 'error': '用户名和密码不能为空'})
if len(username) < 2:
return jsonify({'success': False, 'error': '用户名至少2个字符'})
if len(password) < 6:
return jsonify({'success': False, 'error': '密码至少6个字符'})
if want_role == 'normal' and role == 'super_admin' and want_created_by is None:
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1")
r = cur.fetchone()
conn.close()
want_created_by = r['id'] if r else current_row['id']
except Exception:
want_created_by = current_row['id']
if want_role == 'normal' and want_created_by is None:
want_created_by = current_row['id']
is_admin = 1 if want_role in ('super_admin', 'admin') else 0
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
column_ids = data.get('column_ids')
if column_ids is None:
column_ids = []
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
_set_user_column_permissions(cur, new_uid, column_ids)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '用户创建成功'})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_api.route('/user/<int:uid>', methods=['PUT'])
@admin_required
def update_user(uid):
"""更新用户(密码、角色)"""
data = request.get_json() or {}
password = data.get('password')
want_role = (data.get('role') or '').strip() or data.get('role')
role, current_row = get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
if want_role is None and not password:
return jsonify({'success': False, 'error': '请提供要修改的内容'})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
target = cur.fetchone()
if not target:
conn.close()
return jsonify({'success': False, 'error': '用户不存在'})
if role == 'admin':
if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']:
conn.close()
return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403
want_role = None
else:
if target.get('role') == 'super_admin':
conn.close()
return jsonify({'success': False, 'error': '不能修改超级管理员'})
if want_role == 'super_admin':
return jsonify({'success': False, 'error': '不能将用户设为超级管理员'})
if want_role not in ('admin', 'normal', None, ''):
want_role = None
if password:
if len(password) < 6:
conn.close()
return jsonify({'success': False, 'error': '密码至少6个字符'})
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid))
if want_role is not None and want_role != '':
is_admin = 1 if want_role == 'admin' else 0
cur.execute(
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
(is_admin, want_role, uid),
)
column_ids = data.get('column_ids')
if column_ids is not None:
_set_user_column_permissions(cur, uid, column_ids)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '更新成功'})
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):
"""删除用户"""
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():
"""管理员分页获取所有生成记录"""
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)})
# ---------- 栏目权限配置 ----------
@admin_api.route('/columns')
@admin_required
def list_columns():
"""获取栏目列表(用于栏目配置与用户权限选择)"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, name, column_key, created_at FROM columns ORDER BY id"
)
rows = cur.fetchall()
conn.close()
items = [
{
'id': r['id'],
'name': r['name'] or '',
'column_key': r['column_key'] 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)})
@admin_api.route('/column', methods=['POST'])
@admin_required
def create_column():
"""新增栏目"""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
column_key = (data.get('column_key') or '').strip()
if not name:
return jsonify({'success': False, 'error': '栏目名不能为空'})
if not column_key:
return jsonify({'success': False, 'error': '栏目标识不能为空'})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"INSERT INTO columns (name, column_key) VALUES (%s, %s)",
(name, column_key),
)
cid = cur.lastrowid
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '创建成功', 'id': cid})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '栏目标识已存在'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_api.route('/column/<int:cid>', methods=['PUT'])
@admin_required
def update_column(cid):
"""更新栏目"""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
column_key = (data.get('column_key') or '').strip()
if not name:
return jsonify({'success': False, 'error': '栏目名不能为空'})
if not column_key:
return jsonify({'success': False, 'error': '栏目标识不能为空'})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"UPDATE columns SET name = %s, column_key = %s WHERE id = %s",
(name, column_key, cid),
)
if cur.rowcount == 0:
conn.close()
return jsonify({'success': False, 'error': '栏目不存在'})
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '更新成功'})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '栏目标识已存在'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_api.route('/column/<int:cid>', methods=['DELETE'])
@admin_required
def delete_column(cid):
"""删除栏目(会同步删除用户栏目权限关联)"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("DELETE FROM user_column_permission WHERE column_id = %s", (cid,))
cur.execute("DELETE FROM columns WHERE id = %s", (cid,))
if cur.rowcount == 0:
conn.close()
return jsonify({'success': False, 'error': '栏目不存在'})
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '删除成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_api.route('/user/<int:uid>/columns')
@admin_required
def get_user_columns(uid):
"""获取某用户的栏目权限 ID 列表(编辑用户时回显)"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT column_id FROM user_column_permission WHERE user_id = %s",
(uid,),
)
rows = cur.fetchall()
conn.close()
column_ids = [r['column_id'] for r in rows]
return jsonify({'success': True, 'column_ids': column_ids})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_api.route('/user/<int:uid>/column-permissions')
@admin_required
def get_user_column_permissions(uid):
"""根据用户获取其拥有的栏目权限详细信息"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"""
SELECT c.id, c.name, c.column_key, c.created_at
FROM user_column_permission ucp
JOIN columns c ON c.id = ucp.column_id
WHERE ucp.user_id = %s
ORDER BY c.id
""",
(uid,),
)
rows = cur.fetchall()
conn.close()
items = [
{
'id': r['id'],
'name': r['name'] or '',
'column_key': r['column_key'] 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)})
def _set_user_column_permissions(cur, user_id, column_ids):
"""设置用户栏目权限先删后插。cur 为已打开的游标。"""
cur.execute("DELETE FROM user_column_permission WHERE user_id = %s", (user_id,))
if column_ids:
column_ids = [int(x) for x in column_ids if x]
for cid in column_ids:
cur.execute(
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
(user_id, cid),
)
# ---------- 版本管理web_config ----------
@admin_api.route('/versions')
@admin_required
def list_versions():
"""获取版本列表"""
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)})
@admin_api.route('/version', methods=['POST'])
@admin_required
def upload_version():
"""接收版本号 + zip 文件,上传到 OSS写入 web_config"""
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('/dedupe-total-data')
@admin_required
def list_dedupe_total_data():
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/dedupe-total-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 = [
{
'id': item.get('id'),
'data_value': item.get('dataValue') 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/import', methods=['POST'])
@admin_required
def import_dedupe_total_data():
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,
)
if error_response is not None:
return error_response, status
summary = result.get('data') or {}
return jsonify({
'success': True,
'msg': result.get('message') or '导入成功',
'summary': {
'total_rows': summary.get('totalRows') or 0,
'asin_count': summary.get('asinCount') or 0,
'inserted_count': summary.get('insertedCount') or 0,
'skipped_count': summary.get('skippedCount') or 0,
},
})
@admin_api.route('/dedupe-total-data', methods=['POST'])
@admin_required
def create_dedupe_total_data():
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',
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 '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/dedupe-total-data/<int:item_id>', methods=['PUT'])
@admin_required
def update_dedupe_total_data(item_id):
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}',
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 '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
},
})
@admin_api.route('/dedupe-total-data/<int:item_id>', methods=['DELETE'])
@admin_required
def delete_dedupe_total_data(item_id):
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/dedupe-total-data/{item_id}',
)
if error_response is not None:
return error_response, status
return jsonify({
'success': True,
'msg': result.get('message') or '删除成功',
})