视频任务管理更新

This commit is contained in:
super
2026-07-19 22:23:26 +08:00
parent 69784b8d32
commit cbde22ec79
62 changed files with 2164 additions and 280 deletions

View File

@@ -4,13 +4,15 @@
import json
import os
import re
import tempfile
import threading
import zipfile
from datetime import datetime
from urllib.parse import quote
import requests
from requests.adapters import HTTPAdapter
from flask import Blueprint, request, jsonify, session, current_app, g, Response
from flask import Blueprint, request, jsonify, session, current_app, g, Response, send_file
import pymysql
from werkzeug.security import generate_password_hash
@@ -52,6 +54,8 @@ _column_sort_schema_checked = False
_column_sort_schema_lock = threading.Lock()
_product_category_schema_checked = False
_product_category_schema_lock = threading.Lock()
IMAGE_VIDEO_PERMISSION_KEY = 'admin_image_video_tasks'
IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_task_data'
ADMIN_MENU_ACCESS_CONFIG = {
'dedupe-total-data': {
@@ -288,10 +292,12 @@ def _image_video_admin_item(row, include_json=False):
'task_id': row.get('id'),
'user_id': row.get('user_id'),
'username': row.get('username') or '',
'group_name': row.get('group_name') or '',
'mode': _image_video_mode(row.get('request_json')),
'status': row.get('status') or '',
'coze_status': row.get('coze_status') or '',
'coze_execute_id': row.get('coze_execute_id') or '',
'videos': videos,
'video_url': videos[0]['display_url'] if videos else '',
'debug_url': row.get('debug_url') or '',
'archive_status': row.get('archive_status') or '',
@@ -302,7 +308,6 @@ def _image_video_admin_item(row, include_json=False):
}
if include_json:
item.update({
'videos': videos,
'error_message': row.get('error_message') or '',
'archived_at': _format_admin_datetime(row.get('archived_at')),
'request': _mask_image_video_secrets(_parse_json_value(row.get('request_json'), {})),
@@ -625,17 +630,17 @@ def _load_current_admin_menu_items():
role, current_row = get_current_admin_role()
if not role or not current_row:
return None, None, None, (jsonify({'success': False, 'error': '需要管理员权限'}), 403)
if role == 'super_admin':
items = [
_format_permission_item(item)
for item in _load_local_columns('admin')
if (item.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY
]
return role, current_row, items, None
user_id = _get_current_admin_id(current_row)
if not user_id:
return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400)
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/permission-users/{user_id}/column-permissions',
params={'menuType': 'admin'},
)
if error_response is not None:
return role, current_row, None, (error_response, status)
items = [_format_permission_item(item) for item in (result.get('data') or [])]
items = [_format_permission_item(item) for item in _get_local_user_column_permission_items(user_id, 'admin')]
return role, current_row, items, None
@@ -643,17 +648,17 @@ def _load_current_backend_menu_items():
role, current_row = get_current_admin_role()
if not role or not current_row:
return None, None, None, (jsonify({'success': False, 'error': '需要登录'}), 403)
if role == 'super_admin':
items = [
_format_permission_item(item)
for item in _load_local_columns('admin')
if (item.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY
]
return role, current_row, items, None
user_id = _get_current_admin_id(current_row)
if not user_id:
return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400)
result, error_response, status = _proxy_backend_java(
'GET',
f'/api/admin/permission-users/{user_id}/column-permissions',
params={'menuType': 'admin'},
)
if error_response is not None:
return role, current_row, None, (error_response, status)
items = [_format_permission_item(item) for item in (result.get('data') or [])]
items = [_format_permission_item(item) for item in _get_local_user_column_permission_items(user_id, 'admin')]
return role, current_row, items, None
@@ -676,6 +681,30 @@ def _ensure_backend_menu_access(*menu_names):
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
def _ensure_image_video_data_access():
role, current_row = get_current_admin_role()
if role == 'super_admin':
return role, current_row, None
if not role or not current_row:
return role, current_row, (jsonify({'success': False, 'error': '需要登录'}), 403)
conn = get_db()
try:
with conn.cursor() as cur:
data_column_id = _image_video_permission_column_id(cur)
cur.execute(
"SELECT 1 FROM user_column_permission ucp "
"WHERE ucp.user_id = %s AND ucp.column_id = %s LIMIT 1",
(int(current_row['id']), data_column_id),
)
granted = cur.fetchone()
conn.commit()
finally:
conn.close()
if granted:
return role, current_row, None
return role, current_row, (jsonify({'success': False, 'error': '无权查看视频任务数据'}), 403)
def _ensure_product_category_access():
role, current_row, items, denied = _load_current_backend_menu_items()
if denied:
@@ -781,7 +810,8 @@ def list_users():
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
can_view_all_users = role == 'super_admin' or (current_row and (current_row.get('username') or '').strip().lower() == 'admin')
# 用户范围只由角色决定,用户名不能绕过权限模型。
can_view_all_users = role == 'super_admin'
can_view_child_users = role == 'admin' and not can_view_all_users
if not can_view_all_users:
created_by_id = None
@@ -935,10 +965,23 @@ def create_user():
(username, pwd_hash, is_admin, want_role, want_created_by),
)
new_uid = cur.lastrowid
_set_user_column_permissions(cur, new_uid, column_ids)
_validate_admin_granted_columns(cur, role, current_row, column_ids)
_set_user_column_permissions(
cur,
new_uid,
column_ids,
preserve_column_keys=(IMAGE_VIDEO_DATA_PERMISSION_KEY,),
)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '用户创建成功'})
except PermissionError as exc:
try:
conn.rollback()
conn.close()
except Exception:
pass
return jsonify({'success': False, 'error': str(exc)}), 403
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'})
except Exception as e:
@@ -994,10 +1037,23 @@ def update_user(uid):
(is_admin, want_role, uid),
)
if 'column_ids' in data:
_set_user_column_permissions(cur, uid, data.get('column_ids'))
_validate_admin_granted_columns(cur, role, current_row, data.get('column_ids'))
_set_user_column_permissions(
cur,
uid,
data.get('column_ids'),
preserve_column_keys=(IMAGE_VIDEO_DATA_PERMISSION_KEY,),
)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '更新成功'})
except PermissionError as exc:
try:
conn.rollback()
conn.close()
except Exception:
pass
return jsonify({'success': False, 'error': str(exc)}), 403
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@@ -1118,7 +1174,15 @@ _IMAGE_VIDEO_ADMIN_COLUMNS = """
t.id, t.user_id, t.status, t.request_json, t.submit_response_json, t.result_json,
t.video_urls_json, t.debug_url, t.archived_videos_json, t.archive_status,
t.archive_error, t.archive_attempt_count, t.archived_at, t.error_message,
t.coze_execute_id, t.coze_status, t.submitted_at, t.completed_at, u.username
t.coze_execute_id, t.coze_status, t.submitted_at, t.completed_at, u.username,
COALESCE((
SELECT GROUP_CONCAT(DISTINCT g.group_name ORDER BY g.id SEPARATOR '')
FROM biz_shop_manage_group g
LEFT JOIN biz_shop_manage_group_member gm ON gm.group_id = g.id
WHERE g.created_by_id = t.user_id
OR g.user_id = t.user_id
OR gm.user_id = t.user_id
), '') AS group_name
"""
@@ -1132,10 +1196,220 @@ def _parse_admin_datetime_arg(name):
raise ValueError(f'{name} 时间格式无效') from exc
def _image_video_permission_column_id(cur):
cur.execute("SELECT id FROM columns WHERE column_key = %s LIMIT 1", (IMAGE_VIDEO_DATA_PERMISSION_KEY,))
row = cur.fetchone()
if not row or row.get('id') is None:
# 兼容 V74 尚未执行的旧库:创建数据权限栏目,并继承已有菜单授权。
cur.execute(
"INSERT INTO columns (name, column_key, menu_type, route_path, sort_order) "
"SELECT %s, %s, 'internal', 'image-video-task-data', 0 "
"WHERE NOT EXISTS (SELECT 1 FROM columns WHERE column_key = %s)",
('视频任务数据查看', IMAGE_VIDEO_DATA_PERMISSION_KEY, IMAGE_VIDEO_DATA_PERMISSION_KEY),
)
cur.execute("SELECT id FROM columns WHERE column_key = %s LIMIT 1", (IMAGE_VIDEO_DATA_PERMISSION_KEY,))
row = cur.fetchone()
if not row or row.get('id') is None:
raise ValueError('视频任务数据权限栏目不存在')
cur.execute(
"INSERT IGNORE INTO user_column_permission (user_id, column_id) "
"SELECT old_perm.user_id, %s FROM user_column_permission old_perm "
"INNER JOIN columns old_col ON old_col.id = old_perm.column_id "
"WHERE old_col.column_key = %s",
(int(row['id']), IMAGE_VIDEO_PERMISSION_KEY),
)
return int(row['id'])
def _is_stored_super_admin_sql(alias='u'):
return (
f"(LOWER(COALESCE({alias}.role, '')) = 'super_admin' OR "
f"((COALESCE({alias}.role, '') = '') AND {alias}.is_admin = 1 AND {alias}.created_by_id IS NULL))"
)
@admin_api.route('/image-video-task-permissions', methods=['GET', 'PUT'])
@login_required
def manage_image_video_task_permissions():
role, _ = get_current_admin_role()
if role != 'super_admin':
return jsonify({'success': False, 'error': '仅超级管理员可以配置视频任务权限'}), 403
conn = get_db()
try:
with conn.cursor() as cur:
column_id = _image_video_permission_column_id(cur)
if request.method == 'GET':
cur.execute(
"SELECT u.id, u.username, u.role, u.is_admin, u.created_by_id, "
"CASE WHEN ucp.user_id IS NULL THEN 0 ELSE 1 END AS granted "
"FROM users u LEFT JOIN user_column_permission ucp "
"ON ucp.user_id = u.id AND ucp.column_id = %s "
f"WHERE NOT {_is_stored_super_admin_sql('u')} "
"ORDER BY u.username ASC, u.id ASC",
(column_id,),
)
items = []
for row in cur.fetchall():
stored_role = (row.get('role') or '').strip().lower()
effective_role = stored_role or ('admin' if row.get('is_admin') else 'normal')
items.append({
'id': int(row['id']),
'username': row.get('username') or '',
'role': effective_role,
'granted': bool(row.get('granted')),
})
return jsonify({'success': True, 'items': items})
data = request.get_json(silent=True) or {}
raw_user_ids = data.get('user_ids')
if not isinstance(raw_user_ids, list):
return jsonify({'success': False, 'error': 'user_ids 必须是数组'}), 400
try:
user_ids = sorted({int(value) for value in raw_user_ids if not isinstance(value, bool) and int(value) > 0})
except (TypeError, ValueError):
return jsonify({'success': False, 'error': 'user_ids 包含无效用户 ID'}), 400
if len(user_ids) != len(raw_user_ids):
return jsonify({'success': False, 'error': 'user_ids 包含重复或无效用户 ID'}), 400
if user_ids:
placeholders = ','.join(['%s'] * len(user_ids))
cur.execute(
f"SELECT id FROM users u WHERE id IN ({placeholders}) AND NOT {_is_stored_super_admin_sql('u')}",
tuple(user_ids),
)
valid_ids = {int(row['id']) for row in cur.fetchall()}
if valid_ids != set(user_ids):
return jsonify({'success': False, 'error': '包含不存在或不可授权的用户'}), 400
cur.execute(
"DELETE ucp FROM user_column_permission ucp "
"INNER JOIN users u ON u.id = ucp.user_id "
f"WHERE ucp.column_id = %s AND NOT {_is_stored_super_admin_sql('u')}",
(column_id,),
)
for user_id in user_ids:
cur.execute(
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
(user_id, column_id),
)
conn.commit()
return jsonify({'success': True, 'granted_count': len(user_ids), 'msg': '视频任务权限已更新'})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
if request.method == 'PUT':
conn.rollback()
return jsonify({'success': False, 'error': str(exc)}), 500
finally:
conn.close()
@admin_api.route('/image-video-tasks/download-zip', methods=['POST'])
@login_required
def download_image_video_tasks_zip():
_, _, denied = _ensure_backend_menu_access('image-video-tasks')
if not denied:
_, _, denied = _ensure_image_video_data_access()
if denied:
return denied
data = request.get_json(silent=True) or {}
raw_items = data.get('items')
if not isinstance(raw_items, list) or not raw_items:
return jsonify({'success': False, 'error': '请至少选择一个视频'}), 400
if len(raw_items) > 100:
return jsonify({'success': False, 'error': '单次最多打包 100 个视频'}), 400
selections = []
seen = set()
try:
for item in raw_items:
if not isinstance(item, dict):
raise ValueError
task_id = int(item.get('task_id'))
video_index = int(item.get('video_index'))
if task_id <= 0 or video_index < 0:
raise ValueError
key = (task_id, video_index)
if key not in seen:
seen.add(key)
selections.append(key)
except (TypeError, ValueError):
return jsonify({'success': False, 'error': '视频选择参数无效'}), 400
task_ids = sorted({task_id for task_id, _ in selections})
conn = get_db()
try:
with conn.cursor() as cur:
placeholders = ','.join(['%s'] * len(task_ids))
cur.execute(
'SELECT ' + _IMAGE_VIDEO_ADMIN_COLUMNS +
' FROM biz_image_video_async_task t LEFT JOIN users u ON u.id = t.user_id '
f"WHERE t.task_type = 'IMAGE_VIDEO_WORKFLOW' "
f"AND t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY) "
f"AND t.id IN ({placeholders})",
tuple(task_ids),
)
rows = cur.fetchall()
finally:
conn.close()
tasks = {int(row['id']): row for row in rows}
archive = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024, mode='w+b')
errors = []
file_count = 0
try:
with zipfile.ZipFile(archive, mode='w', compression=zipfile.ZIP_STORED, allowZip64=True) as output_zip:
for task_id, video_index in selections:
row = tasks.get(task_id)
videos = _image_video_urls(row) if row else []
if video_index >= len(videos) or not videos[video_index].get('display_url'):
errors.append(f'task-{task_id}-video-{video_index + 1}: 视频地址不存在')
continue
url = videos[video_index]['display_url']
extension_match = re.search(r'\.([a-z0-9]{2,5})(?:[?#]|$)', url, re.IGNORECASE)
extension = extension_match.group(1).lower() if extension_match else 'mp4'
filename = f'task-{task_id}-video-{video_index + 1}.{extension}'
remote_response = None
try:
remote_response = requests.get(url, stream=True, timeout=(10, 120))
remote_response.raise_for_status()
with output_zip.open(filename, mode='w', force_zip64=True) as target:
for chunk in remote_response.iter_content(chunk_size=1024 * 1024):
if chunk:
target.write(chunk)
file_count += 1
except requests.RequestException as exc:
errors.append(f'{filename}: 下载失败 ({exc})')
finally:
if remote_response is not None:
remote_response.close()
if errors:
output_zip.writestr('download-errors.txt', '\n'.join(errors).encode('utf-8'))
archive.seek(0)
response = send_file(
archive,
mimetype='application/zip',
as_attachment=True,
download_name=f"video-tasks-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip",
max_age=0,
)
response.headers['X-Archive-File-Count'] = str(file_count)
response.headers['X-Archive-Error-Count'] = str(len(errors))
response.call_on_close(archive.close)
return response
except Exception:
archive.close()
raise
@admin_api.route('/image-video-tasks')
@admin_required
@login_required
def list_image_video_tasks():
role, current_row, denied = _ensure_admin_menu_access('image-video-tasks')
_, _, denied = _ensure_backend_menu_access('image-video-tasks')
if not denied:
_, _, denied = _ensure_image_video_data_access()
if denied:
return denied
try:
@@ -1148,8 +1422,11 @@ def list_image_video_tasks():
submitted_from = _parse_admin_datetime_arg('submitted_from')
submitted_to = _parse_admin_datetime_arg('submitted_to')
access_sql, params = _image_video_access_sql(role, current_row)
conditions = [access_sql, "t.task_type = 'IMAGE_VIDEO_WORKFLOW'"]
params = []
conditions = [
"t.task_type = 'IMAGE_VIDEO_WORKFLOW'",
"t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)",
]
if user_id:
conditions.append('t.user_id = %s')
params.append(user_id)
@@ -1203,14 +1480,19 @@ def list_image_video_tasks():
@admin_api.route('/image-video-tasks/<int:task_id>')
@admin_required
@login_required
def get_image_video_task(task_id):
role, current_row, denied = _ensure_admin_menu_access('image-video-tasks')
_, _, denied = _ensure_backend_menu_access('image-video-tasks')
if not denied:
_, _, denied = _ensure_image_video_data_access()
if denied:
return denied
access_sql, params = _image_video_access_sql(role, current_row)
conditions = [access_sql, "t.task_type = 'IMAGE_VIDEO_WORKFLOW'", 't.id = %s']
params.append(task_id)
conditions = [
"t.task_type = 'IMAGE_VIDEO_WORKFLOW'",
"t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)",
't.id = %s',
]
params = [task_id]
conn = get_db()
try:
with conn.cursor() as cur:
@@ -1248,6 +1530,7 @@ def list_columns():
'created_at': str(row.get('created_at') or '')[:16].replace('T', ' '),
}
for row in local_rows
if (row.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY
]
return jsonify({'success': True, 'items': items})
@@ -1405,16 +1688,50 @@ def get_user_column_permissions(uid):
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in items]})
def _set_user_column_permissions(cur, user_id, column_ids):
def _set_user_column_permissions(cur, user_id, column_ids, preserve_column_keys=()):
"""设置用户栏目权限先删后插。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:
requested_ids = {int(x) for x in (column_ids or []) if x}
preserved_ids = set()
if preserve_column_keys:
placeholders = ','.join(['%s'] * len(preserve_column_keys))
cur.execute(
f"SELECT id FROM columns WHERE column_key IN ({placeholders})",
tuple(preserve_column_keys),
)
protected_ids = {int(row['id']) for row in cur.fetchall() if row.get('id') is not None}
requested_ids.difference_update(protected_ids)
if protected_ids:
id_placeholders = ','.join(['%s'] * len(protected_ids))
cur.execute(
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
(user_id, cid),
f"SELECT column_id FROM user_column_permission WHERE user_id = %s AND column_id IN ({id_placeholders})",
(user_id, *sorted(protected_ids)),
)
preserved_ids = {int(row['column_id']) for row in cur.fetchall() if row.get('column_id') is not None}
cur.execute("DELETE FROM user_column_permission WHERE user_id = %s", (user_id,))
for cid in sorted(requested_ids | preserved_ids):
cur.execute(
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
(user_id, cid),
)
def _validate_admin_granted_columns(cur, role, current_row, column_ids):
"""普通管理员只能把自己已拥有的栏目授予其直接管理的普通用户。"""
if role != 'admin' or not current_row or not column_ids:
return
requested_ids = {int(value) for value in column_ids if str(value).isdigit() and int(value) > 0}
if not requested_ids:
return
placeholders = ','.join(['%s'] * len(requested_ids))
cur.execute(
f"SELECT column_id FROM user_column_permission "
f"WHERE user_id = %s AND column_id IN ({placeholders})",
(int(current_row['id']), *sorted(requested_ids)),
)
owned_ids = {int(row['column_id']) for row in cur.fetchall() if row.get('column_id') is not None}
denied_ids = requested_ids - owned_ids
if denied_ids:
raise PermissionError('普通管理员只能分配自己已有的菜单权限')
# ---------- 版本管理web_config ----------