new file: app/blueprints/__init__.py
new file: app/blueprints/__pycache__/__init__.cpython-311.pyc new file: app/blueprints/__pycache__/__init__.cpython-39.pyc new file: app/blueprints/__pycache__/admin.cpython-311.pyc new file: app/blueprints/__pycache__/admin.cpython-39.pyc new file: app/blueprints/__pycache__/auth.cpython-311.pyc new file: app/blueprints/__pycache__/auth.cpython-39.pyc new file: app/blueprints/__pycache__/brand.cpython-311.pyc new file: app/blueprints/__pycache__/brand.cpython-39.pyc new file: app/blueprints/__pycache__/image.cpython-311.pyc new file: app/blueprints/__pycache__/image.cpython-39.pyc new file: app/blueprints/__pycache__/main.cpython-311.pyc new file: app/blueprints/__pycache__/main.cpython-39.pyc new file: app/blueprints/admin.py new file: app/blueprints/auth.py new file: app/blueprints/brand.py new file: app/blueprints/image.py new file: app/blueprints/main.py new file: app/brand_spider/__pycache__/main.cpython-39.pyc new file: app/brand_spider/__pycache__/web_dec.cpython-39.pyc new file: app/brand_spider/main.py new file: app/brand_spider/web_dec.py new file: app/static/bg.jpg new file: "app/static/\345\223\201\347\211\214\346\226\207\346\241\243\346\240\274\345\274\217_\346\250\241\346\235\277.xlsx" new file: "app/static/\346\250\241\346\235\2772-\344\273\245\346\226\207\344\273\266\345\244\271\346\226\271\345\274\217\344\270\212\344\274\240.zip" new file: app/tool/.device_id new file: app/tool/__pycache__/devices.cpython-311.pyc new file: app/tool/__pycache__/devices.cpython-39.pyc new file: app/tool/devices.py
This commit is contained in:
1
app/blueprints/__init__.py
Normal file
1
app/blueprints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 蓝图包
|
||||
BIN
app/blueprints/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
app/blueprints/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
app/blueprints/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/admin.cpython-311.pyc
Normal file
BIN
app/blueprints/__pycache__/admin.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/admin.cpython-39.pyc
Normal file
BIN
app/blueprints/__pycache__/admin.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/auth.cpython-311.pyc
Normal file
BIN
app/blueprints/__pycache__/auth.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/auth.cpython-39.pyc
Normal file
BIN
app/blueprints/__pycache__/auth.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/brand.cpython-311.pyc
Normal file
BIN
app/blueprints/__pycache__/brand.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/brand.cpython-39.pyc
Normal file
BIN
app/blueprints/__pycache__/brand.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/image.cpython-311.pyc
Normal file
BIN
app/blueprints/__pycache__/image.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/image.cpython-39.pyc
Normal file
BIN
app/blueprints/__pycache__/image.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/main.cpython-311.pyc
Normal file
BIN
app/blueprints/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
app/blueprints/__pycache__/main.cpython-39.pyc
Normal file
BIN
app/blueprints/__pycache__/main.cpython-39.pyc
Normal file
Binary file not shown.
362
app/blueprints/admin.py
Normal file
362
app/blueprints/admin.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
管理员蓝图:用户管理(列表/创建/更新/删除)、生成历史、管理页
|
||||
"""
|
||||
import json
|
||||
import pymysql
|
||||
from flask import Blueprint, request, jsonify
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required
|
||||
from flask import session
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
def _parse_json(val, default=None):
|
||||
if val is None:
|
||||
return default if default is not None else []
|
||||
if isinstance(val, (list, dict)):
|
||||
return val
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return default if default is not None else []
|
||||
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@login_required
|
||||
@admin_required
|
||||
def admin_page():
|
||||
return _render_html('admin.html')
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users')
|
||||
@admin_required
|
||||
def admin_list_users():
|
||||
"""分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选"""
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
|
||||
offset = (page - 1) * page_size
|
||||
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
|
||||
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
|
||||
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
|
||||
if role != 'super_admin':
|
||||
created_by_id = None
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
if role == 'super_admin':
|
||||
where_parts = ["1=1"]
|
||||
params = []
|
||||
if search_username:
|
||||
where_parts.append("u.username LIKE %s")
|
||||
params.append("%" + search_username + "%")
|
||||
if created_by_id is not None:
|
||||
where_parts.append("u.created_by_id = %s")
|
||||
params.append(created_by_id)
|
||||
where_sql = " AND ".join(where_parts)
|
||||
cur.execute(
|
||||
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
|
||||
creator.username AS creator_username
|
||||
FROM users u
|
||||
LEFT JOIN users creator ON creator.id = u.created_by_id
|
||||
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
|
||||
tuple(params) + (page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params))
|
||||
total = cur.fetchone()['total']
|
||||
cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id")
|
||||
admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()]
|
||||
else:
|
||||
admin_id = current_row['id']
|
||||
where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"]
|
||||
params = [admin_id, admin_id]
|
||||
if search_username:
|
||||
where_parts.append("u.username LIKE %s")
|
||||
params.append("%" + search_username + "%")
|
||||
where_sql = " AND ".join(where_parts)
|
||||
cur.execute(
|
||||
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
|
||||
creator.username AS creator_username
|
||||
FROM users u
|
||||
LEFT JOIN users creator ON creator.id = u.created_by_id
|
||||
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
|
||||
tuple(params) + (page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as total FROM users u WHERE " + where_sql,
|
||||
tuple(params),
|
||||
)
|
||||
total = cur.fetchone()['total']
|
||||
admins = []
|
||||
items = [
|
||||
{
|
||||
'id': r['id'],
|
||||
'username': r['username'],
|
||||
'is_admin': bool(r.get('is_admin')),
|
||||
'role': r.get('role') or 'normal',
|
||||
'created_by_id': r.get('created_by_id'),
|
||||
'creator_username': r.get('creator_username') or '',
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
conn.close()
|
||||
payload = {
|
||||
'success': True,
|
||||
'items': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'current_user_role': role,
|
||||
'admins': admins,
|
||||
}
|
||||
return jsonify(payload)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_create_user():
|
||||
data = request.get_json() or {}
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
want_role = (data.get('role') or 'normal').strip() or 'normal'
|
||||
if want_role not in ('admin', 'normal'):
|
||||
want_role = 'normal'
|
||||
if role == 'admin' and want_role == 'admin':
|
||||
return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'})
|
||||
if want_role == 'admin':
|
||||
want_created_by = current_row['id']
|
||||
elif role == 'super_admin':
|
||||
want_created_by = data.get('created_by_id')
|
||||
else:
|
||||
want_created_by = current_row['id']
|
||||
if not username or not password:
|
||||
return jsonify({'success': False, 'error': '用户名和密码不能为空'})
|
||||
if len(username) < 2:
|
||||
return jsonify({'success': False, 'error': '用户名至少2个字符'})
|
||||
if len(password) < 6:
|
||||
return jsonify({'success': False, 'error': '密码至少6个字符'})
|
||||
if want_role == 'normal' and role == 'super_admin' and want_created_by is None:
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1")
|
||||
r = cur.fetchone()
|
||||
conn.close()
|
||||
want_created_by = r['id'] if r else current_row['id']
|
||||
except Exception:
|
||||
want_created_by = current_row['id']
|
||||
if want_role == 'normal' and want_created_by is None:
|
||||
want_created_by = current_row['id']
|
||||
is_admin = 1 if want_role in ('super_admin', 'admin') else 0
|
||||
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)",
|
||||
(username, pwd_hash, is_admin, want_role, want_created_by),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'msg': '用户创建成功'})
|
||||
except pymysql.IntegrityError:
|
||||
return jsonify({'success': False, 'error': '用户名已存在'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>', methods=['PUT'])
|
||||
@admin_required
|
||||
def admin_update_user(uid):
|
||||
data = request.get_json() or {}
|
||||
password = data.get('password')
|
||||
want_role = (data.get('role') or '').strip() or data.get('role')
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
if want_role is None and not password:
|
||||
return jsonify({'success': False, 'error': '请提供要修改的内容'})
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
|
||||
target = cur.fetchone()
|
||||
if not target:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
if role == 'admin':
|
||||
if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403
|
||||
want_role = None
|
||||
else:
|
||||
if target.get('role') == 'super_admin':
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '不能修改超级管理员'})
|
||||
if want_role == 'super_admin':
|
||||
return jsonify({'success': False, 'error': '不能将用户设为超级管理员'})
|
||||
if want_role not in ('admin', 'normal', None, ''):
|
||||
want_role = None
|
||||
if password:
|
||||
if len(password) < 6:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '密码至少6个字符'})
|
||||
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid))
|
||||
if want_role is not None and want_role != '':
|
||||
is_admin = 1 if want_role == 'admin' else 0
|
||||
cur.execute(
|
||||
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
|
||||
(is_admin, want_role, uid),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'msg': '更新成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def admin_delete_user(uid):
|
||||
from flask import session
|
||||
if session.get('user_id') == uid:
|
||||
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
|
||||
role, current_row = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
|
||||
target = cur.fetchone()
|
||||
if not target:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
if target.get('role') == 'super_admin':
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '不能删除超级管理员'})
|
||||
if role == 'admin':
|
||||
if target.get('role') != 'normal' or target.get('created_by_id') != current_row['id']:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': '只能删除自己创建的普通用户'}), 403
|
||||
cur.execute("DELETE FROM users WHERE id = %s", (uid,))
|
||||
affected = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if affected == 0:
|
||||
return jsonify({'success': False, 'error': '用户不存在'})
|
||||
return jsonify({'success': True, 'msg': '删除成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/user/<int:uid>/column-permissions')
|
||||
@login_required
|
||||
def admin_user_column_permissions(uid):
|
||||
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
|
||||
current_uid = session.get('user_id')
|
||||
if current_uid != uid:
|
||||
role, _ = _get_current_admin_role()
|
||||
if not role:
|
||||
return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
|
||||
user_row = cur.fetchone()
|
||||
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
|
||||
cur.execute("""
|
||||
SELECT id, name, column_key, created_at FROM columns ORDER BY id
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.column_key, c.created_at
|
||||
FROM columns c
|
||||
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
|
||||
WHERE ucp.user_id = %s
|
||||
ORDER BY c.id
|
||||
""", (uid,))
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
items = [
|
||||
{
|
||||
'id': r['id'],
|
||||
'name': r['name'],
|
||||
'column_key': r['column_key'],
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return jsonify({'success': True, 'items': items})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/history')
|
||||
@admin_required
|
||||
def admin_history():
|
||||
"""管理员分页获取所有生成记录,支持按用户和时间筛选"""
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
|
||||
offset = (page - 1) * page_size
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
time_start = (request.args.get('time_start') or '').strip()
|
||||
time_end = (request.args.get('time_end') or '').strip()
|
||||
conditions, params = [], []
|
||||
if user_id:
|
||||
conditions.append("h.user_id = %s")
|
||||
params.append(user_id)
|
||||
if time_start:
|
||||
conditions.append("h.created_at >= %s")
|
||||
params.append(time_start)
|
||||
if time_end:
|
||||
conditions.append("h.created_at <= %s")
|
||||
params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end)
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
params_count = params[:]
|
||||
params.extend([page_size, offset])
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls,
|
||||
h.long_image_url, u.username
|
||||
FROM image_history h
|
||||
LEFT JOIN users u ON h.user_id = u.id
|
||||
WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count)
|
||||
total = cur.fetchone()['total']
|
||||
conn.close()
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
'id': r['id'],
|
||||
'user_id': r['user_id'],
|
||||
'username': r.get('username') or '-',
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
|
||||
'panel_type': r['panel_type'] or '',
|
||||
'original_urls': _parse_json(r['original_urls'], []),
|
||||
'params': _parse_json(r['params'], {}),
|
||||
'result_urls': _parse_json(r['result_urls'], []),
|
||||
'long_image_url': (r.get('long_image_url') or '').strip() or None,
|
||||
})
|
||||
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
107
app/blueprints/auth.py
Normal file
107
app/blueprints/auth.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
认证蓝图:登录、登出、登录状态校验
|
||||
"""
|
||||
from flask import Blueprint, request, redirect, url_for, session, jsonify
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from app_common import (
|
||||
get_db,
|
||||
_render_html,
|
||||
_is_session_user_valid,
|
||||
login_required,
|
||||
BASE_DIR,
|
||||
)
|
||||
from tool.devices import DeviceIDGenerator
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if session.get('user_id') and _is_session_user_valid():
|
||||
return redirect(url_for('main.home'))
|
||||
if request.method == 'POST':
|
||||
data = request.get_json() if request.is_json else request.form
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
if not username or not password:
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': '请输入用户名和密码'})
|
||||
return _render_html('login.html', error='请输入用户名和密码')
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s",
|
||||
(username,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and check_password_hash(row['password_hash'], password):
|
||||
current_machine = DeviceIDGenerator().get_device_id()
|
||||
stored_machine = (row.get('machine') or '').strip()
|
||||
if not stored_machine:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id']))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
session.permanent = True
|
||||
session['user_id'] = row['id']
|
||||
session['username'] = username
|
||||
if request.is_json:
|
||||
return jsonify({'success': True, 'redirect': url_for('main.home')})
|
||||
return redirect(url_for('main.home'))
|
||||
print("验证设备",stored_machine)
|
||||
print("当前设备",current_machine)
|
||||
if stored_machine != current_machine and row.get("is_admin") != 1:
|
||||
conn.close()
|
||||
err_msg = '当前设备与首次登录设备不一致,请在原设备上登录'
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': err_msg})
|
||||
return _render_html('login.html', error=err_msg)
|
||||
conn.close()
|
||||
session.permanent = True
|
||||
session['user_id'] = row['id']
|
||||
session['username'] = username
|
||||
if request.is_json:
|
||||
return jsonify({'success': True, 'redirect': url_for('main.home')})
|
||||
return redirect(url_for('main.home'))
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
return _render_html('login.html', error='登录失败,请稍后重试')
|
||||
if request.is_json:
|
||||
return jsonify({'success': False, 'error': '用户名或密码错误'})
|
||||
return _render_html('login.html', error='用户名或密码错误')
|
||||
return _render_html('login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/check')
|
||||
@login_required
|
||||
def api_auth_check():
|
||||
"""校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致"""
|
||||
if not session.get('user_id'):
|
||||
return jsonify({'logged_in': False})
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return jsonify({'logged_in': False})
|
||||
stored_machine = (row.get('machine') or '').strip()
|
||||
if stored_machine:
|
||||
current_machine = DeviceIDGenerator().get_device_id()
|
||||
if stored_machine != current_machine and row.get("is_admin") != 1:
|
||||
session.clear()
|
||||
return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'})
|
||||
except Exception:
|
||||
return jsonify({'logged_in': False})
|
||||
return jsonify({'logged_in': True, 'redirect': url_for('main.home')})
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for('auth.login'))
|
||||
855
app/blueprints/brand.py
Normal file
855
app/blueprints/brand.py
Normal file
@@ -0,0 +1,855 @@
|
||||
"""
|
||||
品牌爬虫蓝图:展开文件夹、运行任务、任务列表/详情、下载结果
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import datetime
|
||||
import traceback
|
||||
import zipfile
|
||||
import io
|
||||
import time
|
||||
import tempfile
|
||||
import requests
|
||||
from queue import Queue, Empty
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from config import bucket_path
|
||||
from brand_spider.main import single_file_handle, TaskCancelledError
|
||||
|
||||
brand_bp = Blueprint('brand', __name__)
|
||||
BRAND_OUTPUT_DIR = os.path.join(BASE_DIR, 'brand_output')
|
||||
OSS_PREFIX = "brand_results"
|
||||
|
||||
# 任务完成时推送给前端的 SSE 队列:task_id -> [Queue, ...]
|
||||
_task_event_queues = {}
|
||||
_task_event_lock = threading.Lock()
|
||||
|
||||
# 任务行级进度(当前处理文件的行数/总行数),仅存内存,不落库:
|
||||
# task_id -> {
|
||||
# 'file_index': int, # 当前处理第几个文件
|
||||
# 'file_total': int, # 总文件数
|
||||
# 'file_path': str, # 当前文件路径
|
||||
# 'current_line': int, # 当前行号(或当前品牌序号)
|
||||
# 'total_lines': int, # 总行数(或总品牌数)
|
||||
# }
|
||||
_task_line_progress = {}
|
||||
_task_line_progress_lock = threading.Lock()
|
||||
|
||||
|
||||
def _push_task_event(task_id, event):
|
||||
"""向订阅了该任务的所有 SSE 连接推送事件,并移除该任务的队列列表。
|
||||
同时清理内存中的行级进度,不通过数据库中转行级进度。"""
|
||||
with _task_event_lock:
|
||||
queues = _task_event_queues.pop(task_id, [])
|
||||
for q in queues:
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
# 任务进入终态时,顺便清理行级进度缓存
|
||||
with _task_line_progress_lock:
|
||||
_task_line_progress.pop(task_id, None)
|
||||
|
||||
|
||||
def _expand_folder_xlsx(folder_path):
|
||||
"""返回文件夹下所有 .xlsx 文件的绝对路径列表"""
|
||||
if not folder_path or not os.path.isdir(folder_path):
|
||||
return []
|
||||
paths = []
|
||||
for name in os.listdir(folder_path):
|
||||
if name.endswith('.xlsx') or name.endswith('.XLSX'):
|
||||
paths.append(os.path.normpath(os.path.join(folder_path, name)))
|
||||
return paths
|
||||
|
||||
|
||||
def _upload_local_xlsx_to_oss(local_path, user_id):
|
||||
"""将本地 xlsx 文件上传到 OSS,返回 (url, None) 或 (None, error_message)。"""
|
||||
if not local_path or not os.path.isfile(local_path):
|
||||
return None, "文件不存在"
|
||||
try:
|
||||
from ali_oss import upload_file as oss_upload_file
|
||||
except ImportError:
|
||||
return None, "OSS 模块未配置"
|
||||
base = os.path.basename(local_path)
|
||||
safe_base = "".join(c if c.isalnum() or c in '-_.' else '_' for c in base)
|
||||
if not safe_base.endswith('.xlsx'):
|
||||
safe_base = safe_base + '.xlsx'
|
||||
key = f"{bucket_path}brand_input/{int(time.time() * 1000)}/{user_id}_{safe_base}"
|
||||
try:
|
||||
with open(local_path, "rb") as f:
|
||||
content = f.read()
|
||||
url = oss_upload_file(content, key)
|
||||
return url, None
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def _run_brand_files(file_paths, task_id=None, check_cancelled=None, strategy="Terms"):
|
||||
"""对多个 xlsx 执行 single_file_handle(线程池多线程),返回 (result_paths, error_message)。
|
||||
check_cancelled: 可调用对象,返回 True 时中止并返回 (result_paths, 'cancelled')。
|
||||
task_id 存在时会在每处理完一个文件后更新 progress_current。
|
||||
strategy: 品牌匹配方式,默认 Terms,可选 Simple。"""
|
||||
os.makedirs(BRAND_OUTPUT_DIR, exist_ok=True)
|
||||
subdir = str(task_id) if task_id else ('immediate_' + datetime.datetime.now().strftime('%Y%m%d_%H%M%S'))
|
||||
out_dir = os.path.join(BRAND_OUTPUT_DIR, subdir)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# 先筛出有效路径并生成 (fp, out_path) 列表
|
||||
tasks = []
|
||||
for fp in file_paths:
|
||||
if not fp or not isinstance(fp, str) or not os.path.isfile(fp):
|
||||
continue
|
||||
base = os.path.splitext(os.path.basename(fp))[0]
|
||||
safe_base = "".join(c if c.isalnum() or c in '-_' else '_' for c in base)
|
||||
out_path = os.path.join(out_dir, safe_base + '_result.xlsx')
|
||||
tasks.append((fp, out_path))
|
||||
|
||||
if not tasks:
|
||||
return [], None
|
||||
|
||||
result_paths = []
|
||||
result_lock = threading.Lock()
|
||||
current = [0] # 用列表以便在闭包中修改
|
||||
|
||||
def process_one(fp, out_path, file_index, files_total):
|
||||
# 行级进度回调:由 single_file_handle 在循环中调用,直接写入内存字典,不落库
|
||||
def progress_callback(current_line, total_lines, extra=None):
|
||||
if not task_id:
|
||||
return
|
||||
info = {
|
||||
'file_index': file_index,
|
||||
'file_total': files_total,
|
||||
'file_path': fp,
|
||||
'current_line': int(current_line or 0),
|
||||
'total_lines': int(total_lines or 0),
|
||||
}
|
||||
if isinstance(extra, dict):
|
||||
info.update(extra)
|
||||
with _task_line_progress_lock:
|
||||
_task_line_progress[task_id] = info
|
||||
|
||||
single_file_handle(fp, out_path, strategy, check_cancelled=check_cancelled, progress_callback=progress_callback)
|
||||
return out_path
|
||||
|
||||
max_workers = min(8, len(tasks))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_out = {
|
||||
executor.submit(process_one, fp, out_path, idx + 1, len(tasks)): out_path
|
||||
for idx, (fp, out_path) in enumerate(tasks)
|
||||
}
|
||||
try:
|
||||
for future in as_completed(future_to_out):
|
||||
if check_cancelled and check_cancelled():
|
||||
for f in future_to_out:
|
||||
f.cancel()
|
||||
return result_paths, 'cancelled'
|
||||
try:
|
||||
out_path = future.result()
|
||||
with result_lock:
|
||||
result_paths.append(out_path)
|
||||
current[0] += 1
|
||||
if task_id:
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE brand_crawl_tasks SET progress_current = %s WHERE id = %s",
|
||||
(current[0], task_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
print(traceback.format_exc())
|
||||
except TaskCancelledError:
|
||||
for f in future_to_out:
|
||||
f.cancel()
|
||||
return result_paths, 'cancelled'
|
||||
except Exception as e:
|
||||
print(traceback.format_exc())
|
||||
print(e)
|
||||
for f in future_to_out:
|
||||
f.cancel()
|
||||
return result_paths, str(e)
|
||||
except Exception as e:
|
||||
print(traceback.format_exc())
|
||||
return result_paths, str(e)
|
||||
return result_paths, None
|
||||
|
||||
|
||||
def _upload_results_to_oss(source_paths, local_result_paths, task_id):
|
||||
"""
|
||||
将源文件和结果文件上传到 OSS,并打包成 zip(分两个文件夹:源文件、结果文件)上传。
|
||||
返回 (result_data, error_message),result_data 为 {"urls": [url1, ...], "zip_url": "..."},出错时为 (None, err)。
|
||||
"""
|
||||
if not local_result_paths or not task_id:
|
||||
return None, "无结果文件"
|
||||
try:
|
||||
from ali_oss import upload_file as oss_upload_file
|
||||
except ImportError:
|
||||
return None, "OSS 模块未配置"
|
||||
urls = []
|
||||
subdir = f"{OSS_PREFIX}/{task_id}"
|
||||
for fp in local_result_paths:
|
||||
if not fp or not os.path.isfile(fp):
|
||||
continue
|
||||
name = os.path.basename(fp)
|
||||
key = f"{bucket_path}{subdir}/{name}"
|
||||
try:
|
||||
with open(fp, "rb") as f:
|
||||
content = f.read()
|
||||
url = oss_upload_file(content, key)
|
||||
urls.append(url)
|
||||
except Exception as e:
|
||||
return None, f"上传结果文件失败: {e}"
|
||||
if not urls:
|
||||
return None, "没有可上传的结果文件"
|
||||
# 打包成 zip:源文件 -> 源文件/,结果文件 -> 结果文件/
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for fp in (source_paths or []):
|
||||
if fp and os.path.isfile(fp):
|
||||
zf.write(fp, os.path.join("源文件", os.path.basename(fp)))
|
||||
for fp in local_result_paths:
|
||||
if fp and os.path.isfile(fp):
|
||||
zf.write(fp, os.path.join("结果文件", os.path.basename(fp)))
|
||||
buf.seek(0)
|
||||
zip_key = f"{bucket_path}{subdir}/result.zip"
|
||||
try:
|
||||
zip_url = oss_upload_file(buf.getvalue(), zip_key)
|
||||
except Exception as e:
|
||||
return {"urls": urls, "zip_url": None}, None # 单文件链接已保存,zip 失败不报错
|
||||
return {"urls": urls, "zip_url": zip_url}, None
|
||||
|
||||
|
||||
def _background_brand_task(task_id):
|
||||
"""后台执行单个品牌爬虫任务并更新数据库"""
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, user_id, file_paths, strategy, `desc` FROM brand_crawl_tasks WHERE id = %s AND status = 'pending'",
|
||||
(task_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return
|
||||
file_paths = row.get('file_paths')
|
||||
if isinstance(file_paths, str):
|
||||
file_paths = json.loads(file_paths) if file_paths else []
|
||||
# 优先使用表里存的 strategy 字段,兼容旧数据则从 desc 解析
|
||||
strategy = (row.get('strategy') or '').strip() or None
|
||||
if not strategy:
|
||||
desc_val = (row.get('desc') or '').strip()
|
||||
strategy = "Simple" if desc_val.startswith('[Simple]') else "Terms"
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not file_paths:
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
||||
('没有有效文件路径', task_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_push_task_event(task_id, {'status': 'failed', 'error_message': '没有有效文件路径'})
|
||||
return
|
||||
# 将 file_paths 解析为本地路径:URL 下载到 BRAND_OUTPUT_DIR 对应任务文件夹下(兼容旧数据中的本地路径)
|
||||
os.makedirs(BRAND_OUTPUT_DIR, exist_ok=True)
|
||||
task_subdir = str(task_id) if task_id else ('immediate_' + datetime.datetime.now().strftime('%Y%m%d_%H%M%S'))
|
||||
task_dir = os.path.join(BRAND_OUTPUT_DIR, task_subdir)
|
||||
os.makedirs(task_dir, exist_ok=True)
|
||||
|
||||
local_paths = []
|
||||
downloaded_files_to_cleanup_on_fail = []
|
||||
for idx, p in enumerate(file_paths):
|
||||
if not p or not isinstance(p, str):
|
||||
continue
|
||||
p = p.strip()
|
||||
if _is_url(p):
|
||||
try:
|
||||
parsed = urlparse(p)
|
||||
url_name = os.path.basename(parsed.path or '')
|
||||
if not url_name:
|
||||
url_name = f"input_{idx + 1}.xlsx"
|
||||
safe_name = "".join(c if c.isalnum() or c in '-_.' else '_' for c in url_name)
|
||||
if not safe_name.lower().endswith('.xlsx'):
|
||||
safe_name = safe_name + '.xlsx'
|
||||
|
||||
base_no_ext, ext = os.path.splitext(safe_name)
|
||||
dest = os.path.join(task_dir, safe_name)
|
||||
n = 1
|
||||
while os.path.exists(dest):
|
||||
dest = os.path.join(task_dir, f"{base_no_ext}_{n}{ext}")
|
||||
n += 1
|
||||
|
||||
r = requests.get(p, timeout=120, stream=True)
|
||||
r.raise_for_status()
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
local_paths.append(dest)
|
||||
downloaded_files_to_cleanup_on_fail.append(dest)
|
||||
except Exception as e:
|
||||
for tf in downloaded_files_to_cleanup_on_fail:
|
||||
try:
|
||||
os.unlink(tf)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
||||
(f'下载文件失败: {str(e)}', task_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_push_task_event(task_id, {'status': 'failed', 'error_message': f'下载文件失败: {str(e)}'})
|
||||
return
|
||||
elif os.path.isfile(p):
|
||||
local_paths.append(p)
|
||||
if not local_paths:
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
||||
('没有有效的本地或可下载文件', task_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_push_task_event(task_id, {'status': 'failed', 'error_message': '没有有效的本地或可下载文件'})
|
||||
return
|
||||
try:
|
||||
# 原子地将 pending 改为 running,避免用户先点取消时仍被覆盖为 running
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE brand_crawl_tasks SET status = 'running', progress_total = %s, progress_current = 0 WHERE id = %s AND status = 'pending'",
|
||||
(len(local_paths), task_id)
|
||||
)
|
||||
n = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if n == 0:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT status FROM brand_crawl_tasks WHERE id = %s", (task_id,))
|
||||
r = cur.fetchone()
|
||||
conn.close()
|
||||
if r and (r.get('status') or '') == 'cancelled':
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def check_cancelled():
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT status FROM brand_crawl_tasks WHERE id = %s", (task_id,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return row and (row.get('status') or '') == 'cancelled'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
result_paths, err = _run_brand_files(local_paths, task_id=task_id, check_cancelled=check_cancelled, strategy=strategy)
|
||||
if err:
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
st = 'cancelled' if err == 'cancelled' else 'failed'
|
||||
cur.execute(
|
||||
"UPDATE brand_crawl_tasks SET status = %s, error_message = %s WHERE id = %s",
|
||||
(st, err if st == 'failed' else None, task_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print(e)
|
||||
pass
|
||||
_push_task_event(task_id, {'status': st, 'error_message': err if st == 'failed' else None})
|
||||
return
|
||||
result_data, upload_err = _upload_results_to_oss(local_paths, result_paths, task_id)
|
||||
if upload_err:
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
||||
(upload_err, task_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_push_task_event(task_id, {'status': 'failed', 'error_message': upload_err})
|
||||
return
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE brand_crawl_tasks SET status = 'success', result_paths = %s WHERE id = %s",
|
||||
(json.dumps(result_data), task_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_push_task_event(task_id, {'status': 'success'})
|
||||
finally:
|
||||
pass
|
||||
except Exception as e:
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
||||
(str(e), task_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_push_task_event(task_id, {'status': 'failed', 'error_message': str(e)})
|
||||
|
||||
|
||||
def _parse_json(val, default=None):
|
||||
if val is None:
|
||||
return default if default is not None else []
|
||||
if isinstance(val, (list, dict)):
|
||||
return val
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return default if default is not None else []
|
||||
|
||||
|
||||
def _is_url(s):
|
||||
if not isinstance(s, str) or not s.strip():
|
||||
return False
|
||||
return s.strip().startswith('http://') or s.strip().startswith('https://')
|
||||
|
||||
|
||||
def _normalize_result_paths(raw):
|
||||
"""
|
||||
将 result_paths 规范化为 (url_list, zip_url)。
|
||||
支持旧格式 list 或新格式 dict {"urls": [...], "zip_url": "..."}。
|
||||
"""
|
||||
if not raw:
|
||||
return [], None
|
||||
if isinstance(raw, dict):
|
||||
urls = raw.get('urls') or []
|
||||
if not isinstance(urls, list):
|
||||
urls = []
|
||||
zip_url = raw.get('zip_url')
|
||||
if zip_url and not isinstance(zip_url, str):
|
||||
zip_url = None
|
||||
return urls, zip_url
|
||||
if isinstance(raw, list):
|
||||
return raw, None
|
||||
return [], None
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/expand-folder', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_expand_folder():
|
||||
"""展开文件夹,返回其下所有 .xlsx 文件路径列表"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
folder = (data.get('folder') or '').strip()
|
||||
if not folder:
|
||||
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
|
||||
paths = _expand_folder_xlsx(folder)
|
||||
return jsonify({'success': True, 'paths': paths})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/run', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_run():
|
||||
"""立即运行:创建任务并后台执行,立即返回 task_id,前端可轮询进度与取消"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
paths = data.get('paths') or []
|
||||
# task_type: 1=立即执行,2=添加任务;此接口默认 1
|
||||
try:
|
||||
task_type = int(data.get('task_type') or 1)
|
||||
except Exception:
|
||||
task_type = 1
|
||||
if task_type not in (1, 2):
|
||||
task_type = 1
|
||||
strategy = (data.get('strategy') or 'Terms').strip()
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
paths = [p.strip() for p in paths if p and isinstance(p, str) and os.path.isfile(p.strip())]
|
||||
if not paths:
|
||||
return jsonify({'success': False, 'error': '没有有效的 xlsx 文件路径'}), 400
|
||||
# 先上传到 OSS,获取链接再入库
|
||||
user_id = session['user_id']
|
||||
urls = []
|
||||
for p in paths:
|
||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||
if err:
|
||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||
urls.append(url)
|
||||
desc_core = ', '.join(os.path.basename(p) for p in paths)[:500] if paths else ''
|
||||
desc = f'[{strategy}] ' + (desc_core or '')
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO brand_crawl_tasks (user_id, file_paths, `desc`, strategy, status, task_type) VALUES (%s, %s, %s, %s, 'pending', %s)",
|
||||
(user_id, json.dumps(urls), desc or None, strategy, task_type)
|
||||
)
|
||||
task_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
threading.Thread(target=_background_brand_task, args=(task_id,), daemon=True).start()
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def api_brand_tasks():
|
||||
"""GET: 获取当前用户的任务列表;POST: 添加任务(后台执行)"""
|
||||
if request.method == 'GET':
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT id, file_paths, `desc`, strategy, status, result_paths, error_message,
|
||||
COALESCE(progress_current, 0) AS progress_current,
|
||||
COALESCE(progress_total, 0) AS progress_total,
|
||||
created_at, updated_at
|
||||
FROM brand_crawl_tasks WHERE user_id = %s ORDER BY id DESC LIMIT 100""",
|
||||
(session['user_id'],)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
'id': r['id'],
|
||||
'file_paths': _parse_json(r['file_paths'], []),
|
||||
'desc': (r.get('desc') or '').strip() or None,
|
||||
'strategy': (r.get('strategy') or 'Terms').strip() or 'Terms',
|
||||
'status': r.get('status') or 'pending',
|
||||
'result_paths': _parse_json(r['result_paths']),
|
||||
'error_message': (r.get('error_message') or '').strip() or None,
|
||||
'progress_current': int(r.get('progress_current') or 0),
|
||||
'progress_total': int(r.get('progress_total') or 0),
|
||||
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||
'updated_at': r['updated_at'].strftime('%Y-%m-%d %H:%M') if r.get('updated_at') else '',
|
||||
})
|
||||
return jsonify({'success': True, 'items': items})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
# POST
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
paths = data.get('paths') or []
|
||||
# task_type: 1=立即执行,2=添加任务;此接口默认 2
|
||||
try:
|
||||
task_type = int(data.get('task_type') or 2)
|
||||
except Exception:
|
||||
task_type = 2
|
||||
if task_type not in (1, 2):
|
||||
task_type = 2
|
||||
strategy = (data.get('strategy') or 'Terms').strip()
|
||||
if strategy not in ('Terms', 'Simple'):
|
||||
strategy = 'Terms'
|
||||
if not isinstance(paths, list):
|
||||
paths = []
|
||||
paths = [p.strip() for p in paths if p and isinstance(p, str)]
|
||||
if not paths:
|
||||
return jsonify({'success': False, 'error': '请提供至少一个文件路径或链接'}), 400
|
||||
user_id = session['user_id']
|
||||
urls = []
|
||||
for p in paths:
|
||||
if _is_url(p):
|
||||
urls.append(p)
|
||||
elif os.path.isfile(p):
|
||||
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||
if err:
|
||||
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||
urls.append(url)
|
||||
else:
|
||||
return jsonify({'success': False, 'error': f'无效路径或链接: {p[:80]}'}), 400
|
||||
desc_core = ', '.join(os.path.basename(urlparse(u).path or u) for u in urls)[:500] if urls else ''
|
||||
desc = f'[{strategy}] ' + (desc_core or '')
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO brand_crawl_tasks (user_id, file_paths, `desc`, strategy, status, task_type) VALUES (%s, %s, %s, %s, 'pending', %s)",
|
||||
(user_id, json.dumps(urls), desc or None, strategy, task_type)
|
||||
)
|
||||
task_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# threading.Thread(target=_background_brand_task, args=(task_id,), daemon=True).start()
|
||||
return jsonify({'success': True, 'task_id': task_id})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>')
|
||||
@login_required
|
||||
def api_brand_task_detail(task_id):
|
||||
"""获取单个任务详情(用于轮询状态)"""
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT id, file_paths, `desc`, strategy, status, result_paths, error_message,
|
||||
COALESCE(progress_current, 0) AS progress_current,
|
||||
COALESCE(progress_total, 0) AS progress_total,
|
||||
created_at, updated_at
|
||||
FROM brand_crawl_tasks WHERE id = %s AND user_id = %s""",
|
||||
(task_id, session['user_id'])
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return jsonify({'success': False, 'error': '任务不存在'}), 404
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'task': {
|
||||
'id': row['id'],
|
||||
'file_paths': _parse_json(row['file_paths'], []),
|
||||
'desc': (row.get('desc') or '').strip() or None,
|
||||
'strategy': (row.get('strategy') or 'Terms').strip() or 'Terms',
|
||||
'status': row.get('status') or 'pending',
|
||||
'result_paths': _parse_json(row['result_paths']),
|
||||
'error_message': (row.get('error_message') or '').strip() or None,
|
||||
'progress_current': int(row.get('progress_current') or 0),
|
||||
'progress_total': int(row.get('progress_total') or 0),
|
||||
'created_at': row['created_at'].strftime('%Y-%m-%d %H:%M') if row.get('created_at') else '',
|
||||
'updated_at': row['updated_at'].strftime('%Y-%m-%d %H:%M') if row.get('updated_at') else '',
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/line-progress')
|
||||
@login_required
|
||||
def api_brand_task_line_progress(task_id):
|
||||
"""
|
||||
获取任务的“当前文件行级进度”(当前行数/总行数)。
|
||||
该信息仅存放在内存字典 _task_line_progress 中,不写入数据库。
|
||||
"""
|
||||
try:
|
||||
with _task_line_progress_lock:
|
||||
info = _task_line_progress.get(task_id)
|
||||
if not info:
|
||||
return jsonify({'success': True, 'has_progress': False})
|
||||
# 为了避免暴露完整路径,只返回文件名
|
||||
file_name = os.path.basename(info.get('file_path') or '') if info.get('file_path') else ''
|
||||
resp = {
|
||||
'file_index': int(info.get('file_index') or 0),
|
||||
'file_total': int(info.get('file_total') or 0),
|
||||
'file_name': file_name,
|
||||
'current_line': int(info.get('current_line') or 0),
|
||||
'total_lines': int(info.get('total_lines') or 0),
|
||||
}
|
||||
return jsonify({'success': True, 'has_progress': True, 'info': resp})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/events')
|
||||
@login_required
|
||||
def api_brand_task_events(task_id):
|
||||
"""SSE:任务完成时后端主动推送事件,前端监听后隐藏进度条与取消按钮"""
|
||||
def _task_status_event():
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT status, error_message FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||
(task_id, session['user_id'])
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
st = (row.get('status') or '').lower()
|
||||
if st in ('success', 'failed', 'cancelled'):
|
||||
return {'status': st, 'error_message': (row.get('error_message') or '').strip() or None}
|
||||
return None
|
||||
|
||||
# 若任务已处于终态,直接返回一条事件后结束
|
||||
ev = _task_status_event()
|
||||
if ev is not None:
|
||||
def _one_shot():
|
||||
yield "data: " + json.dumps(ev, ensure_ascii=False) + "\n\n"
|
||||
return Response(
|
||||
_one_shot(),
|
||||
mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||
)
|
||||
|
||||
# 否则注册队列,等待后台任务完成时推送
|
||||
q = Queue()
|
||||
with _task_event_lock:
|
||||
_task_event_queues.setdefault(task_id, []).append(q)
|
||||
|
||||
def _stream():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = q.get(timeout=20)
|
||||
yield "data: " + json.dumps(event, ensure_ascii=False) + "\n\n"
|
||||
return
|
||||
except Empty:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
with _task_event_lock:
|
||||
lst = _task_event_queues.get(task_id, [])
|
||||
if q in lst:
|
||||
lst.remove(q)
|
||||
if not lst:
|
||||
_task_event_queues.pop(task_id, None)
|
||||
|
||||
return Response(
|
||||
_stream(),
|
||||
mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||
)
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>/cancel', methods=['POST'])
|
||||
@login_required
|
||||
def api_brand_task_cancel(task_id):
|
||||
"""取消正在执行或等待中的任务(pending/running 均可取消)"""
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE brand_crawl_tasks SET status = 'cancelled' WHERE id = %s AND user_id = %s AND status IN ('pending', 'running')",
|
||||
(task_id, session['user_id'])
|
||||
)
|
||||
n = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if n == 0:
|
||||
return jsonify({'success': False, 'error': '任务不存在或无法取消'}), 400
|
||||
_push_task_event(task_id, {'status': 'cancelled'})
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/tasks/<int:task_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_brand_task_delete(task_id):
|
||||
"""删除任务(仅限非 running 状态)"""
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM brand_crawl_tasks WHERE id = %s AND user_id = %s AND status != 'running'",
|
||||
(task_id, session['user_id'])
|
||||
)
|
||||
n = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if n == 0:
|
||||
return jsonify({'success': False, 'error': '任务不存在或正在执行中无法删除'}), 400
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@brand_bp.route('/api/brand/download/<int:task_id>')
|
||||
@login_required
|
||||
def api_brand_download(task_id):
|
||||
"""下载任务结果:优先使用 OSS zip_url 重定向;否则本地/单链接/多链接按原逻辑处理"""
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT result_paths FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||
(task_id, session['user_id'])
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row or not row.get('result_paths'):
|
||||
return jsonify({'success': False, 'error': '无结果可下载'}), 404
|
||||
raw = row['result_paths']
|
||||
if isinstance(raw, str):
|
||||
raw = _parse_json(raw)
|
||||
url_list, zip_url = _normalize_result_paths(raw)
|
||||
# 新格式:有 zip_url 直接重定向到 OSS
|
||||
if zip_url and _is_url(zip_url):
|
||||
return redirect(zip_url, code=302)
|
||||
|
||||
# 兼容旧格式:result_paths 可能为 list(本地路径或 url)
|
||||
if not url_list and isinstance(raw, list):
|
||||
url_list = raw
|
||||
local_paths = [p for p in url_list if isinstance(p, str) and not _is_url(p) and os.path.isfile(p)]
|
||||
url_paths = [p.strip() for p in url_list if isinstance(p, str) and _is_url(p)]
|
||||
|
||||
if len(url_paths) == 1 and not local_paths:
|
||||
return redirect(url_paths[0], code=302)
|
||||
|
||||
if len(local_paths) == 1 and not url_paths:
|
||||
return send_file(
|
||||
local_paths[0],
|
||||
as_attachment=True,
|
||||
download_name=os.path.basename(local_paths[0])
|
||||
)
|
||||
|
||||
if local_paths or url_paths:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in local_paths:
|
||||
zf.write(p, os.path.basename(p))
|
||||
for url in url_paths:
|
||||
try:
|
||||
import requests as req
|
||||
r = req.get(url, timeout=30, stream=True)
|
||||
r.raise_for_status()
|
||||
name = os.path.basename(urlparse(url).path) or ('file_%s' % (url_paths.index(url)))
|
||||
if not name or name == 'file_%s' % url_paths.index(url):
|
||||
name = 'download_%s' % url_paths.index(url)
|
||||
zf.writestr(name, r.content)
|
||||
except Exception:
|
||||
pass
|
||||
buf.seek(0)
|
||||
return send_file(
|
||||
buf,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name='brand_task_%s.zip' % task_id
|
||||
)
|
||||
|
||||
return jsonify({'success': False, 'error': '结果文件不存在或链接不可用'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
411
app/blueprints/image.py
Normal file
411
app/blueprints/image.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
图片生成蓝图:生成、历史、下载、拼接、版本
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import io
|
||||
import base64
|
||||
import tempfile
|
||||
import threading
|
||||
import subprocess
|
||||
import requests
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response, session, send_file
|
||||
from PIL import Image
|
||||
|
||||
from app_common import get_db, login_required, BASE_DIR
|
||||
from config import STITCH_WORKFLOW_ID,client_name
|
||||
|
||||
image_bp = Blueprint('image', __name__)
|
||||
|
||||
|
||||
def _load_image_from_url_or_data(url_or_data):
|
||||
"""从 http(s) URL 或 data URL 加载为 PIL Image,失败返回 None"""
|
||||
if not url_or_data or not isinstance(url_or_data, str):
|
||||
return None
|
||||
try:
|
||||
if url_or_data.startswith('data:'):
|
||||
m = re.match(r'data:image/[^;]+;base64,(.+)', url_or_data, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
raw = base64.b64decode(m.group(1).strip())
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
elif url_or_data.startswith(('http://', 'https://')):
|
||||
resp = requests.get(url_or_data, timeout=15)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
else:
|
||||
return None
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
return img
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _stitch_and_upload_long_image(urls):
|
||||
"""将多张图片 URL 先上传获取 file_id,再调用 workflow_run 拼接长图,返回 data.merged_image_url;失败返回 None。"""
|
||||
if not urls or not isinstance(urls, (list, tuple)):
|
||||
return None
|
||||
from coze import upload_file as coze_upload_file, workflow_run
|
||||
file_ids = []
|
||||
temp_paths = []
|
||||
try:
|
||||
for u in urls:
|
||||
img = _load_image_from_url_or_data(u)
|
||||
if img is None:
|
||||
continue
|
||||
fd, path = tempfile.mkstemp(suffix='.png')
|
||||
try:
|
||||
os.close(fd)
|
||||
img.save(path)
|
||||
temp_paths.append(path)
|
||||
resp = coze_upload_file(path)
|
||||
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
|
||||
file_ids.append(resp['data']['id'])
|
||||
except Exception:
|
||||
pass
|
||||
if not file_ids:
|
||||
return None
|
||||
parameters = {"images": [{"file_id": fid} for fid in file_ids]}
|
||||
resp = workflow_run(STITCH_WORKFLOW_ID, parameters, is_async=False)
|
||||
if resp.get('code') != 0:
|
||||
return None
|
||||
data = resp.get('data') or {}
|
||||
data = json.loads(data)
|
||||
merged_image_url = data.get('merged_image_url')
|
||||
if merged_image_url:
|
||||
return merged_image_url
|
||||
output_str = data.get('output') or ''
|
||||
if output_str:
|
||||
try:
|
||||
outer = json.loads(output_str)
|
||||
inner_str = outer.get('Output', '{}')
|
||||
inner = json.loads(inner_str)
|
||||
data_str = inner.get('data', '[]')
|
||||
inner_data = json.loads(data_str)
|
||||
merged_image_url = inner_data.get('merged_image_url')
|
||||
return merged_image_url
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
for p in temp_paths:
|
||||
try:
|
||||
os.unlink(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _sanitize_params_for_history(params):
|
||||
"""移除 base64 大字段及敏感字段,仅保留可存储的请求参数"""
|
||||
exclude = ('ref_images', 'proc_images', 'layout_image')
|
||||
out = {}
|
||||
for k, v in (params or {}).items():
|
||||
if k == 'api_key':
|
||||
continue
|
||||
if k in exclude:
|
||||
if isinstance(v, list):
|
||||
out[f'{k}_count'] = len(v)
|
||||
else:
|
||||
out[f'{k}_count'] = 1 if v else 0
|
||||
elif isinstance(v, (str, int, float, bool, type(None))):
|
||||
out[k] = v
|
||||
elif isinstance(v, list) and not v:
|
||||
out[k] = []
|
||||
elif isinstance(v, list) and isinstance(v[0], (str, int, float, bool)):
|
||||
out[k] = v
|
||||
else:
|
||||
out[k] = str(v)[:200] if v else None
|
||||
return out
|
||||
|
||||
|
||||
@image_bp.route('/api/generate', methods=['POST'])
|
||||
@login_required
|
||||
def api_generate():
|
||||
"""生成图片:调用 generate_api,上传原图到 OSS,保存历史记录"""
|
||||
try:
|
||||
params = request.get_json() or {}
|
||||
from generate_api import generate
|
||||
result = generate(params)
|
||||
if result.get('success') and result.get('urls'):
|
||||
long_image_url = result.get("long_image_url")
|
||||
result["long_image_url"] = long_image_url
|
||||
import json as _json
|
||||
history_id = None
|
||||
try:
|
||||
hid = params.get('history_id')
|
||||
if hid is not None:
|
||||
try:
|
||||
hid = int(hid)
|
||||
except (TypeError, ValueError):
|
||||
hid = None
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
if hid is not None and hid > 0:
|
||||
cur.execute(
|
||||
"SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s",
|
||||
(hid, session['user_id']),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
existing_urls = []
|
||||
if row and row.get('result_urls'):
|
||||
try:
|
||||
existing_urls = _json.loads(row['result_urls'])
|
||||
except Exception:
|
||||
existing_urls = []
|
||||
new_urls = result.get('urls') or []
|
||||
new_url = new_urls[0] if new_urls else None
|
||||
idx = params.get('history_index', 0)
|
||||
try:
|
||||
idx = int(idx)
|
||||
except (TypeError, ValueError):
|
||||
idx = 0
|
||||
if new_url:
|
||||
if not isinstance(existing_urls, list):
|
||||
existing_urls = []
|
||||
while len(existing_urls) <= idx:
|
||||
existing_urls.append(existing_urls[-1] if existing_urls else new_url)
|
||||
existing_urls[idx] = new_url
|
||||
merged_result_urls = existing_urls or new_urls
|
||||
cur.execute(
|
||||
"""UPDATE image_history SET panel_type=%s, original_urls=%s, params=%s, result_urls=%s
|
||||
WHERE id=%s AND user_id=%s""",
|
||||
(
|
||||
params.get('panel_type', ''),
|
||||
_json.dumps(result.get('original_urls') or []),
|
||||
_json.dumps(_sanitize_params_for_history(params)),
|
||||
_json.dumps(merged_result_urls),
|
||||
hid,
|
||||
session['user_id'],
|
||||
),
|
||||
)
|
||||
if cur.rowcount > 0:
|
||||
history_id = hid
|
||||
else:
|
||||
cur.execute(
|
||||
"""INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
(
|
||||
session['user_id'],
|
||||
params.get('panel_type', ''),
|
||||
_json.dumps(result.get('original_urls') or []),
|
||||
_json.dumps(_sanitize_params_for_history(params)),
|
||||
_json.dumps(result.get('urls') or []),
|
||||
long_image_url,
|
||||
),
|
||||
)
|
||||
history_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if history_id is not None:
|
||||
result['history_id'] = history_id
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'urls': [], 'error': str(e)})
|
||||
|
||||
|
||||
@image_bp.route('/api/version')
|
||||
def api_version():
|
||||
"""检测更新:请求 APP_UPDATE_URL 获取最新版本信息,与当前版本比较"""
|
||||
current_version = (os.environ.get('APP_VERSION', '1.0.0') or '1.0.0').strip()
|
||||
update_url = (os.environ.get('APP_UPDATE_URL', '') or '').strip()
|
||||
result = {
|
||||
'version': current_version,
|
||||
'desc': '',
|
||||
'url': '',
|
||||
'has_update': False,
|
||||
'latest_version': current_version,
|
||||
'file_url': '',
|
||||
}
|
||||
if not update_url:
|
||||
return jsonify(result)
|
||||
try:
|
||||
resp = requests.get(update_url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json() or {}
|
||||
latest_version = (data.get('version') or '').strip()
|
||||
file_url = (data.get('file_url') or '').strip()
|
||||
result['latest_version'] = latest_version
|
||||
result['file_url'] = file_url
|
||||
result['url'] = file_url
|
||||
# 版本不一致则视为有更新
|
||||
if latest_version and latest_version != current_version:
|
||||
result['has_update'] = True
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _run_update_and_exit(zip_path, target_dir):
|
||||
"""在后台延迟后启动 update.exe(脱离当前进程),然后退出当前程序"""
|
||||
def _do():
|
||||
import time
|
||||
time.sleep(1.5) # 确保 HTTP 响应已发送
|
||||
# exe_dir = target_dir
|
||||
# exe_dir = "D:\\pack\\nanri\\update_exe\\main.dist"
|
||||
exe_dir = os.path.join(BASE_DIR,"update")
|
||||
update_exe = os.path.join(exe_dir, 'update.exe')
|
||||
if not os.path.isfile(update_exe):
|
||||
return
|
||||
try:
|
||||
creationflags = 0
|
||||
if sys.platform == 'win32':
|
||||
creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
subprocess.Popen(
|
||||
[update_exe, '--zip', zip_path, '--target', target_dir, '--process',client_name],
|
||||
cwd=exe_dir,
|
||||
creationflags=creationflags,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
t = threading.Thread(target=_do, daemon=False)
|
||||
t.start()
|
||||
|
||||
|
||||
@image_bp.route('/api/update/do', methods=['POST'])
|
||||
def api_update_do():
|
||||
"""执行更新:下载 zip 到 tmp,启动 update.exe 后退出程序"""
|
||||
data = request.get_json() or {}
|
||||
file_url = (data.get('file_url') or '').strip()
|
||||
if not file_url:
|
||||
return jsonify({'success': False, 'error': '缺少 file_url'}), 400
|
||||
parsed = urlparse(file_url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return jsonify({'success': False, 'error': '无效的下载地址'}), 400
|
||||
tmp_dir = os.path.join(BASE_DIR, 'tmp')
|
||||
try:
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': f'创建 tmp 目录失败: {e}'}), 500
|
||||
# 使用 URL 中的文件名或默认版本名
|
||||
filename = os.path.basename(parsed.path) or 'update.zip'
|
||||
zip_path = os.path.join(tmp_dir, filename)
|
||||
try:
|
||||
resp = requests.get(file_url, timeout=300, stream=True)
|
||||
resp.raise_for_status()
|
||||
with open(zip_path, 'wb') as f:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
except requests.RequestException as e:
|
||||
return jsonify({'success': False, 'error': f'下载失败: {e}'}), 502
|
||||
_run_update_and_exit(zip_path, BASE_DIR)
|
||||
return jsonify({'success': True, 'message': '更新已启动,程序即将退出'})
|
||||
|
||||
|
||||
@image_bp.route('/api/download')
|
||||
@login_required
|
||||
def api_download():
|
||||
"""代理下载图片,解决跨域 fetch 无法下载的问题"""
|
||||
url = request.args.get('url', '').strip()
|
||||
filename = request.args.get('filename', 'image.png')
|
||||
if not url:
|
||||
return jsonify({'success': False, 'error': '缺少 url 参数'}), 400
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return jsonify({'success': False, 'error': '仅支持 http/https 链接'}), 400
|
||||
try:
|
||||
resp = requests.get(url, timeout=30, stream=True)
|
||||
resp.raise_for_status()
|
||||
content_type = resp.headers.get('Content-Type', 'image/png')
|
||||
encoded = quote(filename, safe='')
|
||||
disposition = f"attachment; filename*=UTF-8''{encoded}"
|
||||
return Response(
|
||||
resp.iter_content(chunk_size=8192),
|
||||
mimetype=content_type,
|
||||
headers={'Content-Disposition': disposition}
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 502
|
||||
|
||||
|
||||
@image_bp.route('/api/stitch/save', methods=['POST'])
|
||||
@login_required
|
||||
def api_stitch_save():
|
||||
"""手动拼接:接收图片 URL 列表(支持 http 或 data URL),拼接并上传,返回长图 URL"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
urls = data.get('urls')
|
||||
if not urls or not isinstance(urls, list):
|
||||
return jsonify({'success': False, 'error': '请提供 urls 数组'}), 400
|
||||
urls = [u for u in urls if u and isinstance(u, str)]
|
||||
if not urls:
|
||||
return jsonify({'success': False, 'error': '没有有效的图片'}), 400
|
||||
long_image_url = _stitch_and_upload_long_image(urls)
|
||||
if not long_image_url:
|
||||
return jsonify({'success': False, 'error': '拼接或上传失败'}), 500
|
||||
return jsonify({'success': True, 'long_image_url': long_image_url})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@image_bp.route('/api/history')
|
||||
@login_required
|
||||
def api_history():
|
||||
"""分页获取当前用户的历史图库,支持按 panel_type 栏目筛选"""
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
page_size = min(50, max(10, int(request.args.get('page_size', 20))))
|
||||
panel_type = (request.args.get('panel_type') or '').strip()
|
||||
offset = (page - 1) * page_size
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
where_user = "user_id = %s"
|
||||
params_where = [session['user_id']]
|
||||
if panel_type:
|
||||
where_user += " AND panel_type = %s"
|
||||
params_where.append(panel_type)
|
||||
cur.execute(
|
||||
"""SELECT id, created_at, panel_type, original_urls, params, result_urls, long_image_url
|
||||
FROM image_history WHERE """ + where_user + """ ORDER BY created_at DESC LIMIT %s OFFSET %s""",
|
||||
params_where + [page_size, offset],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT COUNT(*) as total FROM image_history WHERE " + where_user, params_where)
|
||||
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'],
|
||||
'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)})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
95
app/blueprints/main.py
Normal file
95
app/blueprints/main.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo
|
||||
"""
|
||||
import os
|
||||
from flask import Blueprint, send_file
|
||||
|
||||
from app_common import (
|
||||
get_db,
|
||||
_render_html,
|
||||
_is_session_user_valid,
|
||||
login_required,
|
||||
admin_required,
|
||||
STATIC_DIR,
|
||||
BASE_DIR,
|
||||
ASSETS_DIR
|
||||
)
|
||||
from flask import redirect, url_for, session
|
||||
|
||||
from config import base_url,version
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
def index():
|
||||
if session.get('user_id') and _is_session_user_valid():
|
||||
return redirect(url_for('main.home'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
|
||||
@main_bp.route('/home')
|
||||
@login_required
|
||||
def home():
|
||||
try:
|
||||
conn = get_db()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version)
|
||||
except Exception:
|
||||
return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version)
|
||||
|
||||
|
||||
@main_bp.route('/image')
|
||||
@login_required
|
||||
def wb():
|
||||
return _render_html('index.html')
|
||||
|
||||
|
||||
@main_bp.route('/brand')
|
||||
@login_required
|
||||
def brand_page():
|
||||
return _render_html('brand.html')
|
||||
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/static/<path:filename>')
|
||||
def serve_static(filename):
|
||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
|
||||
static_abs = os.path.abspath(STATIC_DIR)
|
||||
file_abs = os.path.abspath(filepath)
|
||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
|
||||
|
||||
@main_bp.route('/assets/<path:filename>')
|
||||
def serve_assets(filename):
|
||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||
filepath = os.path.normpath(os.path.join(ASSETS_DIR, filename))
|
||||
static_abs = os.path.abspath(ASSETS_DIR)
|
||||
file_abs = os.path.abspath(filepath)
|
||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
|
||||
|
||||
@main_bp.route('/new_web_source/<path:filename>')
|
||||
def serve_new_web_source(filename):
|
||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||
filepath = os.path.normpath(os.path.join("new_web_source", filename))
|
||||
static_abs = os.path.abspath("new_web_source")
|
||||
file_abs = os.path.abspath(filepath)
|
||||
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||
return '', 404
|
||||
return send_file(file_abs, as_attachment=False)
|
||||
|
||||
|
||||
|
||||
@main_bp.route('/logo.jpg', methods=['GET'])
|
||||
def get_logo_image():
|
||||
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')
|
||||
Reference in New Issue
Block a user