feat: Implement core application structure with new services, blueprints, templates, and database migrations.
This commit is contained in:
parent
1cc3d5e37a
commit
f548e8d6ab
70
README.md
Normal file
70
README.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# AI 视界 (AI Vision) 项目指南
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. 环境准备
|
||||||
|
确保已安装 Python 3.8+ 和 PostgreSQL / Redis。
|
||||||
|
|
||||||
|
**激活虚拟环境** (推荐)
|
||||||
|
```bash
|
||||||
|
# Windows (PowerShell)
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
|
||||||
|
# Linux / Mac
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
**安装依赖**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动服务
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
服务默认运行在 `http://127.0.0.1:5000`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 常用维护命令
|
||||||
|
|
||||||
|
### 数据库管理 (推荐)
|
||||||
|
本项目内置了自动化数据库管理工具 `db_manager.py`,用于处理模型变更和迁移。
|
||||||
|
|
||||||
|
**一键自动同步 (最常用)**
|
||||||
|
当您修改了 `models.py` 中的表结构后,运行此命令自动完成迁移:
|
||||||
|
```bash
|
||||||
|
python db_manager.py sync
|
||||||
|
```
|
||||||
|
|
||||||
|
**分步操作**
|
||||||
|
如果您需要更精细的控制:
|
||||||
|
|
||||||
|
* **初始化环境** (仅首次): `python db_manager.py init`
|
||||||
|
* **生成迁移脚本**: `python db_manager.py make "修改说明"`
|
||||||
|
* **执行数据库变更**: `python db_manager.py up`
|
||||||
|
|
||||||
|
### 系统配置更新
|
||||||
|
AI 模型、提示词模板等配置已移至数据库的 `system_dicts` 表中。
|
||||||
|
* 请登录 **Web 后台管理界面** (`/rbac`) 进行可视化的添加和修改。
|
||||||
|
* 或直接操作数据库更新 `system_dicts` 表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 目录结构说明
|
||||||
|
|
||||||
|
* `app.py`: 应用入口
|
||||||
|
* `config.py`: 配置文件
|
||||||
|
* `models.py`: 数据库模型定义
|
||||||
|
* `blueprints/`: 路由蓝图 (API 接口)
|
||||||
|
* `api.py`: 核心业务接口 (Controller)
|
||||||
|
* `admin.py`: 后台管理接口
|
||||||
|
* `auth.py`: 认证接口
|
||||||
|
* `services/`: 业务逻辑层 (Service)
|
||||||
|
* `task_service.py`: 异步任务处理 (生图/视频)
|
||||||
|
* `generation_service.py`: 生成请求验证与计费
|
||||||
|
* `system_service.py`: 系统配置与通知
|
||||||
|
* `history_service.py`: 历史记录查询
|
||||||
|
* `templates/`: 前端 HTML 模板
|
||||||
|
* `static/`: 静态资源 (JS/CSS)
|
||||||
2
app.py
2
app.py
@ -63,7 +63,7 @@ def create_app():
|
|||||||
'path': log.path,
|
'path': log.path,
|
||||||
'method': log.method,
|
'method': log.method,
|
||||||
'user_agent': log.user_agent,
|
'user_agent': log.user_agent,
|
||||||
'created_at': log.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
'created_at': log.created_at_bj.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
} for log in logs])
|
} for log in logs])
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from extensions import db
|
from extensions import db
|
||||||
from models import User, Role, Permission, SystemDict, SystemNotification, Order
|
from models import User, Role, Permission, SystemDict, SystemNotification, Order, to_bj_time
|
||||||
from middlewares.auth import permission_required
|
from middlewares.auth import permission_required
|
||||||
from services.logger import system_logger
|
from services.logger import system_logger
|
||||||
|
|
||||||
@ -10,7 +11,7 @@ admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin')
|
|||||||
@admin_bp.route('/roles', methods=['GET'])
|
@admin_bp.route('/roles', methods=['GET'])
|
||||||
@permission_required('manage_rbac')
|
@permission_required('manage_rbac')
|
||||||
def get_roles():
|
def get_roles():
|
||||||
roles = Role.query.all()
|
roles = Role.query.order_by(Role.id).all()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"roles": [{
|
"roles": [{
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
@ -63,7 +64,7 @@ def delete_role():
|
|||||||
@admin_bp.route('/permissions', methods=['GET'])
|
@admin_bp.route('/permissions', methods=['GET'])
|
||||||
@permission_required('manage_rbac')
|
@permission_required('manage_rbac')
|
||||||
def get_permissions():
|
def get_permissions():
|
||||||
perms = Permission.query.all()
|
perms = Permission.query.order_by(Permission.id).all()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"permissions": [{"name": p.name, "description": p.description} for p in perms]
|
"permissions": [{"name": p.name, "description": p.description} for p in perms]
|
||||||
})
|
})
|
||||||
@ -72,13 +73,29 @@ def get_permissions():
|
|||||||
@admin_bp.route('/users', methods=['GET'])
|
@admin_bp.route('/users', methods=['GET'])
|
||||||
@permission_required('manage_users')
|
@permission_required('manage_users')
|
||||||
def get_users():
|
def get_users():
|
||||||
users = User.query.all()
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 20, type=int)
|
||||||
|
search = request.args.get('q') # 搜索关键字 (手机号)
|
||||||
|
|
||||||
|
query = User.query
|
||||||
|
if search:
|
||||||
|
query = query.filter(User.phone.like(f"%{search}%"))
|
||||||
|
|
||||||
|
pagination = query.order_by(User.id.asc()).paginate(
|
||||||
|
page=page, per_page=per_page, error_out=False
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"users": [{
|
"users": [{
|
||||||
"id": u.id,
|
"id": u.id,
|
||||||
"phone": u.phone,
|
"phone": u.phone,
|
||||||
"role": u.role.name if u.role else "未分配"
|
"role": u.role.name if u.role else "未分配",
|
||||||
} for u in users]
|
"role_id": u.role.id if u.role else None,
|
||||||
|
"is_banned": u.is_banned
|
||||||
|
} for u in pagination.items],
|
||||||
|
"total": pagination.total,
|
||||||
|
"pages": pagination.pages,
|
||||||
|
"current_page": pagination.page
|
||||||
})
|
})
|
||||||
|
|
||||||
@admin_bp.route('/users/assign', methods=['POST'])
|
@admin_bp.route('/users/assign', methods=['POST'])
|
||||||
@ -94,6 +111,21 @@ def assign_role():
|
|||||||
return jsonify({"message": "角色分配成功"})
|
return jsonify({"message": "角色分配成功"})
|
||||||
return jsonify({"error": "用户或角色不存在"}), 404
|
return jsonify({"error": "用户或角色不存在"}), 404
|
||||||
|
|
||||||
|
@admin_bp.route('/users/toggle_ban', methods=['POST'])
|
||||||
|
@permission_required('manage_users')
|
||||||
|
def toggle_ban():
|
||||||
|
data = request.json
|
||||||
|
user = db.session.get(User, data['user_id'])
|
||||||
|
if user:
|
||||||
|
if user.role and user.role.name == '超级管理员':
|
||||||
|
return jsonify({"error": "不能封禁超级管理员"}), 400
|
||||||
|
user.is_banned = not user.is_banned
|
||||||
|
db.session.commit()
|
||||||
|
status = "封禁" if user.is_banned else "解封"
|
||||||
|
system_logger.warning(f"管理员{status}了用户: {user.phone}")
|
||||||
|
return jsonify({"message": f"用户已{status}"})
|
||||||
|
return jsonify({"error": "用户不存在"}), 404
|
||||||
|
|
||||||
# --- 字典管理 ---
|
# --- 字典管理 ---
|
||||||
@admin_bp.route('/dict_types', methods=['GET'])
|
@admin_bp.route('/dict_types', methods=['GET'])
|
||||||
@permission_required('manage_dicts')
|
@permission_required('manage_dicts')
|
||||||
@ -102,7 +134,11 @@ def get_dict_types():
|
|||||||
counts = dict(db.session.query(SystemDict.dict_type, db.func.count(SystemDict.id))\
|
counts = dict(db.session.query(SystemDict.dict_type, db.func.count(SystemDict.id))\
|
||||||
.group_by(SystemDict.dict_type).all())
|
.group_by(SystemDict.dict_type).all())
|
||||||
|
|
||||||
# 定义类型的友好名称 (标准类型)
|
# 获取类型别名配置 (dict_type='dict_type_alias', value='目标类型', label='中文名称')
|
||||||
|
aliases = SystemDict.query.filter_by(dict_type='dict_type_alias').all()
|
||||||
|
alias_map = {a.value: a.label for a in aliases}
|
||||||
|
|
||||||
|
# 定义类型的友好名称 (标准类型 + 别名覆盖)
|
||||||
standard_types = {
|
standard_types = {
|
||||||
'ai_model': 'AI 生成模型',
|
'ai_model': 'AI 生成模型',
|
||||||
'aspect_ratio': '画面比例配置',
|
'aspect_ratio': '画面比例配置',
|
||||||
@ -110,13 +146,15 @@ def get_dict_types():
|
|||||||
'prompt_tpl': '生图提示词模板',
|
'prompt_tpl': '生图提示词模板',
|
||||||
'video_model': '视频生成模型',
|
'video_model': '视频生成模型',
|
||||||
'video_prompt': '视频提示词模板',
|
'video_prompt': '视频提示词模板',
|
||||||
|
'dict_type_alias': '字典类型别名', # 自身配置
|
||||||
}
|
}
|
||||||
|
# 优先使用数据库配置的别名
|
||||||
|
standard_types.update(alias_map)
|
||||||
|
|
||||||
# 合并数据库中存在的其他类型
|
# 合并数据库中存在的其他类型
|
||||||
all_types = {**standard_types}
|
all_types = {}
|
||||||
for t in counts.keys():
|
for t in counts.keys():
|
||||||
if t not in all_types:
|
all_types[t] = standard_types.get(t, t) # 默认为 Key
|
||||||
all_types[t] = t # 未知类型直接使用 Key 作为名称
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"types": [{
|
"types": [{
|
||||||
@ -195,7 +233,7 @@ def get_notifications():
|
|||||||
"title": n.title,
|
"title": n.title,
|
||||||
"content": n.content,
|
"content": n.content,
|
||||||
"is_active": n.is_active,
|
"is_active": n.is_active,
|
||||||
"created_at": n.created_at.strftime('%Y-%m-%d %H:%M')
|
"created_at": n.created_at_bj.strftime('%Y-%m-%d %H:%M')
|
||||||
} for n in notifs]
|
} for n in notifs]
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -239,8 +277,16 @@ def delete_notification():
|
|||||||
@admin_bp.route('/orders', methods=['GET'])
|
@admin_bp.route('/orders', methods=['GET'])
|
||||||
@permission_required('manage_system') # 仅限超级管理员
|
@permission_required('manage_system') # 仅限超级管理员
|
||||||
def get_orders():
|
def get_orders():
|
||||||
orders = Order.query.order_by(Order.created_at.desc()).all()
|
thirty_min_ago = datetime.utcnow() - timedelta(minutes=30)
|
||||||
from datetime import timedelta
|
|
||||||
|
# 过滤掉超过 30 分钟未支付的订单
|
||||||
|
orders = Order.query.filter(
|
||||||
|
db.or_(
|
||||||
|
Order.status == 'PAID',
|
||||||
|
db.and_(Order.status == 'PENDING', Order.created_at >= thirty_min_ago)
|
||||||
|
)
|
||||||
|
).order_by(Order.created_at.desc()).all()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"orders": [{
|
"orders": [{
|
||||||
"id": o.id,
|
"id": o.id,
|
||||||
@ -250,7 +296,7 @@ def get_orders():
|
|||||||
"points": o.points,
|
"points": o.points,
|
||||||
"status": o.status,
|
"status": o.status,
|
||||||
"trade_no": o.trade_no,
|
"trade_no": o.trade_no,
|
||||||
"created_at": (o.created_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S'),
|
"created_at": o.created_at_bj.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
"paid_at": (o.paid_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S') if o.paid_at else None
|
"paid_at": o.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') if o.paid_at else None
|
||||||
} for o in orders]
|
} for o in orders]
|
||||||
})
|
})
|
||||||
@ -1,289 +1,21 @@
|
|||||||
import os
|
|
||||||
import uuid
|
|
||||||
import json
|
|
||||||
import requests
|
|
||||||
import io
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import base64
|
|
||||||
from flask import Blueprint, request, jsonify, session, current_app
|
from flask import Blueprint, request, jsonify, session, current_app
|
||||||
from urllib.parse import quote
|
from extensions import db, redis_client
|
||||||
from config import Config
|
from models import User
|
||||||
from extensions import s3_client, redis_client, db
|
|
||||||
from models import GenerationRecord, User, SystemDict, SystemNotification
|
|
||||||
from middlewares.auth import login_required
|
from middlewares.auth import login_required
|
||||||
from services.logger import system_logger
|
from services.logger import system_logger
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Import Services
|
||||||
|
from services.system_service import get_system_config_data, get_user_latest_notification, mark_notification_as_read
|
||||||
|
from services.history_service import get_user_history_data
|
||||||
|
from services.file_service import handle_file_uploads, get_remote_file_stream
|
||||||
|
from services.generation_service import (
|
||||||
|
validate_generation_request, deduct_points, handle_chat_generation_sync,
|
||||||
|
start_async_image_task, validate_video_request, start_async_video_task
|
||||||
|
)
|
||||||
|
|
||||||
api_bp = Blueprint('api', __name__)
|
api_bp = Blueprint('api', __name__)
|
||||||
|
|
||||||
def sync_images_background(app, record_id, raw_urls):
|
|
||||||
"""后台同步图片至 MinIO,并生成缩略图,带重试机制"""
|
|
||||||
with app.app_context():
|
|
||||||
processed_data = []
|
|
||||||
for raw_url in raw_urls:
|
|
||||||
success = False
|
|
||||||
for attempt in range(3): # 3 次重试机制
|
|
||||||
try:
|
|
||||||
img_resp = requests.get(raw_url, timeout=30)
|
|
||||||
if img_resp.status_code == 200:
|
|
||||||
content = img_resp.content
|
|
||||||
ext = ".png"
|
|
||||||
base_filename = f"gen-{uuid.uuid4().hex}"
|
|
||||||
full_filename = f"{base_filename}{ext}"
|
|
||||||
thumb_filename = f"{base_filename}-thumb{ext}"
|
|
||||||
|
|
||||||
# 1. 上传原图
|
|
||||||
s3_client.upload_fileobj(
|
|
||||||
io.BytesIO(content),
|
|
||||||
Config.MINIO["bucket"],
|
|
||||||
full_filename,
|
|
||||||
ExtraArgs={"ContentType": "image/png"}
|
|
||||||
)
|
|
||||||
|
|
||||||
full_url = f"{Config.MINIO['public_url']}{quote(full_filename)}"
|
|
||||||
thumb_url = full_url # 默认使用原图
|
|
||||||
|
|
||||||
# 2. 生成并上传缩略图 (400px 宽度)
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
img = Image.open(io.BytesIO(content))
|
|
||||||
# 转换为 RGB 如果是 RGBA (避免某些格式保存问题)
|
|
||||||
if img.mode in ("RGBA", "P"):
|
|
||||||
img = img.convert("RGB")
|
|
||||||
|
|
||||||
# 缩放至宽度 400, 高度等比
|
|
||||||
w, h = img.size
|
|
||||||
if w > 400:
|
|
||||||
ratio = 400 / float(w)
|
|
||||||
img.thumbnail((400, int(h * ratio)), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
thumb_io = io.BytesIO()
|
|
||||||
# 缩略图保存为 JPEG 以获得更小的体积
|
|
||||||
img.save(thumb_io, format='JPEG', quality=80, optimize=True)
|
|
||||||
thumb_io.seek(0)
|
|
||||||
|
|
||||||
s3_client.upload_fileobj(
|
|
||||||
thumb_io,
|
|
||||||
Config.MINIO["bucket"],
|
|
||||||
thumb_filename.replace('.png', '.jpg'),
|
|
||||||
ExtraArgs={"ContentType": "image/jpeg"}
|
|
||||||
)
|
|
||||||
thumb_url = f"{Config.MINIO['public_url']}{quote(thumb_filename.replace('.png', '.jpg'))}"
|
|
||||||
except Exception as thumb_e:
|
|
||||||
print(f"⚠️ 缩略图生成失败: {thumb_e}")
|
|
||||||
|
|
||||||
processed_data.append({"url": full_url, "thumb": thumb_url})
|
|
||||||
success = True
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠️ 第 {attempt+1} 次同步失败: {e}")
|
|
||||||
time.sleep(2 ** attempt) # 指数退避
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
# 如果最终失败,保留原始 URL
|
|
||||||
processed_data.append({"url": raw_url, "thumb": raw_url})
|
|
||||||
|
|
||||||
# 更新数据库记录为持久化数据结构
|
|
||||||
try:
|
|
||||||
record = db.session.get(GenerationRecord, record_id)
|
|
||||||
if record:
|
|
||||||
record.image_urls = json.dumps(processed_data)
|
|
||||||
db.session.commit()
|
|
||||||
print(f"✅ 记录 {record_id} 图片及缩略图已完成同步")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 更新记录失败: {e}")
|
|
||||||
|
|
||||||
def process_image_generation(app, user_id, task_id, payload, api_key, target_api, cost):
|
|
||||||
"""异步执行图片生成并存入 Redis"""
|
|
||||||
with app.app_context():
|
|
||||||
try:
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
||||||
# 使用较长的超时时间 (10分钟),确保长耗时任务不被中断
|
|
||||||
resp = requests.post(target_api, json=payload, headers=headers, timeout=1000)
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
|
||||||
user = db.session.get(User, user_id)
|
|
||||||
if user and "sk-" in api_key:
|
|
||||||
user.points += cost
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
system_logger.error(f"生图任务失败: {resp.text}", user_id=user_id, task_id=task_id)
|
|
||||||
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": resp.text}))
|
|
||||||
return
|
|
||||||
|
|
||||||
api_result = resp.json()
|
|
||||||
raw_urls = [item['url'] for item in api_result.get('data', [])]
|
|
||||||
|
|
||||||
# 持久化记录
|
|
||||||
new_record = GenerationRecord(
|
|
||||||
user_id=user_id,
|
|
||||||
prompt=payload.get('prompt'),
|
|
||||||
model=payload.get('model'),
|
|
||||||
image_urls=json.dumps(raw_urls)
|
|
||||||
)
|
|
||||||
db.session.add(new_record)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# 后台线程处理:下载 AI 原始图片并同步到私有 MinIO
|
|
||||||
threading.Thread(
|
|
||||||
target=sync_images_background,
|
|
||||||
args=(app, new_record.id, raw_urls)
|
|
||||||
).start()
|
|
||||||
|
|
||||||
# 存入 Redis 标记完成
|
|
||||||
system_logger.info(f"生图任务完成", user_id=user_id, task_id=task_id, model=payload.get('model'))
|
|
||||||
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "complete", "urls": raw_urls}))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# 异常处理:退还积分
|
|
||||||
user = db.session.get(User, user_id)
|
|
||||||
if user and "sk-" in api_key:
|
|
||||||
user.points += cost
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
system_logger.error(f"生图任务异常: {str(e)}", user_id=user_id, task_id=task_id)
|
|
||||||
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": str(e)}))
|
|
||||||
|
|
||||||
def sync_video_background(app, record_id, raw_url, internal_task_id=None):
|
|
||||||
"""后台同步视频至 MinIO,带重试机制"""
|
|
||||||
with app.app_context():
|
|
||||||
success = False
|
|
||||||
final_url = raw_url
|
|
||||||
for attempt in range(3):
|
|
||||||
try:
|
|
||||||
# 增加了流式下载,处理大视频文件
|
|
||||||
with requests.get(raw_url, stream=True, timeout=120) as r:
|
|
||||||
r.raise_for_status()
|
|
||||||
content_type = r.headers.get('content-type', 'video/mp4')
|
|
||||||
ext = ".mp4"
|
|
||||||
if "text/html" in content_type: # 有些 API 返回的是跳转页面
|
|
||||||
continue
|
|
||||||
|
|
||||||
base_filename = f"video-{uuid.uuid4().hex}"
|
|
||||||
full_filename = f"{base_filename}{ext}"
|
|
||||||
|
|
||||||
video_io = io.BytesIO()
|
|
||||||
for chunk in r.iter_content(chunk_size=8192):
|
|
||||||
video_io.write(chunk)
|
|
||||||
video_io.seek(0)
|
|
||||||
|
|
||||||
# 上传至 MinIO
|
|
||||||
s3_client.upload_fileobj(
|
|
||||||
video_io,
|
|
||||||
Config.MINIO["bucket"],
|
|
||||||
full_filename,
|
|
||||||
ExtraArgs={"ContentType": content_type}
|
|
||||||
)
|
|
||||||
|
|
||||||
final_url = f"{Config.MINIO['public_url']}{quote(full_filename)}"
|
|
||||||
success = True
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
system_logger.error(f"同步视频失败 (第{attempt+1}次): {str(e)}")
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
try:
|
|
||||||
record = db.session.get(GenerationRecord, record_id)
|
|
||||||
if record:
|
|
||||||
# 更新记录为 MinIO 的 URL
|
|
||||||
record.image_urls = json.dumps([{"url": final_url, "type": "video"}])
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# 关键修复:同步更新 Redis 中的缓存,这样前端轮询也能拿到最新的 MinIO 地址
|
|
||||||
if internal_task_id:
|
|
||||||
cached_data = redis_client.get(f"task:{internal_task_id}")
|
|
||||||
if cached_data:
|
|
||||||
if isinstance(cached_data, bytes):
|
|
||||||
cached_data = cached_data.decode('utf-8')
|
|
||||||
task_info = json.loads(cached_data)
|
|
||||||
task_info['video_url'] = final_url
|
|
||||||
redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps(task_info))
|
|
||||||
|
|
||||||
system_logger.info(f"视频同步 MinIO 成功", video_url=final_url)
|
|
||||||
except Exception as dbe:
|
|
||||||
system_logger.error(f"更新视频记录失败: {str(dbe)}")
|
|
||||||
|
|
||||||
def process_video_generation(app, user_id, internal_task_id, payload, api_key, cost):
|
|
||||||
"""异步提交并查询视频任务状态"""
|
|
||||||
with app.app_context():
|
|
||||||
try:
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
||||||
# 1. 提交任务
|
|
||||||
submit_resp = requests.post(Config.VIDEO_GEN_API, json=payload, headers=headers, timeout=60)
|
|
||||||
if submit_resp.status_code != 200:
|
|
||||||
raise Exception(f"视频任务提交失败: {submit_resp.text}")
|
|
||||||
|
|
||||||
submit_result = submit_resp.json()
|
|
||||||
remote_task_id = submit_result.get('task_id')
|
|
||||||
if not remote_task_id:
|
|
||||||
raise Exception(f"未获取到远程任务 ID: {submit_result}")
|
|
||||||
|
|
||||||
# 2. 轮询状态
|
|
||||||
max_retries = 90 # 提升到 15 分钟
|
|
||||||
video_url = None
|
|
||||||
for i in range(max_retries):
|
|
||||||
time.sleep(10)
|
|
||||||
poll_url = Config.VIDEO_POLL_API.format(task_id=remote_task_id)
|
|
||||||
poll_resp = requests.get(poll_url, headers=headers, timeout=30)
|
|
||||||
if poll_resp.status_code != 200:
|
|
||||||
continue
|
|
||||||
|
|
||||||
poll_result = poll_resp.json()
|
|
||||||
status = poll_result.get('status', '').upper()
|
|
||||||
|
|
||||||
if status == 'SUCCESS':
|
|
||||||
# 提取视频输出地址
|
|
||||||
if 'data' in poll_result and isinstance(poll_result['data'], dict):
|
|
||||||
video_url = poll_result['data'].get('output')
|
|
||||||
if not video_url:
|
|
||||||
if 'data' in poll_result and isinstance(poll_result['data'], list) and poll_result['data']:
|
|
||||||
video_url = poll_result['data'][0].get('url')
|
|
||||||
elif 'video' in poll_result:
|
|
||||||
video_url = poll_result['video'].get('url') if isinstance(poll_result['video'], dict) else poll_result['video']
|
|
||||||
elif 'url' in poll_result:
|
|
||||||
video_url = poll_result['url']
|
|
||||||
break
|
|
||||||
elif status in ['FAILED', 'ERROR']:
|
|
||||||
raise Exception(f"视频生成失败: {poll_result.get('fail_reason') or poll_result.get('message') or '未知错误'}")
|
|
||||||
|
|
||||||
if not video_url:
|
|
||||||
raise Exception("超时未获取到视频地址")
|
|
||||||
|
|
||||||
# 3. 持久化记录
|
|
||||||
new_record = GenerationRecord(
|
|
||||||
user_id=user_id,
|
|
||||||
prompt=payload.get('prompt'),
|
|
||||||
model=payload.get('model'),
|
|
||||||
image_urls=json.dumps([{"url": video_url, "type": "video"}])
|
|
||||||
)
|
|
||||||
db.session.add(new_record)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# 后台线程异步同步到 MinIO
|
|
||||||
threading.Thread(
|
|
||||||
target=sync_video_background,
|
|
||||||
args=(app, new_record.id, video_url, internal_task_id)
|
|
||||||
).start()
|
|
||||||
|
|
||||||
# 4. 存入 Redis
|
|
||||||
redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps({"status": "complete", "video_url": video_url, "record_id": new_record.id}))
|
|
||||||
system_logger.info(f"视频生成任务完成", user_id=user_id, task_id=internal_task_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
system_logger.error(f"视频生成执行异常: {str(e)}", user_id=user_id, task_id=internal_task_id)
|
|
||||||
# 尝试退费
|
|
||||||
try:
|
|
||||||
user = db.session.get(User, user_id)
|
|
||||||
if user:
|
|
||||||
user.points += cost
|
|
||||||
db.session.commit()
|
|
||||||
except Exception as re:
|
|
||||||
system_logger.error(f"退费失败: {str(re)}")
|
|
||||||
|
|
||||||
# 确保 Redis 状态一定被更新,防止前端死循环
|
|
||||||
redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps({"status": "error", "message": str(e)}))
|
|
||||||
|
|
||||||
@api_bp.route('/api/task_status/<task_id>')
|
@api_bp.route('/api/task_status/<task_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def get_task_status(task_id):
|
def get_task_status(task_id):
|
||||||
@ -293,7 +25,6 @@ def get_task_status(task_id):
|
|||||||
if not data:
|
if not data:
|
||||||
return jsonify({"status": "pending"})
|
return jsonify({"status": "pending"})
|
||||||
|
|
||||||
# 兼容处理 bytes 和 str
|
|
||||||
if isinstance(data, bytes):
|
if isinstance(data, bytes):
|
||||||
data = data.decode('utf-8')
|
data = data.decode('utf-8')
|
||||||
|
|
||||||
@ -306,35 +37,7 @@ def get_task_status(task_id):
|
|||||||
def get_config():
|
def get_config():
|
||||||
"""从本地数据库字典获取配置"""
|
"""从本地数据库字典获取配置"""
|
||||||
try:
|
try:
|
||||||
dicts = SystemDict.query.filter_by(is_active=True).order_by(SystemDict.sort_order.desc()).all()
|
return jsonify(get_system_config_data())
|
||||||
|
|
||||||
config = {
|
|
||||||
"models": [],
|
|
||||||
"ratios": [],
|
|
||||||
"prompts": [],
|
|
||||||
"sizes": [],
|
|
||||||
"video_models": [],
|
|
||||||
"video_prompts": []
|
|
||||||
}
|
|
||||||
|
|
||||||
for d in dicts:
|
|
||||||
item = {"label": d.label, "value": d.value}
|
|
||||||
if d.dict_type == 'ai_model':
|
|
||||||
item["cost"] = d.cost
|
|
||||||
config["models"].append(item)
|
|
||||||
elif d.dict_type == 'aspect_ratio':
|
|
||||||
config["ratios"].append(item)
|
|
||||||
elif d.dict_type == 'prompt_tpl':
|
|
||||||
config["prompts"].append(item)
|
|
||||||
elif d.dict_type == 'ai_image_size':
|
|
||||||
config["sizes"].append(item)
|
|
||||||
elif d.dict_type == 'video_model':
|
|
||||||
item["cost"] = d.cost
|
|
||||||
config["video_models"].append(item)
|
|
||||||
elif d.dict_type == 'video_prompt':
|
|
||||||
config["video_prompts"].append(item)
|
|
||||||
|
|
||||||
return jsonify(config)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@ -343,16 +46,7 @@ def get_config():
|
|||||||
def upload():
|
def upload():
|
||||||
try:
|
try:
|
||||||
files = request.files.getlist('images')
|
files = request.files.getlist('images')
|
||||||
img_urls = []
|
img_urls = handle_file_uploads(files)
|
||||||
for f in files:
|
|
||||||
ext = os.path.splitext(f.filename)[1]
|
|
||||||
filename = f"{uuid.uuid4().hex}{ext}"
|
|
||||||
s3_client.upload_fileobj(
|
|
||||||
f, Config.MINIO["bucket"], filename,
|
|
||||||
ExtraArgs={"ContentType": f.content_type}
|
|
||||||
)
|
|
||||||
img_urls.append(f"{Config.MINIO['public_url']}{quote(filename)}")
|
|
||||||
|
|
||||||
system_logger.info(f"用户上传文件: {len(files)} 个", user_id=session.get('user_id'))
|
system_logger.info(f"用户上传文件: {len(files)} 个", user_id=session.get('user_id'))
|
||||||
return jsonify({"urls": img_urls})
|
return jsonify({"urls": img_urls})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -366,107 +60,42 @@ def generate():
|
|||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
data = request.json if request.is_json else request.form
|
data = request.json if request.is_json else request.form
|
||||||
mode = data.get('mode', 'trial')
|
|
||||||
is_premium = data.get('is_premium', False)
|
|
||||||
input_key = data.get('apiKey')
|
|
||||||
|
|
||||||
target_api = Config.AI_API
|
# 1. 验证请求与权限
|
||||||
api_key = None
|
api_key, target_api, cost, use_trial, error = validate_generation_request(user, data)
|
||||||
use_trial = False
|
if error:
|
||||||
|
return jsonify({"error": error}), 400
|
||||||
|
|
||||||
if mode == 'key':
|
# 2. 扣除积分 (如果是试用模式)
|
||||||
api_key = input_key or user.api_key
|
if use_trial:
|
||||||
if not api_key:
|
deduct_points(user, cost)
|
||||||
return jsonify({"error": "请先输入您的 API 密钥"}), 400
|
|
||||||
else:
|
|
||||||
if user.points > 0:
|
|
||||||
api_key = Config.PREMIUM_KEY if is_premium else Config.TRIAL_KEY
|
|
||||||
target_api = Config.TRIAL_API
|
|
||||||
use_trial = True
|
|
||||||
else:
|
|
||||||
return jsonify({"error": "可用积分已耗尽,请充值或切换至自定义 Key 模式"}), 400
|
|
||||||
|
|
||||||
if mode == 'key' and input_key and input_key != user.api_key:
|
|
||||||
user.api_key = input_key
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
model_value = data.get('model')
|
model_value = data.get('model')
|
||||||
|
prompt = data.get('prompt')
|
||||||
is_chat_model = "gemini" in model_value.lower() or "gpt" in model_value.lower()
|
is_chat_model = "gemini" in model_value.lower() or "gpt" in model_value.lower()
|
||||||
|
|
||||||
model_dict = SystemDict.query.filter_by(dict_type='ai_model', value=model_value).first()
|
# 3. 处理聊天模型 (同步)
|
||||||
cost = model_dict.cost if model_dict else 1
|
if is_chat_model:
|
||||||
|
result, status_code = handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost)
|
||||||
if use_trial and is_premium:
|
return jsonify(result), status_code
|
||||||
cost *= 2
|
|
||||||
|
|
||||||
if use_trial:
|
|
||||||
if user.points < cost:
|
|
||||||
return jsonify({"error": f"可用积分不足"}), 400
|
|
||||||
user.points -= cost
|
|
||||||
user.has_used_points = True # 标记已使用过积分
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
prompt = data.get('prompt')
|
|
||||||
ratio = data.get('ratio')
|
|
||||||
size = data.get('size')
|
|
||||||
image_data = data.get('image_data', [])
|
|
||||||
|
|
||||||
|
# 4. 构造生图 Payload
|
||||||
payload = {
|
payload = {
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model": model_value,
|
"model": model_value,
|
||||||
"response_format": "url",
|
"response_format": "url",
|
||||||
"aspect_ratio": ratio
|
"aspect_ratio": data.get('ratio')
|
||||||
}
|
}
|
||||||
|
image_data = data.get('image_data', [])
|
||||||
if image_data:
|
if image_data:
|
||||||
payload["image"] = [img.split(',', 1)[1] if ',' in img else img for img in image_data]
|
payload["image"] = [img.split(',', 1)[1] if ',' in img else img for img in image_data]
|
||||||
|
|
||||||
if model_value == "nano-banana-2" and size:
|
if model_value == "nano-banana-2" and data.get('size'):
|
||||||
payload["image_size"] = size
|
payload["image_size"] = data.get('size')
|
||||||
|
|
||||||
# 如果是聊天模型,直接同步处理
|
# 5. 启动异步生图任务
|
||||||
if is_chat_model:
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
||||||
chat_payload = {
|
|
||||||
"model": model_value,
|
|
||||||
"messages": [{"role": "user", "content": prompt}]
|
|
||||||
}
|
|
||||||
resp = requests.post(Config.CHAT_API, json=chat_payload, headers=headers, timeout=120)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
if use_trial:
|
|
||||||
user.points += cost
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({"error": resp.text}), resp.status_code
|
|
||||||
|
|
||||||
api_result = resp.json()
|
|
||||||
content = api_result['choices'][0]['message']['content']
|
|
||||||
|
|
||||||
# 记录聊天历史
|
|
||||||
if prompt != "解读验光单":
|
|
||||||
new_record = GenerationRecord(
|
|
||||||
user_id=user_id,
|
|
||||||
prompt=prompt,
|
|
||||||
model=model_value,
|
|
||||||
image_urls=json.dumps([{"type": "text", "content": content}])
|
|
||||||
)
|
|
||||||
db.session.add(new_record)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"data": [{"content": content, "type": "text"}],
|
|
||||||
"message": "生成成功!"
|
|
||||||
})
|
|
||||||
|
|
||||||
# --- 异步处理生图任务 ---
|
|
||||||
task_id = str(uuid.uuid4())
|
|
||||||
app = current_app._get_current_object()
|
app = current_app._get_current_object()
|
||||||
|
task_id = start_async_image_task(app, user_id, payload, api_key, target_api, cost, data.get('mode'), model_value)
|
||||||
log_msg = "用户发起验光单解读" if prompt == "解读验光单" else "用户发起生图任务"
|
|
||||||
system_logger.info(log_msg, model=model_value, mode=mode)
|
|
||||||
|
|
||||||
threading.Thread(
|
|
||||||
target=process_image_generation,
|
|
||||||
args=(app, user_id, task_id, payload, api_key, target_api, cost)
|
|
||||||
).start()
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
@ -483,25 +112,16 @@ def video_generate():
|
|||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
data = request.json
|
data = request.json
|
||||||
# 视频生成统一使用积分模式,隐藏 Key 模式
|
|
||||||
if user.points <= 0:
|
|
||||||
return jsonify({"error": "可用积分不足,请先充值"}), 400
|
|
||||||
|
|
||||||
model_value = data.get('model', 'veo3.1')
|
# 1. 验证请求
|
||||||
|
model_value, cost, error = validate_video_request(user, data)
|
||||||
|
if error:
|
||||||
|
return jsonify({"error": error}), 400
|
||||||
|
|
||||||
# 确定积分消耗 (优先从字典获取)
|
# 2. 扣除积分
|
||||||
model_dict = SystemDict.query.filter_by(dict_type='video_model', value=model_value).first()
|
deduct_points(user, cost)
|
||||||
cost = model_dict.cost if model_dict else (15 if "pro" in model_value.lower() or "3.1" in model_value else 10)
|
|
||||||
|
|
||||||
if user.points < cost:
|
# 3. 构造 Payload
|
||||||
return jsonify({"error": f"积分不足,生成该视频需要 {cost} 积分"}), 400
|
|
||||||
|
|
||||||
# 扣除积分
|
|
||||||
user.points -= cost
|
|
||||||
user.has_used_points = True
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# 构建符合 API 文档的 Payload
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": model_value,
|
"model": model_value,
|
||||||
"prompt": data.get('prompt'),
|
"prompt": data.get('prompt'),
|
||||||
@ -510,18 +130,9 @@ def video_generate():
|
|||||||
"aspect_ratio": data.get('aspect_ratio', '9:16')
|
"aspect_ratio": data.get('aspect_ratio', '9:16')
|
||||||
}
|
}
|
||||||
|
|
||||||
# 使用系统内置的 Key
|
# 4. 启动异步视频任务
|
||||||
api_key = Config.TRIAL_KEY # 默认使用试用/中转 Key
|
|
||||||
|
|
||||||
task_id = str(uuid.uuid4())
|
|
||||||
app = current_app._get_current_object()
|
app = current_app._get_current_object()
|
||||||
|
task_id = start_async_video_task(app, user_id, payload, cost, model_value)
|
||||||
system_logger.info("用户发起视频生成任务 (积分模式)", model=model_value, cost=cost)
|
|
||||||
|
|
||||||
threading.Thread(
|
|
||||||
target=process_video_generation,
|
|
||||||
args=(app, user_id, task_id, payload, api_key, cost)
|
|
||||||
).start()
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
@ -533,27 +144,16 @@ def video_generate():
|
|||||||
@api_bp.route('/api/notifications/latest', methods=['GET'])
|
@api_bp.route('/api/notifications/latest', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def get_latest_notification():
|
def get_latest_notification():
|
||||||
"""获取用户最近一条未读的激活通知"""
|
|
||||||
try:
|
try:
|
||||||
user_id = session.get('user_id')
|
user_id = session.get('user_id')
|
||||||
latest = SystemNotification.query.filter_by(is_active=True)\
|
data = get_user_latest_notification(user_id)
|
||||||
.filter(~SystemNotification.read_by_users.any(id=user_id))\
|
return jsonify(data)
|
||||||
.order_by(SystemNotification.created_at.desc()).first()
|
|
||||||
|
|
||||||
if latest:
|
|
||||||
return jsonify({
|
|
||||||
"id": latest.id,
|
|
||||||
"title": latest.title,
|
|
||||||
"content": latest.content
|
|
||||||
})
|
|
||||||
return jsonify({"id": None})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/notifications/read', methods=['POST'])
|
@api_bp.route('/api/notifications/read', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def mark_notif_read():
|
def mark_notif_read():
|
||||||
"""将通知标记为已读"""
|
|
||||||
try:
|
try:
|
||||||
user_id = session.get('user_id')
|
user_id = session.get('user_id')
|
||||||
data = request.json
|
data = request.json
|
||||||
@ -561,13 +161,7 @@ def mark_notif_read():
|
|||||||
if not notif_id:
|
if not notif_id:
|
||||||
return jsonify({"error": "缺少通知 ID"}), 400
|
return jsonify({"error": "缺少通知 ID"}), 400
|
||||||
|
|
||||||
notif = db.session.get(SystemNotification, notif_id)
|
mark_notification_as_read(user_id, notif_id)
|
||||||
user = db.session.get(User, user_id)
|
|
||||||
|
|
||||||
if notif and user:
|
|
||||||
if user not in notif.read_by_users:
|
|
||||||
notif.read_by_users.append(user)
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
@ -575,75 +169,49 @@ def mark_notif_read():
|
|||||||
@api_bp.route('/api/history', methods=['GET'])
|
@api_bp.route('/api/history', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def get_history():
|
def get_history():
|
||||||
"""获取用户的历史生成记录 (支持分页,限 90 天内)"""
|
|
||||||
try:
|
try:
|
||||||
from datetime import datetime, timedelta
|
|
||||||
user_id = session.get('user_id')
|
user_id = session.get('user_id')
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
per_page = request.args.get('per_page', 10, type=int)
|
per_page = request.args.get('per_page', 10, type=int)
|
||||||
|
filter_type = request.args.get('filter_type', 'all')
|
||||||
|
|
||||||
# 计算 90 天前的时间
|
data = get_user_history_data(user_id, page, per_page, filter_type)
|
||||||
ninety_days_ago = datetime.utcnow() - timedelta(days=90)
|
return jsonify(data)
|
||||||
|
|
||||||
pagination = GenerationRecord.query.filter(
|
|
||||||
GenerationRecord.user_id == user_id,
|
|
||||||
GenerationRecord.created_at >= ninety_days_ago,
|
|
||||||
GenerationRecord.prompt != "解读验光单" # 过滤掉验光单助手的操作记录
|
|
||||||
).order_by(GenerationRecord.created_at.desc())\
|
|
||||||
.paginate(page=page, per_page=per_page, error_out=False)
|
|
||||||
|
|
||||||
# 格式化 URL,兼容新旧数据格式
|
|
||||||
history_list = []
|
|
||||||
for r in pagination.items:
|
|
||||||
raw_urls = json.loads(r.image_urls)
|
|
||||||
formatted_urls = []
|
|
||||||
for u in raw_urls:
|
|
||||||
if isinstance(u, str):
|
|
||||||
# 旧数据:直接返回原图作为缩略图
|
|
||||||
formatted_urls.append({"url": u, "thumb": u})
|
|
||||||
else:
|
|
||||||
# 如果是视频类型,提供默认预览图 (此处使用一个公共视频占位图或空)
|
|
||||||
if u.get('type') == 'video' and not u.get('thumb'):
|
|
||||||
u['thumb'] = "https://img.icons8.com/flat-round/64/000000/play--v1.png"
|
|
||||||
formatted_urls.append(u)
|
|
||||||
|
|
||||||
history_list.append({
|
|
||||||
"id": r.id,
|
|
||||||
"prompt": r.prompt,
|
|
||||||
"model": r.model,
|
|
||||||
"urls": formatted_urls,
|
|
||||||
"created_at": (r.created_at + timedelta(hours=8)).strftime('%b %d, %H:%M')
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
"history": history_list,
|
|
||||||
"has_next": pagination.has_next,
|
|
||||||
"total": pagination.total
|
|
||||||
})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@api_bp.route('/api/stats/points', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_user_point_stats():
|
||||||
|
"""获取积分统计图表数据"""
|
||||||
|
from services.stats_service import get_point_stats
|
||||||
|
user_id = session.get('user_id')
|
||||||
|
days = request.args.get('days', 7, type=int)
|
||||||
|
return jsonify(get_point_stats(user_id, days))
|
||||||
|
|
||||||
|
@api_bp.route('/api/stats/details', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_user_point_details():
|
||||||
|
"""获取积分消耗明细"""
|
||||||
|
from services.stats_service import get_point_details
|
||||||
|
user_id = session.get('user_id')
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
return jsonify(get_point_details(user_id, page))
|
||||||
|
|
||||||
@api_bp.route('/api/download_proxy', methods=['GET'])
|
@api_bp.route('/api/download_proxy', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def download_proxy():
|
def download_proxy():
|
||||||
"""代理下载远程文件,强制浏览器弹出下载"""
|
import time
|
||||||
url = request.args.get('url')
|
url = request.args.get('url')
|
||||||
filename = request.args.get('filename', f"video-{int(time.time())}.mp4")
|
# 默认文件名逻辑
|
||||||
|
default_name = f"video-{int(time.time())}.mp4"
|
||||||
|
filename = request.args.get('filename') or default_name
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
return jsonify({"error": "缺少 URL 参数"}), 400
|
return jsonify({"error": "缺少 URL 参数"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 流式获取远程文件
|
req, headers = get_remote_file_stream(url)
|
||||||
req = requests.get(url, stream=True, timeout=60)
|
|
||||||
req.raise_for_status()
|
|
||||||
|
|
||||||
headers = {}
|
|
||||||
if req.headers.get('Content-Type'):
|
|
||||||
headers['Content-Type'] = req.headers['Content-Type']
|
|
||||||
else:
|
|
||||||
headers['Content-Type'] = 'application/octet-stream'
|
|
||||||
|
|
||||||
headers['Content-Disposition'] = f'attachment; filename="{filename}"'
|
headers['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from flask import Blueprint, request, jsonify, session, render_template, redirect, url_for
|
from flask import Blueprint, request, jsonify, session, render_template, redirect, url_for
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from extensions import db
|
from extensions import db
|
||||||
from models import User
|
from models import User
|
||||||
from services.sms_service import SMSService
|
from services.sms_service import SMSService
|
||||||
@ -56,14 +57,27 @@ def buy_page():
|
|||||||
user_id = session['user_id']
|
user_id = session['user_id']
|
||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
# 获取用户个人充值记录
|
thirty_min_ago = datetime.utcnow() - timedelta(minutes=30)
|
||||||
personal_orders = Order.query.filter_by(user_id=user_id).order_by(Order.created_at.desc()).limit(10).all()
|
|
||||||
|
# 获取用户个人充值记录 (过滤掉超过 30 分钟未支付的订单)
|
||||||
|
personal_orders = Order.query.filter(
|
||||||
|
Order.user_id == user_id,
|
||||||
|
db.or_(
|
||||||
|
Order.status == 'PAID',
|
||||||
|
db.and_(Order.status == 'PENDING', Order.created_at >= thirty_min_ago)
|
||||||
|
)
|
||||||
|
).order_by(Order.created_at.desc()).limit(10).all()
|
||||||
|
|
||||||
# 如果是管理员,获取全员记录
|
# 如果是管理员,获取全员记录
|
||||||
is_admin = user.has_permission('manage_system')
|
is_admin = user.has_permission('manage_system')
|
||||||
admin_orders = []
|
admin_orders = []
|
||||||
if is_admin:
|
if is_admin:
|
||||||
admin_orders = Order.query.order_by(Order.created_at.desc()).limit(10).all()
|
admin_orders = Order.query.filter(
|
||||||
|
db.or_(
|
||||||
|
Order.status == 'PAID',
|
||||||
|
db.and_(Order.status == 'PENDING', Order.created_at >= thirty_min_ago)
|
||||||
|
)
|
||||||
|
).order_by(Order.created_at.desc()).limit(10).all()
|
||||||
|
|
||||||
# 处理支付成功提示
|
# 处理支付成功提示
|
||||||
success = request.args.get('success') == 'true'
|
success = request.args.get('success') == 'true'
|
||||||
@ -72,12 +86,12 @@ def buy_page():
|
|||||||
if out_trade_no:
|
if out_trade_no:
|
||||||
order = Order.query.filter_by(out_trade_no=out_trade_no).first()
|
order = Order.query.filter_by(out_trade_no=out_trade_no).first()
|
||||||
|
|
||||||
import datetime
|
import datetime as dt_module
|
||||||
return render_template('buy.html',
|
return render_template('buy.html',
|
||||||
personal_orders=personal_orders,
|
personal_orders=personal_orders,
|
||||||
admin_orders=admin_orders,
|
admin_orders=admin_orders,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
modules={'datetime': datetime},
|
modules={'datetime': dt_module},
|
||||||
success=success,
|
success=success,
|
||||||
order=order)
|
order=order)
|
||||||
|
|
||||||
@ -106,6 +120,11 @@ def send_code():
|
|||||||
|
|
||||||
if not phone:
|
if not phone:
|
||||||
return jsonify({"error": "请输入手机号"}), 400
|
return jsonify({"error": "请输入手机号"}), 400
|
||||||
|
|
||||||
|
import re
|
||||||
|
if not re.match(r'^1[3-9]\d{9}$', phone):
|
||||||
|
return jsonify({"error": "手机号格式不正确"}), 400
|
||||||
|
|
||||||
if not captcha:
|
if not captcha:
|
||||||
return jsonify({"error": "请输入图形验证码", "show_captcha": True}), 403
|
return jsonify({"error": "请输入图形验证码", "show_captcha": True}), 403
|
||||||
|
|
||||||
@ -160,6 +179,10 @@ def register():
|
|||||||
code = data.get('code')
|
code = data.get('code')
|
||||||
password = data.get('password')
|
password = data.get('password')
|
||||||
|
|
||||||
|
import re
|
||||||
|
if not phone or not re.match(r'^1[3-9]\d{9}$', phone):
|
||||||
|
return jsonify({"error": "手机号格式不正确"}), 400
|
||||||
|
|
||||||
system_logger.info(f"用户注册请求", phone=phone)
|
system_logger.info(f"用户注册请求", phone=phone)
|
||||||
|
|
||||||
if not SMSService.verify_code(phone, code):
|
if not SMSService.verify_code(phone, code):
|
||||||
@ -217,6 +240,10 @@ def login():
|
|||||||
|
|
||||||
user = User.query.filter_by(phone=phone).first()
|
user = User.query.filter_by(phone=phone).first()
|
||||||
if user and user.check_password(password):
|
if user and user.check_password(password):
|
||||||
|
if user.is_banned:
|
||||||
|
system_logger.warning(f"被封禁用户尝试登录", phone=phone)
|
||||||
|
return jsonify({"error": "您的账号已被封禁,请联系管理员"}), 403
|
||||||
|
|
||||||
# 登录成功,清除失败计数
|
# 登录成功,清除失败计数
|
||||||
redis_client.delete(fail_key)
|
redis_client.delete(fail_key)
|
||||||
|
|
||||||
@ -239,6 +266,10 @@ def reset_password():
|
|||||||
code = data.get('code')
|
code = data.get('code')
|
||||||
new_password = data.get('password')
|
new_password = data.get('password')
|
||||||
|
|
||||||
|
import re
|
||||||
|
if not phone or not re.match(r'^1[3-9]\d{9}$', phone):
|
||||||
|
return jsonify({"error": "手机号格式不正确"}), 400
|
||||||
|
|
||||||
if not phone or not code or not new_password:
|
if not phone or not code or not new_password:
|
||||||
return jsonify({"error": "请填写完整信息"}), 400
|
return jsonify({"error": "请填写完整信息"}), 400
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
from flask import Blueprint, request, redirect, url_for, session, jsonify, render_template
|
from flask import Blueprint, request, redirect, url_for, session, jsonify, render_template
|
||||||
from extensions import db
|
from extensions import db
|
||||||
from models import Order, User
|
from models import Order, User, to_bj_time
|
||||||
from services.alipay_service import AlipayService
|
from services.alipay_service import AlipayService
|
||||||
from services.logger import system_logger
|
from services.logger import system_logger
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
payment_bp = Blueprint('payment', __name__, url_prefix='/payment')
|
payment_bp = Blueprint('payment', __name__, url_prefix='/payment')
|
||||||
|
|
||||||
@ -91,11 +91,19 @@ def payment_history():
|
|||||||
if 'user_id' not in session:
|
if 'user_id' not in session:
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
user_id = session['user_id']
|
thirty_min_ago = datetime.utcnow() - timedelta(minutes=30)
|
||||||
orders = Order.query.filter_by(user_id=user_id).order_by(Order.created_at.desc()).all()
|
|
||||||
|
|
||||||
import datetime
|
user_id = session['user_id']
|
||||||
return render_template('recharge_history.html', orders=orders, modules={'datetime': datetime})
|
orders = Order.query.filter(
|
||||||
|
Order.user_id == user_id,
|
||||||
|
db.or_(
|
||||||
|
Order.status == 'PAID',
|
||||||
|
db.and_(Order.status == 'PENDING', Order.created_at >= thirty_min_ago)
|
||||||
|
)
|
||||||
|
).order_by(Order.created_at.desc()).all()
|
||||||
|
|
||||||
|
import datetime as dt_module
|
||||||
|
return render_template('recharge_history.html', orders=orders, modules={'datetime': dt_module})
|
||||||
|
|
||||||
@payment_bp.route('/api/history', methods=['GET'])
|
@payment_bp.route('/api/history', methods=['GET'])
|
||||||
def api_payment_history():
|
def api_payment_history():
|
||||||
@ -103,10 +111,17 @@ def api_payment_history():
|
|||||||
if 'user_id' not in session:
|
if 'user_id' not in session:
|
||||||
return jsonify({'code': 401, 'msg': '请先登录'}), 401
|
return jsonify({'code': 401, 'msg': '请先登录'}), 401
|
||||||
|
|
||||||
user_id = session['user_id']
|
thirty_min_ago = datetime.utcnow() - timedelta(minutes=30)
|
||||||
orders = Order.query.filter_by(user_id=user_id).order_by(Order.created_at.desc()).all()
|
|
||||||
|
user_id = session['user_id']
|
||||||
|
orders = Order.query.filter(
|
||||||
|
Order.user_id == user_id,
|
||||||
|
db.or_(
|
||||||
|
Order.status == 'PAID',
|
||||||
|
db.and_(Order.status == 'PENDING', Order.created_at >= thirty_min_ago)
|
||||||
|
)
|
||||||
|
).order_by(Order.created_at.desc()).all()
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"orders": [{
|
"orders": [{
|
||||||
"id": o.id,
|
"id": o.id,
|
||||||
@ -115,8 +130,8 @@ def api_payment_history():
|
|||||||
"points": o.points,
|
"points": o.points,
|
||||||
"status": o.status,
|
"status": o.status,
|
||||||
"trade_no": o.trade_no,
|
"trade_no": o.trade_no,
|
||||||
"created_at": (o.created_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S'),
|
"created_at": o.created_at_bj.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
"paid_at": (o.paid_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S') if o.paid_at else None
|
"paid_at": o.paid_at_bj.strftime('%Y-%m-%d %H:%M:%S') if o.paid_at else None
|
||||||
} for o in orders]
|
} for o in orders]
|
||||||
})
|
})
|
||||||
@payment_bp.route('/notify', methods=['POST'])
|
@payment_bp.route('/notify', methods=['POST'])
|
||||||
@ -140,7 +155,7 @@ def payment_notify():
|
|||||||
if order and order.status == 'PENDING':
|
if order and order.status == 'PENDING':
|
||||||
order.status = 'PAID'
|
order.status = 'PAID'
|
||||||
order.trade_no = trade_no
|
order.trade_no = trade_no
|
||||||
order.paid_at = datetime.utcnow()
|
order.paid_at = datetime.now()
|
||||||
|
|
||||||
user = db.session.get(User, order.user_id)
|
user = db.session.get(User, order.user_id)
|
||||||
if user:
|
if user:
|
||||||
|
|||||||
@ -1,163 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
支付宝配置检查脚本
|
|
||||||
用于检查支付宝配置是否正确,不依赖虚拟环境
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
def check_alipay_config():
|
|
||||||
"""检查支付宝配置"""
|
|
||||||
print("="*60)
|
|
||||||
print("支付宝配置检查")
|
|
||||||
print("="*60)
|
|
||||||
|
|
||||||
# 读取配置文件内容
|
|
||||||
config_path = os.path.join(os.path.dirname(__file__), 'config.py')
|
|
||||||
|
|
||||||
if not os.path.exists(config_path):
|
|
||||||
print("错误: config.py 文件不存在")
|
|
||||||
return False
|
|
||||||
|
|
||||||
with open(config_path, 'r', encoding='utf-8') as f:
|
|
||||||
config_content = f.read()
|
|
||||||
|
|
||||||
# 检查支付宝相关配置
|
|
||||||
required_configs = [
|
|
||||||
'ALIPAY_APP_ID',
|
|
||||||
'ALIPAY_APP_PRIVATE_KEY',
|
|
||||||
'ALIPAY_PUBLIC_KEY',
|
|
||||||
'ALIPAY_RETURN_URL',
|
|
||||||
'ALIPAY_NOTIFY_URL',
|
|
||||||
'ALIPAY_DEBUG'
|
|
||||||
]
|
|
||||||
|
|
||||||
print("检查必需的配置项:")
|
|
||||||
for config in required_configs:
|
|
||||||
if config in config_content:
|
|
||||||
print(f" ✓ {config}: 存在")
|
|
||||||
else:
|
|
||||||
print(f" ✗ {config}: 缺失")
|
|
||||||
|
|
||||||
# 提取并显示配置值(部分显示以保护安全)
|
|
||||||
import re
|
|
||||||
|
|
||||||
# 检查密钥格式
|
|
||||||
private_key_match = re.search(r'ALIPAY_APP_PRIVATE_KEY\s*=\s*"""(.*?)"""', config_content, re.DOTALL)
|
|
||||||
public_key_match = re.search(r'ALIPAY_PUBLIC_KEY\s*=\s*"""(.*?)"""', config_content, re.DOTALL)
|
|
||||||
|
|
||||||
if private_key_match:
|
|
||||||
private_key = private_key_match.group(1)
|
|
||||||
if "BEGIN RSA PRIVATE KEY" in private_key and "END RSA PRIVATE KEY" in private_key:
|
|
||||||
print(" ✓ 应用私钥格式正确")
|
|
||||||
else:
|
|
||||||
print(" ✗ 应用私钥格式错误")
|
|
||||||
else:
|
|
||||||
print(" ✗ 未找到应用私钥")
|
|
||||||
|
|
||||||
if public_key_match:
|
|
||||||
public_key = public_key_match.group(1)
|
|
||||||
if "BEGIN PUBLIC KEY" in public_key and "END PUBLIC KEY" in public_key:
|
|
||||||
print(" ✓ 支付宝公钥格式正确")
|
|
||||||
else:
|
|
||||||
print(" ✗ 支付宝公钥格式错误")
|
|
||||||
else:
|
|
||||||
print(" ✗ 未找到支付宝公钥")
|
|
||||||
|
|
||||||
# 检查调试模式
|
|
||||||
debug_match = re.search(r'ALIPAY_DEBUG\s*=\s*(True|False)', config_content)
|
|
||||||
if debug_match:
|
|
||||||
debug_mode = debug_match.group(1)
|
|
||||||
print(f" ✓ 调试模式: {debug_mode}")
|
|
||||||
if debug_mode == "False":
|
|
||||||
print(" 注意: 当前为正式环境模式,确保使用正式环境的密钥")
|
|
||||||
else:
|
|
||||||
print(" 提示: 当前为沙箱环境模式,适用于测试")
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print("常见问题排查")
|
|
||||||
print("="*60)
|
|
||||||
print("""
|
|
||||||
1. 密钥配置问题:
|
|
||||||
- 确保应用私钥和支付宝公钥格式正确
|
|
||||||
- 检查BEGIN/END标签是否完整
|
|
||||||
- 确认沙箱/正式环境配置一致
|
|
||||||
|
|
||||||
2. 回调地址问题:
|
|
||||||
- 确保ALIPAY_RETURN_URL和ALIPAY_NOTIFY_URL可以公网访问
|
|
||||||
- 检查URL是否拼写正确
|
|
||||||
|
|
||||||
3. 签名算法:
|
|
||||||
- SDK默认使用RSA2算法
|
|
||||||
- 确保支付宝开放平台应用设置中也是RSA2
|
|
||||||
|
|
||||||
4. 对于同步回调400错误:
|
|
||||||
- 主要是签名验证失败
|
|
||||||
- 需要正确处理sign和sign_type参数
|
|
||||||
- 确保使用正确的公钥验证
|
|
||||||
|
|
||||||
5. 环境问题:
|
|
||||||
- 如果使用沙箱环境,确保使用沙箱的AppID和密钥
|
|
||||||
- 确保服务器时间准确
|
|
||||||
""")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def analyze_callback_request():
|
|
||||||
"""分析提供的回调请求"""
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print("回调请求分析")
|
|
||||||
print("="*60)
|
|
||||||
|
|
||||||
sample_request = "GET /payment/return?charset=utf-8&out_trade_no=20260114192935cfcf96&method=alipay.trade.page.pay.return&total_amount=5.00&sign=ksH8Nov1SA9U4fgovUXv%2BXxmZccCDaqVhPmm%2BAPlGL8QgMYWDN7NSqDQTDoVshe2agHT11rNuVEXuApE3lVOnBPPbUvUlyMdaWpx/0GlFBRS0tezfdUcCQsShOTj4YdKwa2K0bfoqQeStupG0LFVipsWiga9WIryFU5JWDK3lDOuaVLiw2gLFMemsz/Xg14UPQMWcmlyXVGYzeLYvNmVRbQQjnJL8m%2BFOq5tqMgopEtZmAC4wstIwm7n1kOrV%2Bs/HBxMeQqWOTtFEbDkzbU8o%2BhS5%2BavQm5BUvFTmjbsVs6Npo5qmmTkI8dRvqRO1HzqSv6ymL8%2BpiguKEBmaFaBeg%3D%3D&trade_no=2026011422001420011429097835&auth_app_id=2021006126656681&version=1.0&app_id=2021006126656681&sign_type=RSA2&seller_id=2088802857784696×tamp=2026-01-14+19:30:21"
|
|
||||||
|
|
||||||
print("从您提供的请求中,我们可以看到:")
|
|
||||||
print("- 订单号: 20260114192935cfcf96")
|
|
||||||
print("- 支付金额: 5.00")
|
|
||||||
print("- 支付宝交易号: 2026011422001420011429097835")
|
|
||||||
print("- 签名类型: RSA2")
|
|
||||||
print("- 时间戳: 2026-01-14 19:30:21")
|
|
||||||
print("\n这些参数应该被正确传递给验证函数")
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print("解决方案")
|
|
||||||
print("="*60)
|
|
||||||
print("""
|
|
||||||
1. 确保签名验证时:
|
|
||||||
- 从参数中移除 'sign' 和 'sign_type' 字段
|
|
||||||
- 使用剩余参数进行签名验证
|
|
||||||
- 使用正确的支付宝公钥
|
|
||||||
|
|
||||||
2. 检查配置:
|
|
||||||
- 确认使用的AppID与请求中的app_id一致
|
|
||||||
- 确认密钥对正确配对
|
|
||||||
|
|
||||||
3. 日志查看:
|
|
||||||
- 运行应用后,进行一次支付测试
|
|
||||||
- 查看 logs/system.log 中的详细错误信息
|
|
||||||
- 检查具体的验证失败原因
|
|
||||||
|
|
||||||
4. 沙箱测试:
|
|
||||||
- 如果使用沙箱环境,请确保配置正确
|
|
||||||
- 使用沙箱提供的测试账号进行支付
|
|
||||||
""")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("支付宝配置检查工具\n")
|
|
||||||
|
|
||||||
check_alipay_config()
|
|
||||||
analyze_callback_request()
|
|
||||||
|
|
||||||
print("\n" + "="*60)
|
|
||||||
print("下一步操作建议")
|
|
||||||
print("="*60)
|
|
||||||
print("""
|
|
||||||
1. 运行应用: python app.py
|
|
||||||
2. 进行支付测试
|
|
||||||
3. 检查日志文件 logs/system.log
|
|
||||||
4. 查看具体的错误信息
|
|
||||||
5. 根据错误信息进行相应修复
|
|
||||||
""")
|
|
||||||
@ -7,6 +7,11 @@ class Config:
|
|||||||
# PostgreSQL 配置
|
# PostgreSQL 配置
|
||||||
SQLALCHEMY_DATABASE_URI = "postgresql://user_xREpkJ:password_DZz8DQ@331002.xyz:2022/ai_vision"
|
SQLALCHEMY_DATABASE_URI = "postgresql://user_xREpkJ:password_DZz8DQ@331002.xyz:2022/ai_vision"
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
"pool_recycle": 1800,
|
||||||
|
"pool_timeout": 30
|
||||||
|
}
|
||||||
|
|
||||||
# Redis 配置
|
# Redis 配置
|
||||||
REDIS_URL = "redis://:redis_WWjNyb@331002.xyz:2020/0"
|
REDIS_URL = "redis://:redis_WWjNyb@331002.xyz:2020/0"
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
数据库创建脚本
|
|
||||||
用于在 PostgreSQL 服务器上创建 ai_vision 数据库
|
|
||||||
"""
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.engine import url
|
|
||||||
|
|
||||||
# 数据库连接信息 (从 config 或直接指定)
|
|
||||||
DB_HOST = "331002.xyz"
|
|
||||||
DB_PORT = 2022
|
|
||||||
DB_USER = "user_xREpkJ"
|
|
||||||
DB_PASSWORD = "password_DZz8DQ"
|
|
||||||
DB_NAME = "ai_vision"
|
|
||||||
|
|
||||||
def create_database():
|
|
||||||
"""创建数据库"""
|
|
||||||
try:
|
|
||||||
# 连接到默认的 postgres 数据库
|
|
||||||
print(f"🔗 正在连接到 PostgreSQL 服务器 {DB_HOST}:{DB_PORT}...")
|
|
||||||
|
|
||||||
# 构造连接 URL (连接到 postgres 数据库以执行 CREATE DATABASE)
|
|
||||||
postgres_url = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/postgres"
|
|
||||||
engine = create_engine(postgres_url, isolation_level="AUTOCOMMIT")
|
|
||||||
|
|
||||||
with engine.connect() as conn:
|
|
||||||
# 检查数据库是否存在
|
|
||||||
result = conn.execute(text(f"SELECT 1 FROM pg_database WHERE datname = '{DB_NAME}'"))
|
|
||||||
exists = result.fetchone()
|
|
||||||
|
|
||||||
if exists:
|
|
||||||
print(f"✅ 数据库 {DB_NAME} 已经存在")
|
|
||||||
else:
|
|
||||||
# 创建数据库
|
|
||||||
print(f"🔧 正在创建数据库 {DB_NAME}...")
|
|
||||||
conn.execute(text(f'CREATE DATABASE {DB_NAME}'))
|
|
||||||
print(f"✅ 数据库 {DB_NAME} 创建成功!")
|
|
||||||
|
|
||||||
print(f"\n📊 数据库信息:")
|
|
||||||
print(f" 主机: {DB_HOST}:{DB_PORT}")
|
|
||||||
print(f" 数据库名: {DB_NAME}")
|
|
||||||
print(f" 用户: {DB_USER}")
|
|
||||||
print(f"\n💡 下一步:运行 python init_db.py 创建数据表")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 发生错误: {e}")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
create_database()
|
|
||||||
67
db_manager.py
Normal file
67
db_manager.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from app import create_app, db
|
||||||
|
from flask_migrate import upgrade, migrate, init, stamp
|
||||||
|
|
||||||
|
def init_migrations():
|
||||||
|
"""初始化迁移环境 (如果 migrations 文件夹不存在)"""
|
||||||
|
if not os.path.exists('migrations'):
|
||||||
|
print("📂 初始化 migrations 文件夹...")
|
||||||
|
with app.app_context():
|
||||||
|
init()
|
||||||
|
else:
|
||||||
|
print("✅ migrations 文件夹已存在,跳过初始化。")
|
||||||
|
|
||||||
|
def make_migrations(message="Auto update"):
|
||||||
|
"""生成迁移脚本 (检测 models.py 的变动)"""
|
||||||
|
print(f"📝 正在生成迁移脚本 (备注: {message})...")
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
migrate(message=message)
|
||||||
|
print("✅ 迁移脚本生成成功!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 生成失败 (可能是没有变动): {e}")
|
||||||
|
|
||||||
|
def apply_migrations():
|
||||||
|
"""应用迁移到数据库 (执行 SQL)"""
|
||||||
|
print("🚀 正在将变动应用到数据库...")
|
||||||
|
with app.app_context():
|
||||||
|
upgrade()
|
||||||
|
print("✅ 数据库结构已同步至最新版!")
|
||||||
|
|
||||||
|
def force_sync():
|
||||||
|
"""强制同步 (不仅是 Upgrade,还包括 create_all)"""
|
||||||
|
print("🔧 正在进行全量同步检查...")
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
# 标记当前状态为最新
|
||||||
|
stamp()
|
||||||
|
print("✅ 数据库表结构已确保存在。")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("\n🔧 数据库管理脚本使用说明:")
|
||||||
|
print(" python db_manager.py init -> 初始化迁移环境")
|
||||||
|
print(" python db_manager.py make -> 生成迁移文件 (检测 models.py 变动)")
|
||||||
|
print(" python db_manager.py up -> 执行更新 (修改数据库表结构)")
|
||||||
|
print(" python db_manager.py sync -> 一键同步模式 (生成 + 执行)")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
|
||||||
|
if cmd == 'init':
|
||||||
|
init_migrations()
|
||||||
|
elif cmd == 'make':
|
||||||
|
msg = sys.argv[2] if len(sys.argv) > 2 else "schema_update"
|
||||||
|
make_migrations(msg)
|
||||||
|
elif cmd == 'up':
|
||||||
|
apply_migrations()
|
||||||
|
elif cmd == 'sync':
|
||||||
|
# 一键傻瓜模式
|
||||||
|
init_migrations()
|
||||||
|
make_migrations("auto_sync")
|
||||||
|
apply_migrations()
|
||||||
|
else:
|
||||||
|
print("❌ 未知命令")
|
||||||
@ -1,22 +0,0 @@
|
|||||||
from sqlalchemy import create_engine, text
|
|
||||||
from config import Config
|
|
||||||
|
|
||||||
def migrate():
|
|
||||||
# 从 URI 解析连接参数
|
|
||||||
uri = Config.SQLALCHEMY_DATABASE_URI
|
|
||||||
print(f"正在手动连接数据库进行迁移 (SQLAlchemy)... ")
|
|
||||||
|
|
||||||
engine = create_engine(uri)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with engine.connect() as conn:
|
|
||||||
# 添加 api_key 字段
|
|
||||||
print("🔧 正在检查并添加 users.api_key 字段...")
|
|
||||||
conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS api_key VARCHAR(255);"))
|
|
||||||
conn.commit()
|
|
||||||
print("✅ 数据库字段 users.api_key 处理成功")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 迁移失败: {e}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate()
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
from sqlalchemy import create_engine, text
|
|
||||||
from config import Config
|
|
||||||
|
|
||||||
def fix_db():
|
|
||||||
# 从 SQLALCHEMY_DATABASE_URI 提取连接信息
|
|
||||||
uri = Config.SQLALCHEMY_DATABASE_URI
|
|
||||||
print(f"🔗 正在尝试连接数据库 (SQLAlchemy)... ")
|
|
||||||
|
|
||||||
engine = create_engine(uri)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with engine.connect() as conn:
|
|
||||||
# 检查并添加 points 字段
|
|
||||||
print("🔧 正在检查并添加 users.points 字段...")
|
|
||||||
conn.execute(text("""
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='points') THEN
|
|
||||||
ALTER TABLE users ADD COLUMN points INTEGER DEFAULT 2;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
"""))
|
|
||||||
conn.commit()
|
|
||||||
print("✅ 数据库字段 points 处理完成 (默认值 2)")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 数据库修复失败: {e}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
fix_db()
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
from app import app
|
|
||||||
from extensions import db
|
|
||||||
from models import User, Role, Permission
|
|
||||||
from sqlalchemy import text
|
|
||||||
import sys
|
|
||||||
|
|
||||||
def force_migrate():
|
|
||||||
with app.app_context():
|
|
||||||
print("🛠️ 开始强制迁移...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 1. 尝试清除该表的其他活动连接 (仅限 PostgreSQL)
|
|
||||||
print("🧹 正在清理数据库死锁...")
|
|
||||||
db.session.execute(text("""
|
|
||||||
SELECT pg_terminate_backend(pid)
|
|
||||||
FROM pg_stat_activity
|
|
||||||
WHERE datname = current_database()
|
|
||||||
AND pid <> pg_backend_pid();
|
|
||||||
"""))
|
|
||||||
db.session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠️ 清理连接跳过 (可能是权限问题): {e}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 2. 创建所有新表
|
|
||||||
print("📦 正在同步表结构...")
|
|
||||||
db.create_all()
|
|
||||||
|
|
||||||
# 3. 增加字段
|
|
||||||
print("📝 正在调整 users 表结构...")
|
|
||||||
db.session.execute(text('ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES roles(id)'))
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# 4. 初始化角色和权限
|
|
||||||
print("🚀 正在初始化 RBAC 权限数据...")
|
|
||||||
perms = {
|
|
||||||
'view_logs': '查看系统日志',
|
|
||||||
'manage_rbac': '管理角色与权限',
|
|
||||||
'manage_users': '管理用户信息',
|
|
||||||
'manage_system': '系统最高权限'
|
|
||||||
}
|
|
||||||
perm_objs = {}
|
|
||||||
for code, desc in perms.items():
|
|
||||||
p = Permission.query.filter_by(name=code).first()
|
|
||||||
if not p:
|
|
||||||
p = Permission(name=code, description=desc)
|
|
||||||
db.session.add(p)
|
|
||||||
db.session.flush()
|
|
||||||
perm_objs[code] = p
|
|
||||||
|
|
||||||
# 创建管理员角色
|
|
||||||
admin_role = Role.query.filter_by(name='超级管理员').first()
|
|
||||||
if not admin_role:
|
|
||||||
admin_role = Role(name='超级管理员', description='系统最高权限持有者')
|
|
||||||
admin_role.permissions = list(perm_objs.values())
|
|
||||||
db.session.add(admin_role)
|
|
||||||
|
|
||||||
# 创建用户角色
|
|
||||||
user_role = Role.query.filter_by(name='普通用户').first()
|
|
||||||
if not user_role:
|
|
||||||
user_role = Role(name='普通用户', description='常规功能使用者')
|
|
||||||
db.session.add(user_role)
|
|
||||||
|
|
||||||
db.session.flush()
|
|
||||||
|
|
||||||
# 5. 修复旧数据:把所有现有用户设为超级管理员(方便你第一时间进入后台)
|
|
||||||
print("👤 正在升级现有用户为管理员...")
|
|
||||||
all_users = User.query.all()
|
|
||||||
for u in all_users:
|
|
||||||
u.role = admin_role
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
print("✨ 迁移与初始化全部完成!")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 运行出错: {e}")
|
|
||||||
db.session.rollback()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
force_migrate()
|
|
||||||
53
init_db.py
53
init_db.py
@ -1,53 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
数据库初始化脚本
|
|
||||||
用于手动创建或重置数据库表
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app import app
|
|
||||||
from extensions import db
|
|
||||||
import models
|
|
||||||
|
|
||||||
def init_database():
|
|
||||||
"""初始化数据库表"""
|
|
||||||
with app.app_context():
|
|
||||||
print("🔧 开始初始化数据库...")
|
|
||||||
|
|
||||||
# 创建所有表
|
|
||||||
db.create_all()
|
|
||||||
|
|
||||||
# 检查表是否创建成功
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
inspector = inspect(db.engine)
|
|
||||||
tables = inspector.get_table_names()
|
|
||||||
|
|
||||||
print(f"\n✅ 数据库表已创建,共 {len(tables)} 张表:")
|
|
||||||
for table in tables:
|
|
||||||
print(f" - {table}")
|
|
||||||
|
|
||||||
print("\n📊 表结构详情:")
|
|
||||||
for table_name in tables:
|
|
||||||
columns = inspector.get_columns(table_name)
|
|
||||||
print(f"\n{table_name}:")
|
|
||||||
for col in columns:
|
|
||||||
print(f" {col['name']} ({col['type']})")
|
|
||||||
|
|
||||||
def drop_all_tables():
|
|
||||||
"""删除所有表(慎用)"""
|
|
||||||
with app.app_context():
|
|
||||||
print("⚠️ 警告:即将删除所有数据库表!")
|
|
||||||
confirm = input("确认删除?输入 yes 继续: ")
|
|
||||||
if confirm.lower() == 'yes':
|
|
||||||
db.drop_all()
|
|
||||||
print("✅ 所有表已删除")
|
|
||||||
else:
|
|
||||||
print("❌ 操作已取消")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
import sys
|
|
||||||
|
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == '--drop':
|
|
||||||
drop_all_tables()
|
|
||||||
else:
|
|
||||||
init_database()
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
import requests
|
|
||||||
from app import app
|
|
||||||
from extensions import db
|
|
||||||
from models import SystemDict
|
|
||||||
from config import Config
|
|
||||||
|
|
||||||
def fetch_and_init():
|
|
||||||
with app.app_context():
|
|
||||||
# 定义需要抓取的字典代码及对应的本地类型
|
|
||||||
target_mappings = {
|
|
||||||
"nano_model": "ai_model",
|
|
||||||
"aspect_ratio": "aspect_ratio",
|
|
||||||
"ai_prompt": "prompt_tpl",
|
|
||||||
"ai_image_size": "ai_image_size"
|
|
||||||
}
|
|
||||||
|
|
||||||
print("🚀 开始从远程接口获取字典数据...")
|
|
||||||
|
|
||||||
for remote_code, local_type in target_mappings.items():
|
|
||||||
try:
|
|
||||||
url = f"{Config.DICT_URL}?platform={Config.PLATFORM}&code={remote_code}"
|
|
||||||
response = requests.get(url, verify=False, timeout=15)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
data = response.json().get("data", [])
|
|
||||||
print(f"📦 抓取到 {remote_code} ({len(data)} 条数据)")
|
|
||||||
|
|
||||||
for item in data:
|
|
||||||
label = item.get("label")
|
|
||||||
value = item.get("value")
|
|
||||||
|
|
||||||
# 检查本地是否已存在
|
|
||||||
exists = SystemDict.query.filter_by(dict_type=local_type, value=value).first()
|
|
||||||
if not exists:
|
|
||||||
new_dict = SystemDict(
|
|
||||||
dict_type=local_type,
|
|
||||||
label=label,
|
|
||||||
value=value,
|
|
||||||
cost=1 if local_type == 'ai_model' else 0, # 模型默认 1 积分,其余 0
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
db.session.add(new_dict)
|
|
||||||
else:
|
|
||||||
print(f"❌ 抓取 {remote_code} 失败: HTTP {response.status_code}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"⚠️ 抓取 {remote_code} 发生异常: {e}")
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
print("\n✅ 字典数据本地化初始化成功!")
|
|
||||||
print("💡 您现在可以直接在数据库 system_dicts 表中修改模型的 cost (积分消耗) 字段。")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
fetch_and_init()
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
from app import app
|
|
||||||
from extensions import db
|
|
||||||
from models import SystemNotification
|
|
||||||
|
|
||||||
def init_notifications():
|
|
||||||
with app.app_context():
|
|
||||||
# 检查是否已存在通知
|
|
||||||
if SystemNotification.query.first():
|
|
||||||
print("📅 通知系统已初始化,跳过。")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 创建欢迎通知
|
|
||||||
welcome_notif = SystemNotification(
|
|
||||||
title="✨ 欢迎使用 AI 视界 2.0",
|
|
||||||
content="""感谢您体验我们的 AI 创作平台!
|
|
||||||
|
|
||||||
1. 我们已上线“优质渲染模式”,支持更精细的画面细节。
|
|
||||||
2. 积分充值功能正在最后联调中,敬请期待。
|
|
||||||
3. 提示词区域现已支持自动折叠,界面更简洁。
|
|
||||||
|
|
||||||
如果您有任何建议,欢迎通过系统审计日志联系管理员。""",
|
|
||||||
is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
db.session.add(welcome_notif)
|
|
||||||
db.session.commit()
|
|
||||||
print("✅ 系统欢迎通知已发布!")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
init_notifications()
|
|
||||||
58
init_rbac.py
58
init_rbac.py
@ -1,58 +0,0 @@
|
|||||||
from app import app
|
|
||||||
from extensions import db
|
|
||||||
from models import User, Role, Permission
|
|
||||||
|
|
||||||
def init_rbac():
|
|
||||||
with app.app_context():
|
|
||||||
print("🚀 正在初始化 RBAC 系统...")
|
|
||||||
|
|
||||||
# 1. 创建基础权限
|
|
||||||
perms = {
|
|
||||||
'view_logs': '查看系统日志',
|
|
||||||
'manage_rbac': '管理角色与权限',
|
|
||||||
'manage_users': '管理用户信息',
|
|
||||||
'manage_dicts': '管理系统字典',
|
|
||||||
'manage_notifications': '管理系统通知',
|
|
||||||
'manage_system': '系统最高权限'
|
|
||||||
}
|
|
||||||
perm_objs = {}
|
|
||||||
for code, desc in perms.items():
|
|
||||||
p = Permission.query.filter_by(name=code).first()
|
|
||||||
if not p:
|
|
||||||
p = Permission(name=code, description=desc)
|
|
||||||
db.session.add(p)
|
|
||||||
perm_objs[code] = p
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# 2. 创建基础角色
|
|
||||||
# 超级管理员角色
|
|
||||||
admin_role = Role.query.filter_by(name='超级管理员').first()
|
|
||||||
if not admin_role:
|
|
||||||
admin_role = Role(name='超级管理员', description='系统最高权限持有者')
|
|
||||||
admin_role.permissions = list(perm_objs.values())
|
|
||||||
db.session.add(admin_role)
|
|
||||||
else:
|
|
||||||
# 确保现有超级管理员拥有所有新权限
|
|
||||||
admin_role.permissions = list(perm_objs.values())
|
|
||||||
|
|
||||||
# 普通用户角色
|
|
||||||
user_role = Role.query.filter_by(name='普通用户').first()
|
|
||||||
if not user_role:
|
|
||||||
user_role = Role(name='普通用户', description='常规功能使用者')
|
|
||||||
db.session.add(user_role)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# 3. 为现有用户分配超级管理员角色(作为测试)
|
|
||||||
# 请根据实际情况修改
|
|
||||||
first_user = User.query.first()
|
|
||||||
if first_user:
|
|
||||||
first_user.role = admin_role
|
|
||||||
db.session.commit()
|
|
||||||
print(f"✅ 已将用户 {first_user.phone} 设为超级管理员")
|
|
||||||
|
|
||||||
print("✨ RBAC 初始化完成")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
init_rbac()
|
|
||||||
@ -14,6 +14,17 @@ def login_required(f):
|
|||||||
return jsonify({"error": "请先登录", "code": 401}), 401
|
return jsonify({"error": "请先登录", "code": 401}), 401
|
||||||
# 记录当前路径以便登录后跳转
|
# 记录当前路径以便登录后跳转
|
||||||
return redirect(url_for('auth.login_page', next=request.path))
|
return redirect(url_for('auth.login_page', next=request.path))
|
||||||
|
|
||||||
|
# 增加封禁检查
|
||||||
|
from extensions import db
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user and user.is_banned:
|
||||||
|
session.pop('user_id', None) # 强制踢下线
|
||||||
|
system_logger.error(f"遭封禁用户尝试访问: {request.path}", user_id=user_id)
|
||||||
|
if request.path.startswith('/api/'):
|
||||||
|
return jsonify({"error": "您的账号已被封禁", "code": 403}), 403
|
||||||
|
return redirect(url_for('auth.login_page', error="您的账号已被封禁"))
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
@ -30,7 +41,17 @@ def permission_required(perm_name):
|
|||||||
return redirect(url_for('auth.login_page', next=request.path))
|
return redirect(url_for('auth.login_page', next=request.path))
|
||||||
|
|
||||||
user = User.query.get(user_id)
|
user = User.query.get(user_id)
|
||||||
if not user or not user.has_permission(perm_name):
|
if not user or user.is_banned:
|
||||||
|
if user and user.is_banned:
|
||||||
|
session.pop('user_id', None)
|
||||||
|
system_logger.error(f"遭封禁用户通过权限检查尝试: {request.path}", user_id=user_id)
|
||||||
|
|
||||||
|
if request.path.startswith('/api/'):
|
||||||
|
msg = "您的账号已被封禁" if user and user.is_banned else f"需要权限: {perm_name}"
|
||||||
|
return jsonify({"error": msg, "code": 403}), 403
|
||||||
|
return redirect(url_for('index', error="权限不足或账号已封禁"))
|
||||||
|
|
||||||
|
if not user.has_permission(perm_name):
|
||||||
system_logger.warning(f"未授权访问尝试 (权限不足: {perm_name}): {request.path}", user_id=user_id, ip=request.remote_addr)
|
system_logger.warning(f"未授权访问尝试 (权限不足: {perm_name}): {request.path}", user_id=user_id, ip=request.remote_addr)
|
||||||
if request.path.startswith('/api/'):
|
if request.path.startswith('/api/'):
|
||||||
return jsonify({"error": f"需要权限: {perm_name}", "code": 403}), 403
|
return jsonify({"error": f"需要权限: {perm_name}", "code": 403}), 403
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
from app import app
|
|
||||||
from extensions import db
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
try:
|
|
||||||
# 尝试添加 api_key 字段到 users 表
|
|
||||||
db.session.execute(text('ALTER TABLE users ADD COLUMN IF NOT EXISTS api_key VARCHAR(255)'))
|
|
||||||
db.session.commit()
|
|
||||||
print("✅ 数据库字段 users.api_key 同步成功")
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
print(f"❌ 同步失败: {e}")
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
from app import app
|
|
||||||
from extensions import db
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
def migrate():
|
|
||||||
with app.app_context():
|
|
||||||
print("🔧 正在为 users 表增加 role 字段...")
|
|
||||||
try:
|
|
||||||
# 使用原生 SQL 增加字段
|
|
||||||
db.session.execute(text('ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT \'user\''))
|
|
||||||
db.session.commit()
|
|
||||||
print("✅ 字段增加成功")
|
|
||||||
|
|
||||||
# 设置管理员(可选,方便您测试)
|
|
||||||
# db.session.execute(text("UPDATE users SET role = 'admin' WHERE phone = '您的手机号'"))
|
|
||||||
# db.session.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 迁移失败: {e}")
|
|
||||||
db.session.rollback()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
migrate()
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
from app import app
|
|
||||||
from extensions import db
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
def migrate():
|
|
||||||
with app.app_context():
|
|
||||||
print("🔧 正在平滑迁移至 RBAC 体系...")
|
|
||||||
try:
|
|
||||||
# 1. 创建新表
|
|
||||||
db.create_all()
|
|
||||||
|
|
||||||
# 2. 修改 users 表结构
|
|
||||||
# 增加 role_id
|
|
||||||
db.session.execute(text('ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES roles(id)'))
|
|
||||||
|
|
||||||
# 3. 尝试迁移旧数据:如果旧的 role 字段存在且值为 'admin',则关联超级管理员角色
|
|
||||||
# 我们先执行初始化脚本创建角色
|
|
||||||
from init_rbac import init_rbac
|
|
||||||
init_rbac()
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
print("✅ 数据库结构迁移成功")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 迁移失败: {e}")
|
|
||||||
db.session.rollback()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
migrate()
|
|
||||||
32
migrations/versions/0cc7ce54ecc0_auto_sync.py
Normal file
32
migrations/versions/0cc7ce54ecc0_auto_sync.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""auto_sync
|
||||||
|
|
||||||
|
Revision ID: 0cc7ce54ecc0
|
||||||
|
Revises: a77f97f56b61
|
||||||
|
Create Date: 2026-01-17 22:41:07.264297
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0cc7ce54ecc0'
|
||||||
|
down_revision = 'a77f97f56b61'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('generation_records', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('cost', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('generation_records', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('cost')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
32
migrations/versions/a77f97f56b61_auto_sync.py
Normal file
32
migrations/versions/a77f97f56b61_auto_sync.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""auto_sync
|
||||||
|
|
||||||
|
Revision ID: a77f97f56b61
|
||||||
|
Revises: 9024b393e1ef
|
||||||
|
Create Date: 2026-01-17 22:32:23.254493
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a77f97f56b61'
|
||||||
|
down_revision = '9024b393e1ef'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('is_banned', sa.Boolean(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('is_banned')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
41
models.py
41
models.py
@ -1,7 +1,14 @@
|
|||||||
from extensions import db
|
from extensions import db
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
def to_bj_time(dt):
|
||||||
|
"""将 UTC 时间转换为北京时间 (UTC+8)"""
|
||||||
|
if not dt:
|
||||||
|
return None
|
||||||
|
return dt + timedelta(hours=8)
|
||||||
|
|
||||||
|
|
||||||
# 角色与权限的多对多关联表
|
# 角色与权限的多对多关联表
|
||||||
role_permissions = db.Table('role_permissions',
|
role_permissions = db.Table('role_permissions',
|
||||||
db.Column('role_id', db.Integer, db.ForeignKey('roles.id'), primary_key=True),
|
db.Column('role_id', db.Integer, db.ForeignKey('roles.id'), primary_key=True),
|
||||||
@ -20,7 +27,9 @@ class Role(db.Model):
|
|||||||
name = db.Column(db.String(50), unique=True, nullable=False) # 如: '超级管理员', '普通用户'
|
name = db.Column(db.String(50), unique=True, nullable=False) # 如: '超级管理员', '普通用户'
|
||||||
description = db.Column(db.String(100))
|
description = db.Column(db.String(100))
|
||||||
# 角色拥有的权限
|
# 角色拥有的权限
|
||||||
permissions = db.relationship('Permission', secondary=role_permissions, backref=db.backref('roles', lazy='dynamic'))
|
permissions = db.relationship('Permission', secondary=role_permissions,
|
||||||
|
backref=db.backref('roles', lazy='dynamic'),
|
||||||
|
order_by='Permission.id')
|
||||||
|
|
||||||
class User(db.Model):
|
class User(db.Model):
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
@ -31,10 +40,15 @@ class User(db.Model):
|
|||||||
api_key = db.Column(db.String(255)) # 存储用户的 API Key
|
api_key = db.Column(db.String(255)) # 存储用户的 API Key
|
||||||
points = db.Column(db.Integer, default=2) # 账户积分,默认赠送2次试用
|
points = db.Column(db.Integer, default=2) # 账户积分,默认赠送2次试用
|
||||||
has_used_points = db.Column(db.Boolean, default=False) # 是否使用过积分
|
has_used_points = db.Column(db.Boolean, default=False) # 是否使用过积分
|
||||||
|
is_banned = db.Column(db.Boolean, default=False) # 账号是否被封禁
|
||||||
# 关联角色 ID
|
# 关联角色 ID
|
||||||
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
|
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def created_at_bj(self):
|
||||||
|
return to_bj_time(self.created_at)
|
||||||
|
|
||||||
# 关系映射
|
# 关系映射
|
||||||
role = db.relationship('Role', backref=db.backref('users', lazy='dynamic'))
|
role = db.relationship('Role', backref=db.backref('users', lazy='dynamic'))
|
||||||
|
|
||||||
@ -78,10 +92,15 @@ class GenerationRecord(db.Model):
|
|||||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
prompt = db.Column(db.Text)
|
prompt = db.Column(db.Text)
|
||||||
model = db.Column(db.String(100))
|
model = db.Column(db.String(100))
|
||||||
|
cost = db.Column(db.Integer, default=0) # 消耗积分
|
||||||
# 存储生成的图片 URL 列表 (JSON 字符串)
|
# 存储生成的图片 URL 列表 (JSON 字符串)
|
||||||
image_urls = db.Column(db.Text)
|
image_urls = db.Column(db.Text)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def created_at_bj(self):
|
||||||
|
return to_bj_time(self.created_at)
|
||||||
|
|
||||||
user = db.relationship('User', backref=db.backref('records', lazy='dynamic', order_by='GenerationRecord.created_at.desc()'))
|
user = db.relationship('User', backref=db.backref('records', lazy='dynamic', order_by='GenerationRecord.created_at.desc()'))
|
||||||
|
|
||||||
# 用户已读通知关联表
|
# 用户已读通知关联表
|
||||||
@ -100,6 +119,10 @@ class SystemNotification(db.Model):
|
|||||||
is_active = db.Column(db.Boolean, default=True)
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def created_at_bj(self):
|
||||||
|
return to_bj_time(self.created_at)
|
||||||
|
|
||||||
# 哪些用户已读
|
# 哪些用户已读
|
||||||
read_by_users = db.relationship('User', secondary=notification_reads, backref=db.backref('read_notifications', lazy='dynamic'))
|
read_by_users = db.relationship('User', secondary=notification_reads, backref=db.backref('read_notifications', lazy='dynamic'))
|
||||||
|
|
||||||
@ -117,6 +140,14 @@ class Order(db.Model):
|
|||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
paid_at = db.Column(db.DateTime)
|
paid_at = db.Column(db.DateTime)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def created_at_bj(self):
|
||||||
|
return to_bj_time(self.created_at)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paid_at_bj(self):
|
||||||
|
return to_bj_time(self.paid_at)
|
||||||
|
|
||||||
user = db.relationship('User', backref=db.backref('orders', lazy='dynamic', order_by='Order.created_at.desc()'))
|
user = db.relationship('User', backref=db.backref('orders', lazy='dynamic', order_by='Order.created_at.desc()'))
|
||||||
|
|
||||||
class SystemLog(db.Model):
|
class SystemLog(db.Model):
|
||||||
@ -136,6 +167,10 @@ class SystemLog(db.Model):
|
|||||||
method = db.Column(db.String(10))
|
method = db.Column(db.String(10))
|
||||||
user_agent = db.Column(db.String(255))
|
user_agent = db.Column(db.String(255))
|
||||||
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def created_at_bj(self):
|
||||||
|
return to_bj_time(self.created_at)
|
||||||
|
|
||||||
user = db.relationship('User', backref=db.backref('logs', lazy='dynamic', order_by='SystemLog.created_at.desc()'))
|
user = db.relationship('User', backref=db.backref('logs', lazy='dynamic', order_by='SystemLog.created_at.desc()'))
|
||||||
|
|||||||
0
services/__init__.py
Normal file
0
services/__init__.py
Normal file
34
services/file_service.py
Normal file
34
services/file_service.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from urllib.parse import quote
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from config import Config
|
||||||
|
from extensions import s3_client
|
||||||
|
|
||||||
|
def handle_file_uploads(files):
|
||||||
|
"""处理文件上传到 MinIO"""
|
||||||
|
img_urls = []
|
||||||
|
for f in files:
|
||||||
|
ext = os.path.splitext(f.filename)[1]
|
||||||
|
filename = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
s3_client.upload_fileobj(
|
||||||
|
f, Config.MINIO["bucket"], filename,
|
||||||
|
ExtraArgs={"ContentType": f.content_type}
|
||||||
|
)
|
||||||
|
img_urls.append(f"{Config.MINIO['public_url']}{quote(filename)}")
|
||||||
|
return img_urls
|
||||||
|
|
||||||
|
def get_remote_file_stream(url):
|
||||||
|
"""获取远程文件的流"""
|
||||||
|
req = requests.get(url, stream=True, timeout=60)
|
||||||
|
req.raise_for_status()
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if req.headers.get('Content-Type'):
|
||||||
|
headers['Content-Type'] = req.headers['Content-Type']
|
||||||
|
else:
|
||||||
|
headers['Content-Type'] = 'application/octet-stream'
|
||||||
|
|
||||||
|
return req, headers
|
||||||
156
services/generation_service.py
Normal file
156
services/generation_service.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
from config import Config
|
||||||
|
from models import SystemDict, GenerationRecord, User, db
|
||||||
|
from services.logger import system_logger
|
||||||
|
from services.task_service import process_image_generation, process_video_generation
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import threading
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
def get_model_cost(model_value, is_video=False):
|
||||||
|
"""获取模型消耗积分"""
|
||||||
|
dict_type = 'video_model' if is_video else 'ai_model'
|
||||||
|
model_dict = SystemDict.query.filter_by(dict_type=dict_type, value=model_value).first()
|
||||||
|
|
||||||
|
if model_dict:
|
||||||
|
return model_dict.cost
|
||||||
|
|
||||||
|
# Default costs
|
||||||
|
if is_video:
|
||||||
|
return 15 if "pro" in model_value.lower() or "3.1" in model_value else 10
|
||||||
|
else:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def validate_generation_request(user, data):
|
||||||
|
"""验证生图请求并返回配置 (api_key, target_api, cost, use_trial)"""
|
||||||
|
mode = data.get('mode', 'trial')
|
||||||
|
is_premium = data.get('is_premium', False)
|
||||||
|
input_key = data.get('apiKey')
|
||||||
|
model_value = data.get('model')
|
||||||
|
|
||||||
|
target_api = Config.AI_API
|
||||||
|
api_key = None
|
||||||
|
use_trial = False
|
||||||
|
|
||||||
|
if mode == 'key':
|
||||||
|
api_key = input_key or user.api_key
|
||||||
|
if not api_key:
|
||||||
|
return None, None, 0, False, "请先输入您的 API 密钥"
|
||||||
|
|
||||||
|
# Update user key if changed
|
||||||
|
if input_key and input_key != user.api_key:
|
||||||
|
user.api_key = input_key
|
||||||
|
db.session.commit()
|
||||||
|
else:
|
||||||
|
if user.points > 0:
|
||||||
|
api_key = Config.PREMIUM_KEY if is_premium else Config.TRIAL_KEY
|
||||||
|
target_api = Config.TRIAL_API
|
||||||
|
use_trial = True
|
||||||
|
else:
|
||||||
|
return None, None, 0, False, "可用积分已耗尽,请充值或切换至自定义 Key 模式"
|
||||||
|
|
||||||
|
cost = get_model_cost(model_value, is_video=False)
|
||||||
|
if use_trial and is_premium:
|
||||||
|
cost *= 2
|
||||||
|
|
||||||
|
if use_trial:
|
||||||
|
if user.points < cost:
|
||||||
|
return None, None, cost, True, "可用积分不足"
|
||||||
|
|
||||||
|
return api_key, target_api, cost, use_trial, None
|
||||||
|
|
||||||
|
def deduct_points(user, cost):
|
||||||
|
"""扣除积分"""
|
||||||
|
user.points -= cost
|
||||||
|
user.has_used_points = True
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def refund_points(user_id, cost):
|
||||||
|
"""退还积分"""
|
||||||
|
try:
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user:
|
||||||
|
user.points += cost
|
||||||
|
db.session.commit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost):
|
||||||
|
"""同步处理对话类模型"""
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
|
chat_payload = {
|
||||||
|
"model": model_value,
|
||||||
|
"messages": [{"role": "user", "content": prompt}]
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(Config.CHAT_API, json=chat_payload, headers=headers, timeout=120)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
if use_trial:
|
||||||
|
refund_points(user_id, cost)
|
||||||
|
return {"error": resp.text}, resp.status_code
|
||||||
|
|
||||||
|
api_result = resp.json()
|
||||||
|
content = api_result['choices'][0]['message']['content']
|
||||||
|
|
||||||
|
# 记录聊天历史
|
||||||
|
if prompt != "解读验光单":
|
||||||
|
new_record = GenerationRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
prompt=prompt,
|
||||||
|
model=model_value,
|
||||||
|
cost=cost,
|
||||||
|
image_urls=json.dumps([{"type": "text", "content": content}])
|
||||||
|
)
|
||||||
|
db.session.add(new_record)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": [{"content": content, "type": "text"}],
|
||||||
|
"message": "生成成功!"
|
||||||
|
}, 200
|
||||||
|
except Exception as e:
|
||||||
|
if use_trial:
|
||||||
|
refund_points(user_id, cost)
|
||||||
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
|
def start_async_image_task(app, user_id, payload, api_key, target_api, cost, mode, model_value):
|
||||||
|
"""启动异步生图任务"""
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
log_msg = "用户发起验光单解读" if payload.get('prompt') == "解读验光单" else "用户发起生图任务"
|
||||||
|
system_logger.info(log_msg, model=model_value, mode=mode)
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=process_image_generation,
|
||||||
|
args=(app, user_id, task_id, payload, api_key, target_api, cost)
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
def validate_video_request(user, data):
|
||||||
|
"""验证视频生成请求"""
|
||||||
|
if user.points <= 0:
|
||||||
|
return None, 0, "可用积分不足,请先充值"
|
||||||
|
|
||||||
|
model_value = data.get('model', 'veo3.1')
|
||||||
|
cost = get_model_cost(model_value, is_video=True)
|
||||||
|
|
||||||
|
if user.points < cost:
|
||||||
|
return None, cost, f"积分不足,生成该视频需要 {cost} 积分"
|
||||||
|
|
||||||
|
return model_value, cost, None
|
||||||
|
|
||||||
|
def start_async_video_task(app, user_id, payload, cost, model_value):
|
||||||
|
"""启动异步视频任务"""
|
||||||
|
api_key = Config.TRIAL_KEY
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
system_logger.info("用户发起视频生成任务 (积分模式)", model=model_value, cost=cost)
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=process_video_generation,
|
||||||
|
args=(app, user_id, task_id, payload, api_key, cost)
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return task_id
|
||||||
55
services/history_service.py
Normal file
55
services/history_service.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
from models import GenerationRecord, to_bj_time
|
||||||
|
from flask import request
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
def get_user_history_data(user_id, page=1, per_page=10, filter_type='all'):
|
||||||
|
"""获取用户历史记录业务逻辑"""
|
||||||
|
|
||||||
|
# 计算 90 天前的时间
|
||||||
|
ninety_days_ago = datetime.now() - timedelta(days=90)
|
||||||
|
|
||||||
|
query = GenerationRecord.query.filter(
|
||||||
|
GenerationRecord.user_id == user_id,
|
||||||
|
GenerationRecord.created_at >= ninety_days_ago,
|
||||||
|
GenerationRecord.prompt != "解读验光单"
|
||||||
|
)
|
||||||
|
|
||||||
|
if filter_type == 'video':
|
||||||
|
# 只看视频
|
||||||
|
query = query.filter(GenerationRecord.image_urls.like('%"type": "video"%'))
|
||||||
|
elif filter_type == 'image':
|
||||||
|
# 只看图片 (排除视频)
|
||||||
|
query = query.filter(~GenerationRecord.image_urls.like('%"type": "video"%'))
|
||||||
|
|
||||||
|
pagination = query.order_by(GenerationRecord.created_at.desc())\
|
||||||
|
.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
# 格式化 URL,兼容新旧数据格式
|
||||||
|
history_list = []
|
||||||
|
for r in pagination.items:
|
||||||
|
raw_urls = json.loads(r.image_urls)
|
||||||
|
formatted_urls = []
|
||||||
|
for u in raw_urls:
|
||||||
|
if isinstance(u, str):
|
||||||
|
# 旧数据:直接返回原图作为缩略图
|
||||||
|
formatted_urls.append({"url": u, "thumb": u})
|
||||||
|
else:
|
||||||
|
# 如果是视频类型,提供默认预览图 (此处使用一个公共视频占位图或空)
|
||||||
|
if u.get('type') == 'video' and not u.get('thumb'):
|
||||||
|
u['thumb'] = "https://img.icons8.com/flat-round/64/000000/play--v1.png"
|
||||||
|
formatted_urls.append(u)
|
||||||
|
|
||||||
|
history_list.append({
|
||||||
|
"id": r.id,
|
||||||
|
"prompt": r.prompt,
|
||||||
|
"model": r.model,
|
||||||
|
"urls": formatted_urls,
|
||||||
|
"created_at": r.created_at_bj.strftime('%m-%d %H:%M')
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"history": history_list,
|
||||||
|
"has_next": pagination.has_next,
|
||||||
|
"total": pagination.total
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from extensions import redis_client, db
|
from extensions import redis_client, db
|
||||||
import json
|
import json
|
||||||
@ -42,13 +42,14 @@ class SystemLogger:
|
|||||||
def _push_to_redis(self, level, message, extra=None):
|
def _push_to_redis(self, level, message, extra=None):
|
||||||
"""推送到 Redis 并保留 30 天数据"""
|
"""推送到 Redis 并保留 30 天数据"""
|
||||||
try:
|
try:
|
||||||
now = datetime.now()
|
now = datetime.utcnow()
|
||||||
|
bj_now = now + timedelta(hours=8)
|
||||||
user_id = None
|
user_id = None
|
||||||
if has_request_context():
|
if has_request_context():
|
||||||
user_id = g.get('user_id') or (getattr(g, 'user', None).id if hasattr(g, 'user') and g.user else None)
|
user_id = g.get('user_id') or (getattr(g, 'user', None).id if hasattr(g, 'user') and g.user else None)
|
||||||
|
|
||||||
log_entry = {
|
log_entry = {
|
||||||
"time": now.strftime('%Y-%m-%d %H:%M:%S'),
|
"time": bj_now.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
"level": level,
|
"level": level,
|
||||||
"message": message,
|
"message": message,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@ -75,7 +76,7 @@ class SystemLogger:
|
|||||||
'module': module,
|
'module': module,
|
||||||
'user_id': extra.get('user_id') if extra else None,
|
'user_id': extra.get('user_id') if extra else None,
|
||||||
'extra': json.dumps(extra, ensure_ascii=False) if extra else None,
|
'extra': json.dumps(extra, ensure_ascii=False) if extra else None,
|
||||||
'created_at': datetime.now()
|
'created_at': datetime.utcnow()
|
||||||
}
|
}
|
||||||
|
|
||||||
# 捕获请求上下文信息
|
# 捕获请求上下文信息
|
||||||
|
|||||||
65
services/stats_service.py
Normal file
65
services/stats_service.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
from models import GenerationRecord, Order, db, to_bj_time
|
||||||
|
from sqlalchemy import func
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
def get_point_stats(user_id, days=7):
|
||||||
|
"""获取用户积分消耗统计数据 (用于图表)"""
|
||||||
|
end_date = datetime.utcnow()
|
||||||
|
start_date = end_date - timedelta(days=days-1)
|
||||||
|
|
||||||
|
# 1. 获取消耗统计 (从 GenerationRecord)
|
||||||
|
# 按天分组汇总结算
|
||||||
|
deductions = db.session.query(
|
||||||
|
func.date(GenerationRecord.created_at).label('date'),
|
||||||
|
func.sum(GenerationRecord.cost).label('total_cost')
|
||||||
|
).filter(
|
||||||
|
GenerationRecord.user_id == user_id,
|
||||||
|
GenerationRecord.created_at >= start_date.date()
|
||||||
|
).group_by(func.date(GenerationRecord.created_at)).all()
|
||||||
|
|
||||||
|
# 2. 获取充值统计 (从 Order)
|
||||||
|
incomes = db.session.query(
|
||||||
|
func.date(Order.created_at).label('date'),
|
||||||
|
func.sum(Order.points).label('total_points')
|
||||||
|
).filter(
|
||||||
|
Order.user_id == user_id,
|
||||||
|
Order.status == 'PAID',
|
||||||
|
Order.created_at >= start_date.date()
|
||||||
|
).group_by(func.date(Order.created_at)).all()
|
||||||
|
|
||||||
|
# 3. 补齐日期,生成连续数据
|
||||||
|
date_list = [(start_date + timedelta(days=i)).strftime('%m-%d') for i in range(days)]
|
||||||
|
deduction_map = {d.date.strftime('%m-%d'): int(d.total_cost or 0) for d in deductions}
|
||||||
|
income_map = {i.date.strftime('%m-%d'): int(i.total_points or 0) for i in incomes}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"labels": date_list,
|
||||||
|
"deductions": [deduction_map.get(d, 0) for d in date_list],
|
||||||
|
"incomes": [income_map.get(d, 0) for d in date_list]
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_point_details(user_id, page=1, per_page=20):
|
||||||
|
"""获取积分变动明细列表"""
|
||||||
|
pagination = GenerationRecord.query.filter(
|
||||||
|
GenerationRecord.user_id == user_id,
|
||||||
|
GenerationRecord.cost > 0
|
||||||
|
).order_by(GenerationRecord.created_at.desc()).paginate(
|
||||||
|
page=page, per_page=per_page, error_out=False
|
||||||
|
)
|
||||||
|
|
||||||
|
details = []
|
||||||
|
for r in pagination.items:
|
||||||
|
details.append({
|
||||||
|
"type": "deduction",
|
||||||
|
"desc": r.prompt[:30] + "..." if r.prompt else "AI 生成",
|
||||||
|
"model": r.model,
|
||||||
|
"change": f"-{r.cost}",
|
||||||
|
"time": r.created_at_bj.strftime('%Y-%m-%d %H:%M')
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": details,
|
||||||
|
"total": pagination.total,
|
||||||
|
"pages": pagination.pages,
|
||||||
|
"current_page": pagination.page
|
||||||
|
}
|
||||||
62
services/system_service.py
Normal file
62
services/system_service.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from models import SystemDict, SystemNotification, db
|
||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
def get_system_config_data():
|
||||||
|
"""获取系统配置数据的业务逻辑"""
|
||||||
|
dicts = SystemDict.query.filter_by(is_active=True).order_by(SystemDict.sort_order.desc()).all()
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"models": [],
|
||||||
|
"ratios": [],
|
||||||
|
"prompts": [],
|
||||||
|
"sizes": [],
|
||||||
|
"video_models": [],
|
||||||
|
"video_prompts": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for d in dicts:
|
||||||
|
item = {"label": d.label, "value": d.value}
|
||||||
|
if d.dict_type == 'ai_model':
|
||||||
|
item["cost"] = d.cost
|
||||||
|
config["models"].append(item)
|
||||||
|
elif d.dict_type == 'aspect_ratio':
|
||||||
|
config["ratios"].append(item)
|
||||||
|
elif d.dict_type == 'prompt_tpl':
|
||||||
|
config["prompts"].append(item)
|
||||||
|
elif d.dict_type == 'ai_image_size':
|
||||||
|
config["sizes"].append(item)
|
||||||
|
elif d.dict_type == 'video_model':
|
||||||
|
item["cost"] = d.cost
|
||||||
|
config["video_models"].append(item)
|
||||||
|
elif d.dict_type == 'video_prompt':
|
||||||
|
config["video_prompts"].append(item)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def get_user_latest_notification(user_id):
|
||||||
|
"""获取用户最近一条未读通知"""
|
||||||
|
latest = SystemNotification.query.filter_by(is_active=True)\
|
||||||
|
.filter(~SystemNotification.read_by_users.any(id=user_id))\
|
||||||
|
.order_by(SystemNotification.created_at.desc()).first()
|
||||||
|
|
||||||
|
if latest:
|
||||||
|
return {
|
||||||
|
"id": latest.id,
|
||||||
|
"title": latest.title,
|
||||||
|
"content": latest.content,
|
||||||
|
"time": latest.created_at_bj.strftime('%Y-%m-%d %H:%M')
|
||||||
|
}
|
||||||
|
return {"id": None}
|
||||||
|
|
||||||
|
def mark_notification_as_read(user_id, notif_id):
|
||||||
|
"""标记通知已读"""
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
notif = db.session.get(SystemNotification, notif_id)
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
|
if notif and user:
|
||||||
|
if user not in notif.read_by_users:
|
||||||
|
notif.read_by_users.append(user)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
285
services/task_service.py
Normal file
285
services/task_service.py
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import io
|
||||||
|
import time
|
||||||
|
import base64
|
||||||
|
import threading
|
||||||
|
from urllib.parse import quote
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from extensions import s3_client, redis_client, db
|
||||||
|
from models import GenerationRecord, User
|
||||||
|
from config import Config
|
||||||
|
from services.logger import system_logger
|
||||||
|
|
||||||
|
def sync_images_background(app, record_id, raw_urls):
|
||||||
|
"""后台同步图片至 MinIO,并生成缩略图,带重试机制"""
|
||||||
|
with app.app_context():
|
||||||
|
processed_data = []
|
||||||
|
for raw_url in raw_urls:
|
||||||
|
success = False
|
||||||
|
for attempt in range(3): # 3 次重试机制
|
||||||
|
try:
|
||||||
|
img_resp = requests.get(raw_url, timeout=30)
|
||||||
|
if img_resp.status_code == 200:
|
||||||
|
content = img_resp.content
|
||||||
|
ext = ".png"
|
||||||
|
base_filename = f"gen-{uuid.uuid4().hex}"
|
||||||
|
full_filename = f"{base_filename}{ext}"
|
||||||
|
thumb_filename = f"{base_filename}-thumb{ext}"
|
||||||
|
|
||||||
|
# 1. 上传原图
|
||||||
|
s3_client.upload_fileobj(
|
||||||
|
io.BytesIO(content),
|
||||||
|
Config.MINIO["bucket"],
|
||||||
|
full_filename,
|
||||||
|
ExtraArgs={"ContentType": "image/png"}
|
||||||
|
)
|
||||||
|
|
||||||
|
full_url = f"{Config.MINIO['public_url']}{quote(full_filename)}"
|
||||||
|
thumb_url = full_url # 默认使用原图
|
||||||
|
|
||||||
|
# 2. 生成并上传缩略图 (400px 宽度)
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(content))
|
||||||
|
# 转换为 RGB 如果是 RGBA (避免某些格式保存问题)
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
# 缩放至宽度 400, 高度等比
|
||||||
|
w, h = img.size
|
||||||
|
if w > 400:
|
||||||
|
ratio = 400 / float(w)
|
||||||
|
img.thumbnail((400, int(h * ratio)), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
thumb_io = io.BytesIO()
|
||||||
|
# 缩略图保存为 JPEG 以获得更小的体积
|
||||||
|
img.save(thumb_io, format='JPEG', quality=80, optimize=True)
|
||||||
|
thumb_io.seek(0)
|
||||||
|
|
||||||
|
s3_client.upload_fileobj(
|
||||||
|
thumb_io,
|
||||||
|
Config.MINIO["bucket"],
|
||||||
|
thumb_filename.replace('.png', '.jpg'),
|
||||||
|
ExtraArgs={"ContentType": "image/jpeg"}
|
||||||
|
)
|
||||||
|
thumb_url = f"{Config.MINIO['public_url']}{quote(thumb_filename.replace('.png', '.jpg'))}"
|
||||||
|
except Exception as thumb_e:
|
||||||
|
print(f"⚠️ 缩略图生成失败: {thumb_e}")
|
||||||
|
|
||||||
|
processed_data.append({"url": full_url, "thumb": thumb_url})
|
||||||
|
success = True
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 第 {attempt+1} 次同步失败: {e}")
|
||||||
|
time.sleep(2 ** attempt) # 指数退避
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
# 如果最终失败,保留原始 URL
|
||||||
|
processed_data.append({"url": raw_url, "thumb": raw_url})
|
||||||
|
|
||||||
|
# 更新数据库记录为持久化数据结构
|
||||||
|
try:
|
||||||
|
record = db.session.get(GenerationRecord, record_id)
|
||||||
|
if record:
|
||||||
|
record.image_urls = json.dumps(processed_data)
|
||||||
|
db.session.commit()
|
||||||
|
print(f"✅ 记录 {record_id} 图片及缩略图已完成同步")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 更新记录失败: {e}")
|
||||||
|
|
||||||
|
def process_image_generation(app, user_id, task_id, payload, api_key, target_api, cost):
|
||||||
|
"""异步执行图片生成并存入 Redis"""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
|
# 使用较长的超时时间 (10分钟),确保长耗时任务不被中断
|
||||||
|
resp = requests.post(target_api, json=payload, headers=headers, timeout=1000)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user and "sk-" in api_key:
|
||||||
|
user.points += cost
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# 记录详细的失败上下文
|
||||||
|
system_logger.error(f"生图任务失败: {resp.text}", user_id=user_id, task_id=task_id, prompt=payload.get('prompt'), model=payload.get('model'))
|
||||||
|
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": resp.text}))
|
||||||
|
return
|
||||||
|
|
||||||
|
api_result = resp.json()
|
||||||
|
raw_urls = [item['url'] for item in api_result.get('data', [])]
|
||||||
|
|
||||||
|
# 持久化记录
|
||||||
|
new_record = GenerationRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
prompt=payload.get('prompt'),
|
||||||
|
model=payload.get('model'),
|
||||||
|
cost=cost,
|
||||||
|
image_urls=json.dumps(raw_urls)
|
||||||
|
)
|
||||||
|
db.session.add(new_record)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# 后台线程处理:下载 AI 原始图片并同步到私有 MinIO
|
||||||
|
threading.Thread(
|
||||||
|
target=sync_images_background,
|
||||||
|
args=(app, new_record.id, raw_urls)
|
||||||
|
).start()
|
||||||
|
|
||||||
|
# 存入 Redis 标记完成
|
||||||
|
system_logger.info(f"生图任务完成", user_id=user_id, task_id=task_id, model=payload.get('model'))
|
||||||
|
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "complete", "urls": raw_urls}))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 异常处理:退还积分
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user and "sk-" in api_key:
|
||||||
|
user.points += cost
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
system_logger.error(f"生图任务异常: {str(e)}", user_id=user_id, task_id=task_id, prompt=payload.get('prompt'), model=payload.get('model'))
|
||||||
|
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": str(e)}))
|
||||||
|
|
||||||
|
def sync_video_background(app, record_id, raw_url, internal_task_id=None):
|
||||||
|
"""后台同步视频至 MinIO,带重试机制"""
|
||||||
|
with app.app_context():
|
||||||
|
success = False
|
||||||
|
final_url = raw_url
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
# 增加了流式下载,处理大视频文件
|
||||||
|
with requests.get(raw_url, stream=True, timeout=120) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
content_type = r.headers.get('content-type', 'video/mp4')
|
||||||
|
ext = ".mp4"
|
||||||
|
if "text/html" in content_type: # 有些 API 返回的是跳转页面
|
||||||
|
continue
|
||||||
|
|
||||||
|
base_filename = f"video-{uuid.uuid4().hex}"
|
||||||
|
full_filename = f"{base_filename}{ext}"
|
||||||
|
|
||||||
|
video_io = io.BytesIO()
|
||||||
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
video_io.write(chunk)
|
||||||
|
video_io.seek(0)
|
||||||
|
|
||||||
|
# 上传至 MinIO
|
||||||
|
s3_client.upload_fileobj(
|
||||||
|
video_io,
|
||||||
|
Config.MINIO["bucket"],
|
||||||
|
full_filename,
|
||||||
|
ExtraArgs={"ContentType": content_type}
|
||||||
|
)
|
||||||
|
|
||||||
|
final_url = f"{Config.MINIO['public_url']}{quote(full_filename)}"
|
||||||
|
success = True
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
system_logger.error(f"同步视频失败 (第{attempt+1}次): {str(e)}")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
try:
|
||||||
|
record = db.session.get(GenerationRecord, record_id)
|
||||||
|
if record:
|
||||||
|
# 更新记录为 MinIO 的 URL
|
||||||
|
record.image_urls = json.dumps([{"url": final_url, "type": "video"}])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# 同步更新 Redis 中的缓存
|
||||||
|
if internal_task_id:
|
||||||
|
cached_data = redis_client.get(f"task:{internal_task_id}")
|
||||||
|
if cached_data:
|
||||||
|
if isinstance(cached_data, bytes):
|
||||||
|
cached_data = cached_data.decode('utf-8')
|
||||||
|
task_info = json.loads(cached_data)
|
||||||
|
task_info['video_url'] = final_url
|
||||||
|
redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps(task_info))
|
||||||
|
|
||||||
|
system_logger.info(f"视频同步 MinIO 成功", video_url=final_url)
|
||||||
|
except Exception as dbe:
|
||||||
|
system_logger.error(f"更新视频记录失败: {str(dbe)}")
|
||||||
|
|
||||||
|
def process_video_generation(app, user_id, internal_task_id, payload, api_key, cost):
|
||||||
|
"""异步提交并查询视频任务状态"""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
|
# 1. 提交任务
|
||||||
|
submit_resp = requests.post(Config.VIDEO_GEN_API, json=payload, headers=headers, timeout=60)
|
||||||
|
if submit_resp.status_code != 200:
|
||||||
|
raise Exception(f"视频任务提交失败: {submit_resp.text}")
|
||||||
|
|
||||||
|
submit_result = submit_resp.json()
|
||||||
|
remote_task_id = submit_result.get('task_id')
|
||||||
|
if not remote_task_id:
|
||||||
|
raise Exception(f"未获取到远程任务 ID: {submit_result}")
|
||||||
|
|
||||||
|
# 2. 轮询状态
|
||||||
|
max_retries = 90 # 提升到 15 分钟
|
||||||
|
video_url = None
|
||||||
|
for i in range(max_retries):
|
||||||
|
time.sleep(10)
|
||||||
|
poll_url = Config.VIDEO_POLL_API.format(task_id=remote_task_id)
|
||||||
|
poll_resp = requests.get(poll_url, headers=headers, timeout=30)
|
||||||
|
if poll_resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
|
||||||
|
poll_result = poll_resp.json()
|
||||||
|
status = poll_result.get('status', '').upper()
|
||||||
|
|
||||||
|
if status == 'SUCCESS':
|
||||||
|
# 提取视频输出地址
|
||||||
|
if 'data' in poll_result and isinstance(poll_result['data'], dict):
|
||||||
|
video_url = poll_result['data'].get('output')
|
||||||
|
if not video_url:
|
||||||
|
if 'data' in poll_result and isinstance(poll_result['data'], list) and poll_result['data']:
|
||||||
|
video_url = poll_result['data'][0].get('url')
|
||||||
|
elif 'video' in poll_result:
|
||||||
|
video_url = poll_result['video'].get('url') if isinstance(poll_result['video'], dict) else poll_result['video']
|
||||||
|
elif 'url' in poll_result:
|
||||||
|
video_url = poll_result['url']
|
||||||
|
break
|
||||||
|
elif status in ['FAILED', 'ERROR']:
|
||||||
|
raise Exception(f"视频生成失败: {poll_result.get('fail_reason') or poll_result.get('message') or '未知错误'}")
|
||||||
|
|
||||||
|
if not video_url:
|
||||||
|
raise Exception("超时未获取到视频地址")
|
||||||
|
|
||||||
|
# 3. 持久化记录
|
||||||
|
new_record = GenerationRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
prompt=payload.get('prompt'),
|
||||||
|
model=payload.get('model'),
|
||||||
|
cost=cost,
|
||||||
|
image_urls=json.dumps([{"url": video_url, "type": "video"}])
|
||||||
|
)
|
||||||
|
db.session.add(new_record)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# 后台线程异步同步到 MinIO
|
||||||
|
threading.Thread(
|
||||||
|
target=sync_video_background,
|
||||||
|
args=(app, new_record.id, video_url, internal_task_id)
|
||||||
|
).start()
|
||||||
|
|
||||||
|
# 4. 存入 Redis
|
||||||
|
redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps({"status": "complete", "video_url": video_url, "record_id": new_record.id}))
|
||||||
|
system_logger.info(f"视频生成任务完成", user_id=user_id, task_id=internal_task_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
system_logger.error(f"视频生成执行异常: {str(e)}", user_id=user_id, task_id=internal_task_id, prompt=payload.get('prompt'))
|
||||||
|
# 尝试退费
|
||||||
|
try:
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user:
|
||||||
|
user.points += cost
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as re:
|
||||||
|
system_logger.error(f"退费失败: {str(re)}")
|
||||||
|
|
||||||
|
# 确保 Redis 状态一定被更新,防止前端死循环
|
||||||
|
redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps({"status": "error", "message": str(e)}))
|
||||||
@ -137,7 +137,7 @@ async function loadHistory(isLoadMore = false) {
|
|||||||
footer.classList.remove('hidden');
|
footer.classList.remove('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/history?page=${currentHistoryPage}&per_page=10`);
|
const r = await fetch(`/api/history?page=${currentHistoryPage}&per_page=10&filter_type=image`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
|
|
||||||
const list = document.getElementById('historyList');
|
const list = document.getElementById('historyList');
|
||||||
@ -146,7 +146,7 @@ async function loadHistory(isLoadMore = false) {
|
|||||||
const html = d.history.map(item => `
|
const html = d.history.map(item => `
|
||||||
<div class="bg-white border border-slate-100 rounded-2xl p-4 space-y-3 hover:border-indigo-100 transition-all shadow-sm group">
|
<div class="bg-white border border-slate-100 rounded-2xl p-4 space-y-3 hover:border-indigo-100 transition-all shadow-sm group">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-[10px] font-black text-slate-400 bg-slate-50 px-2 py-0.5 rounded-md uppercase tracking-widest">${item.time}</span>
|
<span class="text-[10px] font-black text-slate-400 bg-slate-50 px-2 py-0.5 rounded-md uppercase tracking-widest">${item.created_at}</span>
|
||||||
<span class="text-[10px] font-bold text-indigo-500">${item.model}</span>
|
<span class="text-[10px] font-bold text-indigo-500">${item.model}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
@ -735,3 +735,131 @@ window.addEventListener('message', (e) => {
|
|||||||
closeVisualizerModal();
|
closeVisualizerModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 积分与钱包中心逻辑 ---
|
||||||
|
let pointsChart = null;
|
||||||
|
|
||||||
|
async function openPointsModal() {
|
||||||
|
const modal = document.getElementById('pointsModal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.classList.add('opacity-100');
|
||||||
|
modal.querySelector('div').classList.remove('scale-95');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
loadPointStats();
|
||||||
|
loadPointDetails();
|
||||||
|
|
||||||
|
// 更新当前余额
|
||||||
|
const r = await fetch('/api/auth/me');
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.logged_in) {
|
||||||
|
document.getElementById('modalPointsDisplay').innerText = d.points;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePointsModal() {
|
||||||
|
const modal = document.getElementById('pointsModal');
|
||||||
|
modal.classList.remove('opacity-100');
|
||||||
|
modal.querySelector('div').classList.add('scale-95');
|
||||||
|
setTimeout(() => modal.classList.add('hidden'), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPointStats() {
|
||||||
|
const r = await fetch('/api/stats/points?days=7');
|
||||||
|
const d = await r.json();
|
||||||
|
|
||||||
|
const canvas = document.getElementById('pointsChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (pointsChart) pointsChart.destroy();
|
||||||
|
|
||||||
|
if (typeof Chart === 'undefined') {
|
||||||
|
console.error('Chart.js not loaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pointsChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: d.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '消耗积分',
|
||||||
|
data: d.deductions,
|
||||||
|
borderColor: '#6366f1',
|
||||||
|
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
||||||
|
borderWidth: 3,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#6366f1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '充值积分',
|
||||||
|
data: d.incomes,
|
||||||
|
borderColor: '#10b981',
|
||||||
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||||
|
borderWidth: 3,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointBackgroundColor: '#10b981'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { color: 'rgba(241, 245, 249, 1)' },
|
||||||
|
ticks: { font: { weight: 'bold' } }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { font: { weight: 'bold' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPointDetails() {
|
||||||
|
const body = document.getElementById('pointDetailsBody');
|
||||||
|
if (!body) return;
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-400">正在加载明细...</td></tr>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/stats/details?page=1');
|
||||||
|
const d = await r.json();
|
||||||
|
|
||||||
|
if (d.items.length === 0) {
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-slate-400">暂无积分变动记录</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = d.items.map(item => `
|
||||||
|
<tr class="hover:bg-slate-50 transition-colors border-b border-slate-50">
|
||||||
|
<td class="px-8 py-5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-2 h-2 rounded-full ${item.type === 'deduction' ? 'bg-indigo-500' : 'bg-emerald-500'}"></div>
|
||||||
|
<span class="truncate max-w-[200px]">${item.desc}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-8 py-5 text-slate-400 uppercase font-black text-[10px] tracking-widest">${item.model}</td>
|
||||||
|
<td class="px-8 py-5 font-black ${item.type === 'deduction' ? 'text-rose-500' : 'text-emerald-500'}">${item.change}</td>
|
||||||
|
<td class="px-8 py-5 text-right font-mono text-slate-400">${item.time}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="px-8 py-10 text-center text-rose-500">加载失败</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -54,14 +54,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (isLoadingHistory) return;
|
if (isLoadingHistory) return;
|
||||||
isLoadingHistory = true;
|
isLoadingHistory = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/history?page=${page}&per_page=10`);
|
const r = await fetch(`/api/history?page=${page}&per_page=10&filter_type=video`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
|
|
||||||
// 过滤出有视频的记录
|
// 服务端已完成过滤
|
||||||
const videoRecords = d.history.filter(item => {
|
const videoRecords = d.history;
|
||||||
const urls = item.urls || [];
|
|
||||||
return urls.some(u => u.type === 'video' || (typeof u === 'string' && u.endsWith('.mp4')));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (videoRecords.length > 0) {
|
if (videoRecords.length > 0) {
|
||||||
const html = videoRecords.map(item => {
|
const html = videoRecords.map(item => {
|
||||||
|
|||||||
15
sync_db.py
15
sync_db.py
@ -1,15 +0,0 @@
|
|||||||
from app import app
|
|
||||||
from extensions import db
|
|
||||||
import models
|
|
||||||
|
|
||||||
def init():
|
|
||||||
with app.app_context():
|
|
||||||
print("🔧 正在同步数据库架构...")
|
|
||||||
try:
|
|
||||||
db.create_all()
|
|
||||||
print("✅ 数据库表已成功创建或已存在")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 同步失败: {e}")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
init()
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
from app import app
|
|
||||||
from extensions import db
|
|
||||||
from models import GenerationRecord
|
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
try:
|
|
||||||
db.create_all()
|
|
||||||
print("✅ 数据库表同步成功 (包括 GenerationRecord)")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 同步失败: {e}")
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import json
|
|
||||||
import io
|
|
||||||
import requests
|
|
||||||
import uuid
|
|
||||||
import time
|
|
||||||
from urllib.parse import quote
|
|
||||||
from app import create_app
|
|
||||||
from extensions import db, s3_client
|
|
||||||
from config import Config
|
|
||||||
from models import GenerationRecord
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
def sync_old_videos():
|
|
||||||
with app.app_context():
|
|
||||||
print("🔍 开始扫描未同步的视频记录...")
|
|
||||||
|
|
||||||
# 获取所有包含 'video' 字样的记录 (简单过滤)
|
|
||||||
records = GenerationRecord.query.filter(GenerationRecord.image_urls.like('%video%')).all()
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
success_count = 0
|
|
||||||
|
|
||||||
for r in records:
|
|
||||||
try:
|
|
||||||
data = json.loads(r.image_urls)
|
|
||||||
updated = False
|
|
||||||
new_data = []
|
|
||||||
|
|
||||||
for item in data:
|
|
||||||
# 检查是否是视频且 URL 不是 MinIO 的地址
|
|
||||||
if isinstance(item, dict) and item.get('type') == 'video':
|
|
||||||
url = item.get('url')
|
|
||||||
if url and Config.MINIO['public_url'] not in url:
|
|
||||||
print(f"⏳ 正在同步记录 {r.id}: {url[:50]}...")
|
|
||||||
|
|
||||||
# 尝试下载并转存
|
|
||||||
try:
|
|
||||||
with requests.get(url, stream=True, timeout=60) as req:
|
|
||||||
if req.status_code == 200:
|
|
||||||
content_type = req.headers.get('content-type', 'video/mp4')
|
|
||||||
ext = ".mp4"
|
|
||||||
|
|
||||||
base_filename = f"video-{uuid.uuid4().hex}"
|
|
||||||
full_filename = f"{base_filename}{ext}"
|
|
||||||
|
|
||||||
video_io = io.BytesIO()
|
|
||||||
for chunk in req.iter_content(chunk_size=8192):
|
|
||||||
video_io.write(chunk)
|
|
||||||
video_io.seek(0)
|
|
||||||
|
|
||||||
# 上传至 MinIO
|
|
||||||
s3_client.upload_fileobj(
|
|
||||||
video_io,
|
|
||||||
Config.MINIO["bucket"],
|
|
||||||
full_filename,
|
|
||||||
ExtraArgs={"ContentType": content_type}
|
|
||||||
)
|
|
||||||
|
|
||||||
final_url = f"{Config.MINIO['public_url']}{quote(full_filename)}"
|
|
||||||
item['url'] = final_url
|
|
||||||
updated = True
|
|
||||||
print(f"✅ 同步成功: {final_url}")
|
|
||||||
else:
|
|
||||||
print(f"❌ 下载失败 (Status {req.status_code}),可能链接已过期")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ 同步异常: {e}")
|
|
||||||
|
|
||||||
new_data.append(item)
|
|
||||||
|
|
||||||
if updated:
|
|
||||||
r.image_urls = json.dumps(new_data)
|
|
||||||
db.session.commit()
|
|
||||||
success_count += 1
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"处理记录 {r.id} 出错: {e}")
|
|
||||||
|
|
||||||
print(f"\n🎉 扫描完成! 成功同步了 {success_count} 个视频。")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sync_old_videos()
|
|
||||||
@ -12,6 +12,7 @@
|
|||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<script src="{{ url_for('static', filename='js/lucide.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/lucide.min.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/Sortable.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/Sortable.min.js') }}"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
<style>
|
<style>
|
||||||
/* 关键路径 CSS:防止侧边栏及图标闪烁 */
|
/* 关键路径 CSS:防止侧边栏及图标闪烁 */
|
||||||
@ -109,7 +110,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-center space-y-2">
|
<div class="text-center space-y-2">
|
||||||
<h2 id="notifTitle" class="text-2xl font-black text-slate-900">系统通知</h2>
|
<h2 id="notifTitle" class="text-2xl font-black text-slate-900">系统通知</h2>
|
||||||
<div id="notifContent" class="text-slate-500 text-sm font-bold leading-relaxed whitespace-pre-wrap">
|
<p id="notifTime" class="text-[10px] font-black text-slate-300 uppercase tracking-widest"></p>
|
||||||
|
<div id="notifContent"
|
||||||
|
class="text-slate-500 text-sm font-bold leading-relaxed whitespace-pre-wrap pt-4">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="closeNotifBtn"
|
<button id="closeNotifBtn"
|
||||||
@ -137,6 +140,7 @@
|
|||||||
if (d.id) {
|
if (d.id) {
|
||||||
currentNotifId = d.id;
|
currentNotifId = d.id;
|
||||||
document.getElementById('notifTitle').innerText = d.title;
|
document.getElementById('notifTitle').innerText = d.title;
|
||||||
|
document.getElementById('notifTime').innerText = d.time || '';
|
||||||
document.getElementById('notifContent').innerText = d.content;
|
document.getElementById('notifContent').innerText = d.content;
|
||||||
|
|
||||||
const modal = document.getElementById('notifModal');
|
const modal = document.getElementById('notifModal');
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
<div class="w-full h-full overflow-y-auto p-8 lg:p-12 custom-scrollbar">
|
<div class="w-full h-full overflow-y-auto p-8 lg:p-12 custom-scrollbar">
|
||||||
<div class="max-w-4xl mx-auto space-y-12">
|
<div class="max-w-4xl mx-auto space-y-12">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="w-12 h-12 bg-amber-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-amber-200">
|
<div
|
||||||
|
class="w-12 h-12 bg-amber-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-amber-200">
|
||||||
<i data-lucide="shopping-cart" class="w-7 h-7"></i>
|
<i data-lucide="shopping-cart" class="w-7 h-7"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -16,13 +17,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if success %}
|
{% if success %}
|
||||||
<div class="bg-emerald-50 border border-emerald-100 rounded-3xl p-6 flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500">
|
<div
|
||||||
<div class="w-12 h-12 bg-emerald-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-emerald-100">
|
class="bg-emerald-50 border border-emerald-100 rounded-3xl p-6 flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 bg-emerald-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-emerald-100">
|
||||||
<i data-lucide="check" class="w-7 h-7"></i>
|
<i data-lucide="check" class="w-7 h-7"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-emerald-900 font-black">支付成功!</h3>
|
<h3 class="text-emerald-900 font-black">支付成功!</h3>
|
||||||
<p class="text-emerald-600 text-xs font-bold">您的订单 #{{ order.out_trade_no if order else '' }} 已完成,积分已到账。</p>
|
<p class="text-emerald-600 text-xs font-bold">您的订单 #{{ order.out_trade_no if order else '' }} 已完成,积分已到账。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -44,7 +48,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<form action="/payment/create" method="POST">
|
<form action="/payment/create" method="POST">
|
||||||
<input type="hidden" name="package_id" value="50">
|
<input type="hidden" name="package_id" value="50">
|
||||||
<button type="submit" class="w-full py-4 rounded-2xl bg-slate-900 text-white font-black text-sm">
|
<button type="submit"
|
||||||
|
class="w-full py-4 rounded-2xl bg-slate-900 text-white font-black text-sm">
|
||||||
¥ 5.00 购买
|
¥ 5.00 购买
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -52,7 +57,9 @@
|
|||||||
|
|
||||||
<!-- 套餐 2 -->
|
<!-- 套餐 2 -->
|
||||||
<div class="bg-white rounded-[2.5rem] border border-slate-100 p-8 shadow-xl relative">
|
<div class="bg-white rounded-[2.5rem] border border-slate-100 p-8 shadow-xl relative">
|
||||||
<div class="absolute -top-3 right-6 bg-indigo-600 text-white text-[8px] font-black px-3 py-1 rounded-full shadow-lg uppercase">推荐</div>
|
<div
|
||||||
|
class="absolute -top-3 right-6 bg-indigo-600 text-white text-[8px] font-black px-3 py-1 rounded-full shadow-lg uppercase">
|
||||||
|
推荐</div>
|
||||||
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">普通创作</div>
|
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-4">普通创作</div>
|
||||||
<div class="flex items-baseline gap-1 mb-6">
|
<div class="flex items-baseline gap-1 mb-6">
|
||||||
<span class="text-4xl font-black text-slate-900">200</span>
|
<span class="text-4xl font-black text-slate-900">200</span>
|
||||||
@ -66,7 +73,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<form action="/payment/create" method="POST">
|
<form action="/payment/create" method="POST">
|
||||||
<input type="hidden" name="package_id" value="200">
|
<input type="hidden" name="package_id" value="200">
|
||||||
<button type="submit" class="w-full py-4 rounded-2xl bg-indigo-600 text-white font-black text-sm">
|
<button type="submit"
|
||||||
|
class="w-full py-4 rounded-2xl bg-indigo-600 text-white font-black text-sm">
|
||||||
¥ 20.00 购买
|
¥ 20.00 购买
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -87,7 +95,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<form action="/payment/create" method="POST">
|
<form action="/payment/create" method="POST">
|
||||||
<input type="hidden" name="package_id" value="1000">
|
<input type="hidden" name="package_id" value="1000">
|
||||||
<button type="submit" class="w-full py-4 rounded-2xl bg-white text-indigo-900 font-black text-sm">
|
<button type="submit"
|
||||||
|
class="w-full py-4 rounded-2xl bg-white text-indigo-900 font-black text-sm">
|
||||||
¥ 100.00 购买
|
¥ 100.00 购买
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -108,7 +117,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<form action="/payment/create" method="POST">
|
<form action="/payment/create" method="POST">
|
||||||
<input type="hidden" name="package_id" value="5000">
|
<input type="hidden" name="package_id" value="5000">
|
||||||
<button type="submit" class="w-full py-4 rounded-2xl bg-slate-900 text-white font-black text-sm">
|
<button type="submit"
|
||||||
|
class="w-full py-4 rounded-2xl bg-slate-900 text-white font-black text-sm">
|
||||||
¥ 500.00 购买
|
¥ 500.00 购买
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -127,79 +137,92 @@
|
|||||||
<div class="space-y-8 pt-8 border-t border-slate-200">
|
<div class="space-y-8 pt-8 border-t border-slate-200">
|
||||||
<!-- 标签页切换 -->
|
<!-- 标签页切换 -->
|
||||||
<div class="flex items-center gap-4 bg-slate-100 p-1.5 rounded-2xl w-fit">
|
<div class="flex items-center gap-4 bg-slate-100 p-1.5 rounded-2xl w-fit">
|
||||||
<button onclick="switchTab('personal')" id="tabBtn-personal" class="px-6 py-2.5 rounded-xl text-sm font-black transition-all bg-white text-slate-900 shadow-sm">
|
<button onclick="switchTab('personal')" id="tabBtn-personal"
|
||||||
|
class="px-6 py-2.5 rounded-xl text-sm font-black transition-all bg-white text-slate-900 shadow-sm">
|
||||||
我的充值
|
我的充值
|
||||||
</button>
|
</button>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<button onclick="switchTab('admin')" id="tabBtn-admin" class="px-6 py-2.5 rounded-xl text-sm font-black transition-all text-slate-500 hover:text-slate-700">
|
<button onclick="switchTab('admin')" id="tabBtn-admin"
|
||||||
|
class="px-6 py-2.5 rounded-xl text-sm font-black transition-all text-slate-500 hover:text-slate-700">
|
||||||
全员记录 (管理员)
|
全员记录 (管理员)
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 我的充值列表 -->
|
<!-- 我的充值列表 -->
|
||||||
<div id="tab-personal" class="bg-white/70 backdrop-blur-xl rounded-[2.5rem] border border-slate-200/50 shadow-2xl overflow-hidden animate-in fade-in duration-500">
|
<div id="tab-personal"
|
||||||
|
class="bg-white/70 backdrop-blur-xl rounded-[2.5rem] border border-slate-200/50 shadow-2xl overflow-hidden animate-in fade-in duration-500">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-left border-collapse">
|
<table class="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-slate-100 bg-slate-50/50">
|
<tr class="border-b border-slate-100 bg-slate-50/50">
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">订单号</th>
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">积分/金额</th>
|
订单号</th>
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">状态</th>
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">支付时间</th>
|
积分/金额</th>
|
||||||
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">状态
|
||||||
|
</th>
|
||||||
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||||
|
支付时间</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
{% if personal_orders %}
|
{% if personal_orders %}
|
||||||
{% for order in personal_orders %}
|
{% for order in personal_orders %}
|
||||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||||
<td class="px-8 py-5">
|
<td class="px-8 py-5">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-xs font-bold text-slate-700">{{ order.out_trade_no }}</span>
|
<span class="text-xs font-bold text-slate-700">{{ order.out_trade_no }}</span>
|
||||||
<span class="text-[10px] text-slate-400 font-mono">Ali: {{ order.trade_no or '-' }}</span>
|
<span class="text-[10px] text-slate-400 font-mono">Ali: {{ order.trade_no or '-'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-8 py-5">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<i data-lucide="zap" class="w-3 h-3 text-amber-500"></i>
|
||||||
|
<span class="text-sm font-black text-slate-900">+{{ order.points }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<span class="text-[10px] font-bold text-slate-400">¥{{ order.amount }}</span>
|
||||||
<td class="px-8 py-5">
|
</div>
|
||||||
<div class="flex flex-col">
|
</td>
|
||||||
<div class="flex items-center gap-1">
|
<td class="px-8 py-5">
|
||||||
<i data-lucide="zap" class="w-3 h-3 text-amber-500"></i>
|
{% if order.status == 'PAID' %}
|
||||||
<span class="text-sm font-black text-slate-900">+{{ order.points }}</span>
|
<span
|
||||||
</div>
|
class="px-2 py-1 bg-emerald-50 text-emerald-600 text-[10px] font-black rounded-lg border border-emerald-100">已完成</span>
|
||||||
<span class="text-[10px] font-bold text-slate-400">¥{{ order.amount }}</span>
|
{% elif order.status == 'PENDING' %}
|
||||||
</div>
|
<span
|
||||||
</td>
|
class="px-2 py-1 bg-amber-50 text-amber-600 text-[10px] font-black rounded-lg border border-amber-100">待支付</span>
|
||||||
<td class="px-8 py-5">
|
{% else %}
|
||||||
{% if order.status == 'PAID' %}
|
<span
|
||||||
<span class="px-2 py-1 bg-emerald-50 text-emerald-600 text-[10px] font-black rounded-lg border border-emerald-100">已完成</span>
|
class="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-black rounded-lg border border-slate-100">已取消</span>
|
||||||
{% elif order.status == 'PENDING' %}
|
{% endif %}
|
||||||
<span class="px-2 py-1 bg-amber-50 text-amber-600 text-[10px] font-black rounded-lg border border-amber-100">待支付</span>
|
</td>
|
||||||
|
<td class="px-8 py-5">
|
||||||
|
<span class="text-[10px] font-bold text-slate-400">
|
||||||
|
{% if order.paid_at %}
|
||||||
|
{{ (order.paid_at + modules.datetime.timedelta(hours=8)).strftime('%m-%d %H:%M')
|
||||||
|
}}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-black rounded-lg border border-slate-100">已取消</span>
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</span>
|
||||||
<td class="px-8 py-5">
|
</td>
|
||||||
<span class="text-[10px] font-bold text-slate-400">
|
</tr>
|
||||||
{% if order.paid_at %}
|
{% endfor %}
|
||||||
{{ (order.paid_at + modules.datetime.timedelta(hours=8)).strftime('%m-%d %H:%M') }}
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="px-8 py-16 text-center">
|
<td colspan="4" class="px-8 py-16 text-center">
|
||||||
<p class="text-slate-300 font-bold text-sm">暂无记录</p>
|
<p class="text-slate-300 font-bold text-sm">暂无记录</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 bg-slate-50/50 border-t border-slate-100 text-center">
|
<div class="p-6 bg-slate-50/50 border-t border-slate-100 text-center">
|
||||||
<a href="/payment/history" class="text-[10px] font-black text-indigo-600 hover:text-indigo-700 uppercase tracking-widest flex items-center justify-center gap-1">
|
<a href="/payment/history"
|
||||||
|
class="text-[10px] font-black text-indigo-600 hover:text-indigo-700 uppercase tracking-widest flex items-center justify-center gap-1">
|
||||||
查看更多记录 <i data-lucide="chevron-right" class="w-3 h-3"></i>
|
查看更多记录 <i data-lucide="chevron-right" class="w-3 h-3"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -207,53 +230,64 @@
|
|||||||
|
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
<!-- 管理员全员记录列表 -->
|
<!-- 管理员全员记录列表 -->
|
||||||
<div id="tab-admin" class="hidden bg-white/70 backdrop-blur-xl rounded-[2.5rem] border border-slate-200/50 shadow-2xl overflow-hidden animate-in fade-in duration-500">
|
<div id="tab-admin"
|
||||||
|
class="hidden bg-white/70 backdrop-blur-xl rounded-[2.5rem] border border-slate-200/50 shadow-2xl overflow-hidden animate-in fade-in duration-500">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-left border-collapse">
|
<table class="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-slate-100 bg-slate-50/50">
|
<tr class="border-b border-slate-100 bg-slate-50/50">
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">用户信息</th>
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">订单号</th>
|
用户信息</th>
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">积分/金额</th>
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">状态</th>
|
订单号</th>
|
||||||
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||||
|
积分/金额</th>
|
||||||
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">状态
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
{% if admin_orders %}
|
{% if admin_orders %}
|
||||||
{% for order in admin_orders %}
|
{% for order in admin_orders %}
|
||||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||||
<td class="px-8 py-5">
|
<td class="px-8 py-5">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-xs font-bold text-slate-700">{{ order.user.phone if order.user else '未知' }}</span>
|
<span class="text-xs font-bold text-slate-700">{{ order.user.phone if order.user
|
||||||
<span class="text-[10px] text-slate-400">UID: {{ order.user_id }}</span>
|
else '未知' }}</span>
|
||||||
</div>
|
<span class="text-[10px] text-slate-400">UID: {{ order.user_id }}</span>
|
||||||
</td>
|
</div>
|
||||||
<td class="px-8 py-5">
|
</td>
|
||||||
<div class="flex flex-col">
|
<td class="px-8 py-5">
|
||||||
<span class="text-[10px] font-bold text-slate-500">{{ order.out_trade_no }}</span>
|
<div class="flex flex-col">
|
||||||
</div>
|
<span class="text-[10px] font-bold text-slate-500">{{ order.out_trade_no
|
||||||
</td>
|
}}</span>
|
||||||
<td class="px-8 py-5">
|
</div>
|
||||||
<div class="flex flex-col">
|
</td>
|
||||||
<span class="text-xs font-black text-slate-900">+{{ order.points }}</span>
|
<td class="px-8 py-5">
|
||||||
<span class="text-[10px] font-bold text-slate-400">¥{{ order.amount }}</span>
|
<div class="flex flex-col">
|
||||||
</div>
|
<span class="text-xs font-black text-slate-900">+{{ order.points }}</span>
|
||||||
</td>
|
<span class="text-[10px] font-bold text-slate-400">¥{{ order.amount }}</span>
|
||||||
<td class="px-8 py-5">
|
</div>
|
||||||
{% if order.status == 'PAID' %}
|
</td>
|
||||||
<span class="px-2 py-1 bg-emerald-50 text-emerald-600 text-[10px] font-black rounded-lg border border-emerald-100">已完成</span>
|
<td class="px-8 py-5">
|
||||||
{% else %}
|
{% if order.status == 'PAID' %}
|
||||||
<span class="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-black rounded-lg border border-slate-100">{{ order.status }}</span>
|
<span
|
||||||
{% endif %}
|
class="px-2 py-1 bg-emerald-50 text-emerald-600 text-[10px] font-black rounded-lg border border-emerald-100">已完成</span>
|
||||||
</td>
|
{% else %}
|
||||||
</tr>
|
<span
|
||||||
{% endfor %}
|
class="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-black rounded-lg border border-slate-100">{{
|
||||||
|
order.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 bg-slate-50/50 border-t border-slate-100 text-center">
|
<div class="p-6 bg-slate-50/50 border-t border-slate-100 text-center">
|
||||||
<a href="/admin/orders" class="text-[10px] font-black text-indigo-600 hover:text-indigo-700 uppercase tracking-widest flex items-center justify-center gap-1">
|
<a href="/admin/orders"
|
||||||
|
class="text-[10px] font-black text-indigo-600 hover:text-indigo-700 uppercase tracking-widest flex items-center justify-center gap-1">
|
||||||
进入后台管理全员订单 <i data-lucide="chevron-right" class="w-3 h-3"></i>
|
进入后台管理全员订单 <i data-lucide="chevron-right" class="w-3 h-3"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -270,18 +304,18 @@
|
|||||||
// 切换内容显隐
|
// 切换内容显隐
|
||||||
document.getElementById('tab-personal').classList.toggle('hidden', tab !== 'personal');
|
document.getElementById('tab-personal').classList.toggle('hidden', tab !== 'personal');
|
||||||
const adminTab = document.getElementById('tab-admin');
|
const adminTab = document.getElementById('tab-admin');
|
||||||
if(adminTab) adminTab.classList.toggle('hidden', tab !== 'admin');
|
if (adminTab) adminTab.classList.toggle('hidden', tab !== 'admin');
|
||||||
|
|
||||||
// 切换按钮样式
|
// 切换按钮样式
|
||||||
const personalBtn = document.getElementById('tabBtn-personal');
|
const personalBtn = document.getElementById('tabBtn-personal');
|
||||||
const adminBtn = document.getElementById('tabBtn-admin');
|
const adminBtn = document.getElementById('tabBtn-admin');
|
||||||
|
|
||||||
if(tab === 'personal') {
|
if (tab === 'personal') {
|
||||||
personalBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all bg-white text-slate-900 shadow-sm";
|
personalBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all bg-white text-slate-900 shadow-sm";
|
||||||
if(adminBtn) adminBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all text-slate-500 hover:text-slate-700";
|
if (adminBtn) adminBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all text-slate-500 hover:text-slate-700";
|
||||||
} else {
|
} else {
|
||||||
personalBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all text-slate-500 hover:text-slate-700";
|
personalBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all text-slate-500 hover:text-slate-700";
|
||||||
if(adminBtn) adminBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all bg-white text-slate-900 shadow-sm";
|
if (adminBtn) adminBtn.className = "px-6 py-2.5 rounded-xl text-sm font-black transition-all bg-white text-slate-900 shadow-sm";
|
||||||
}
|
}
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,12 @@
|
|||||||
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
<i data-lucide="arrow-left" class="w-4 h-4"></i>
|
||||||
返回列表
|
返回列表
|
||||||
</button>
|
</button>
|
||||||
|
<button id="addAliasBtn"
|
||||||
|
onclick="openModal({dict_type: 'dict_type_alias', label: '新类型别名', value: 'target_type_key', cost: 0, sort_order: 0, is_active: true})"
|
||||||
|
class="hidden px-5 py-3 rounded-xl border border-indigo-200 text-indigo-600 text-sm font-bold hover:bg-indigo-50 transition-all flex items-center gap-2">
|
||||||
|
<i data-lucide="type" class="w-4 h-4"></i>
|
||||||
|
管理别名
|
||||||
|
</button>
|
||||||
<button id="addBtn" onclick="openModal()"
|
<button id="addBtn" onclick="openModal()"
|
||||||
class="hidden btn-primary px-6 py-3 rounded-xl text-sm font-bold shadow-lg flex items-center gap-2">
|
class="hidden btn-primary px-6 py-3 rounded-xl text-sm font-bold shadow-lg flex items-center gap-2">
|
||||||
<i data-lucide="plus" class="w-4 h-4"></i>
|
<i data-lucide="plus" class="w-4 h-4"></i>
|
||||||
@ -188,6 +194,7 @@
|
|||||||
document.getElementById('detailView').classList.remove('hidden');
|
document.getElementById('detailView').classList.remove('hidden');
|
||||||
document.getElementById('backBtn').classList.remove('hidden');
|
document.getElementById('backBtn').classList.remove('hidden');
|
||||||
document.getElementById('addBtn').classList.remove('hidden');
|
document.getElementById('addBtn').classList.remove('hidden');
|
||||||
|
document.getElementById('addAliasBtn').classList.add('hidden'); // 详情页隐藏别名按钮
|
||||||
|
|
||||||
document.getElementById('pageTitle').innerText = name;
|
document.getElementById('pageTitle').innerText = name;
|
||||||
document.getElementById('pageSubTitle').innerText = `正在管理 ${type} 类型的详细参数`;
|
document.getElementById('pageSubTitle').innerText = `正在管理 ${type} 类型的详细参数`;
|
||||||
@ -202,6 +209,7 @@
|
|||||||
document.getElementById('typeListView').classList.remove('hidden');
|
document.getElementById('typeListView').classList.remove('hidden');
|
||||||
document.getElementById('backBtn').classList.add('hidden');
|
document.getElementById('backBtn').classList.add('hidden');
|
||||||
document.getElementById('addBtn').classList.add('hidden');
|
document.getElementById('addBtn').classList.add('hidden');
|
||||||
|
document.getElementById('addAliasBtn').classList.remove('hidden'); // 在主列表显示别名管理入口
|
||||||
|
|
||||||
document.getElementById('pageTitle').innerText = '数据字典控制中心';
|
document.getElementById('pageTitle').innerText = '数据字典控制中心';
|
||||||
document.getElementById('pageSubTitle').innerText = '全局业务参数与 AI 模型配置';
|
document.getElementById('pageSubTitle').innerText = '全局业务参数与 AI 模型配置';
|
||||||
@ -317,5 +325,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadCategories();
|
loadCategories();
|
||||||
|
// 默认在列表页显示别名按钮
|
||||||
|
document.getElementById('addAliasBtn').classList.remove('hidden');
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -164,10 +164,11 @@
|
|||||||
<i data-lucide="history" class="w-4 h-4 text-indigo-500"></i>
|
<i data-lucide="history" class="w-4 h-4 text-indigo-500"></i>
|
||||||
历史记录
|
历史记录
|
||||||
</button>
|
</button>
|
||||||
<div id="userProfile"
|
<div id="userProfile" onclick="openPointsModal()"
|
||||||
class="{% if not g.user %}hidden{% endif %} flex items-center gap-3 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm hover:bg-white transition-all">
|
class="{% if not g.user %}hidden{% endif %} flex items-center gap-3 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm hover:bg-white transition-all cursor-pointer group">
|
||||||
<div class="w-8 h-8 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600">
|
<div
|
||||||
<i data-lucide="user" class="w-4 h-4"></i>
|
class="w-8 h-8 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600 group-hover:bg-indigo-600 group-hover:text-white transition-all">
|
||||||
|
<i data-lucide="wallet" class="w-4 h-4"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">{{ g.user.phone[:3] ~ "****" ~
|
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">{{ g.user.phone[:3] ~ "****" ~
|
||||||
@ -175,7 +176,7 @@
|
|||||||
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">{{
|
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">{{
|
||||||
g.user.points if g.user else 0 }}</span> 积分</span>
|
g.user.points if g.user else 0 }}</span> 积分</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="openPwdModalBtn" title="修改密码"
|
<button id="openPwdModalBtn" title="修改密码" onclick="event.stopPropagation(); openPwdModal()"
|
||||||
class="ml-1 p-1.5 text-slate-400 hover:text-indigo-600 transition-colors">
|
class="ml-1 p-1.5 text-slate-400 hover:text-indigo-600 transition-colors">
|
||||||
<i data-lucide="key-round" class="w-4 h-4"></i>
|
<i data-lucide="key-round" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -259,6 +260,95 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- 积分与钱包中心弹窗 -->
|
||||||
|
<div id="pointsModal"
|
||||||
|
class="fixed inset-0 bg-slate-900/60 backdrop-blur-md z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
|
||||||
|
<div
|
||||||
|
class="bg-white w-full max-w-4xl rounded-[3rem] shadow-3xl overflow-hidden transform scale-95 transition-transform duration-300 flex flex-col h-[85vh]">
|
||||||
|
<div
|
||||||
|
class="px-10 py-8 border-b border-slate-100 flex items-center justify-between flex-shrink-0 bg-slate-50/50">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 bg-amber-500 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-amber-200">
|
||||||
|
<i data-lucide="line-chart" class="w-6 h-6"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-black text-slate-900 tracking-tight">财富与积分中心</h2>
|
||||||
|
<p class="text-xs text-slate-400 font-bold uppercase tracking-widest mt-0.5">Wallet & Usage
|
||||||
|
Statistics</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="closePointsModal()"
|
||||||
|
class="w-12 h-12 rounded-2xl hover:bg-slate-200/50 flex items-center justify-center text-slate-400 transition-colors">
|
||||||
|
<i data-lucide="x" class="w-6 h-6"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-10 space-y-10 custom-scrollbar">
|
||||||
|
<!-- 统计概览卡片 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div class="lg:col-span-2 bg-slate-50 rounded-[2.5rem] p-8 border border-slate-100">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-sm font-black text-slate-900 uppercase tracking-widest">消费趋势 (近7日)</h3>
|
||||||
|
<div class="flex items-center gap-4 text-[10px] font-bold">
|
||||||
|
<div class="flex items-center gap-1.5"><span
|
||||||
|
class="w-2 h-2 rounded-full bg-indigo-500"></span> 消耗积分</div>
|
||||||
|
<div class="flex items-center gap-1.5"><span
|
||||||
|
class="w-2 h-2 rounded-full bg-emerald-500"></span> 充值积分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="h-64">
|
||||||
|
<canvas id="pointsChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div
|
||||||
|
class="bg-indigo-600 rounded-[2rem] p-8 text-white relative overflow-hidden shadow-2xl shadow-indigo-200">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<p class="text-xs font-bold opacity-80 mb-2">当前可用余额</p>
|
||||||
|
<h4 class="text-5xl font-black tracking-tighter mb-4"><span id="modalPointsDisplay">0</span>
|
||||||
|
<span class="text-lg opacity-60">Pts</span></h4>
|
||||||
|
<a href="/buy"
|
||||||
|
class="inline-flex items-center gap-2 bg-white/20 hover:bg-white/30 px-5 py-2.5 rounded-xl text-xs font-bold backdrop-blur-md transition-all">
|
||||||
|
<i data-lucide="plus-circle" class="w-4 h-4"></i> 立即充值
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<i data-lucide="wallet" class="absolute -right-6 -bottom-6 w-32 h-32 opacity-10 rotate-12"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 使用明细 -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-black text-slate-900 uppercase tracking-widest">积分消耗明细</h3>
|
||||||
|
<button onclick="loadPointDetails()"
|
||||||
|
class="text-indigo-500 hover:text-indigo-600 p-2 transition-colors">
|
||||||
|
<i data-lucide="rotate-cw" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-slate-100 rounded-[2rem] overflow-hidden shadow-sm">
|
||||||
|
<table class="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-slate-50 font-black text-[10px] text-slate-400 uppercase tracking-widest">
|
||||||
|
<th class="px-8 py-4">动作描述</th>
|
||||||
|
<th class="px-8 py-4">计算核心</th>
|
||||||
|
<th class="px-8 py-4">积分变动</th>
|
||||||
|
<th class="px-8 py-4 text-right">发生时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="pointDetailsBody" class="text-xs font-bold text-slate-600">
|
||||||
|
<!-- 动态数据 -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 修改密码弹窗 -->
|
<!-- 修改密码弹窗 -->
|
||||||
<div id="pwdModal"
|
<div id="pwdModal"
|
||||||
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
|
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
|
||||||
|
|||||||
@ -238,9 +238,9 @@
|
|||||||
<tr class="group hover:bg-slate-50/50 transition-all cursor-default">
|
<tr class="group hover:bg-slate-50/50 transition-all cursor-default">
|
||||||
<td class="px-8 py-6 text-slate-400 font-mono text-[11px] border-b border-slate-50">${log.time}</td>
|
<td class="px-8 py-6 text-slate-400 font-mono text-[11px] border-b border-slate-50">${log.time}</td>
|
||||||
<td class="px-8 py-6 border-b border-slate-50">
|
<td class="px-8 py-6 border-b border-slate-50">
|
||||||
<span class="px-2.5 py-1 rounded-lg text-[10px] font-black uppercase tracking-tight ${log.level === 'INFO' ? 'bg-indigo-50 text-indigo-600' :
|
<span class="inline-flex items-center justify-center px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider whitespace-nowrap ${log.level === 'INFO' ? 'bg-indigo-50 text-indigo-600' :
|
||||||
log.level === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-rose-50 text-rose-600'
|
log.level === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-rose-50 text-rose-600'
|
||||||
}">${log.level === 'INFO' ? '常规' : log.level === 'WARNING' ? '安全' : '异常'}</span>
|
}">${log.level === 'INFO' ? '常规操作' : log.level === 'WARNING' ? '安全警告' : '异常拦截'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-8 py-6 border-b border-slate-50">
|
<td class="px-8 py-6 border-b border-slate-50">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
|
|||||||
@ -1,260 +1,436 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}权限管理 - AI 视界{% endblock %}
|
{% block title %}RBAC 权限中心 - AI 视界{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="w-full h-full overflow-y-auto p-8 lg:p-12 custom-scrollbar">
|
<div class="w-full h-full overflow-hidden flex flex-col p-6 lg:p-10">
|
||||||
<div class="max-w-6xl mx-auto space-y-12">
|
<div class="max-w-7xl w-full mx-auto flex flex-col h-full space-y-6">
|
||||||
<!-- 头部 -->
|
|
||||||
<div class="flex items-center justify-between">
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between shrink-0">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="w-12 h-12 bg-indigo-600 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-indigo-200">
|
<div
|
||||||
|
class="w-12 h-12 bg-indigo-600 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-indigo-200">
|
||||||
<i data-lucide="shield-check" class="w-7 h-7"></i>
|
<i data-lucide="shield-check" class="w-7 h-7"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-black text-slate-900 tracking-tight">动态 RBAC 权限中心</h1>
|
<h1 class="text-3xl font-black text-slate-900 tracking-tight">权限控制中心</h1>
|
||||||
<p class="text-slate-400 text-sm">动态配置系统角色与权限资产</p>
|
<p class="text-slate-400 text-sm font-medium">RBAC Role-Based Access Control</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/" class="btn-primary px-6 py-3 rounded-xl text-sm font-bold shadow-lg">返回工作台</a>
|
<!-- Tabs Switcher -->
|
||||||
|
<div class="bg-slate-100 p-1.5 rounded-xl flex font-bold text-sm">
|
||||||
|
<button onclick="switchTab('roles')" id="tab-roles"
|
||||||
|
class="px-6 py-2.5 rounded-lg bg-white text-slate-900 shadow-sm transition-all">角色定义</button>
|
||||||
|
<button onclick="switchTab('users')" id="tab-users"
|
||||||
|
class="px-6 py-2.5 rounded-lg text-slate-500 hover:text-slate-900 transition-all">用户授权</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<!-- Tab 1: Roles Management -->
|
||||||
<!-- 左侧:编辑/创建角色 -->
|
<div id="view-roles" class="flex-1 flex gap-6 min-h-0 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div class="lg:col-span-1 space-y-6">
|
<!-- Left: Roles List -->
|
||||||
<div class="bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100">
|
<div class="w-1/3 flex flex-col gap-4">
|
||||||
<h3 id="formTitle" class="text-lg font-black text-slate-900 mb-6 flex items-center gap-2">
|
<div
|
||||||
<i data-lucide="plus-circle" class="w-5 h-5 text-indigo-500"></i>创建新角色
|
class="bg-white rounded-3xl shadow-xl border border-slate-100 flex-1 flex flex-col overflow-hidden">
|
||||||
</h3>
|
<div class="p-6 border-b border-slate-50 flex justify-between items-center bg-slate-50/50">
|
||||||
<div class="space-y-4">
|
<h3 class="font-black text-slate-800">角色列表</h3>
|
||||||
<input type="hidden" id="editRoleId">
|
<button onclick="createNewRole()"
|
||||||
<input id="newRoleName" type="text" placeholder="角色名称 (如: 审核员)" class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 text-sm font-bold outline-none focus:border-indigo-500">
|
class="p-2 bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors">
|
||||||
<textarea id="newRoleDesc" placeholder="角色描述" class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 text-sm font-medium outline-none focus:border-indigo-500 h-24 resize-none"></textarea>
|
<i data-lucide="plus" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="rolesList" class="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
|
||||||
|
<!-- Dynamic Roles -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<!-- Right: Role Editor -->
|
||||||
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">分配权限</label>
|
<div
|
||||||
<div id="permissionCheckboxes" class="grid grid-cols-1 gap-2 bg-slate-50 p-4 rounded-2xl">
|
class="w-2/3 bg-white rounded-3xl shadow-xl border border-slate-100 flex flex-col overflow-hidden relative">
|
||||||
<!-- 动态加载 -->
|
<!-- Overlay for no selection -->
|
||||||
|
<div id="roleEditorEmpty"
|
||||||
|
class="absolute inset-0 bg-white z-10 flex flex-col items-center justify-center text-slate-300">
|
||||||
|
<i data-lucide="shield" class="w-16 h-16 mb-4 opacity-50"></i>
|
||||||
|
<p class="font-bold">请选择左侧角色或创建新角色</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-8 h-full overflow-y-auto custom-scrollbar">
|
||||||
|
<div class="flex items-center justify-between mb-8">
|
||||||
|
<h2 id="editorTitle" class="text-xl font-black text-slate-900">编辑角色</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="btnDeleteRole"
|
||||||
|
class="hidden px-4 py-2 text-rose-500 bg-rose-50 hover:bg-rose-100 rounded-xl font-bold text-xs transition-colors">
|
||||||
|
删除角色
|
||||||
|
</button>
|
||||||
|
<button onclick="saveRole()"
|
||||||
|
class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-bold text-sm shadow-lg shadow-indigo-200 transition-all">
|
||||||
|
保存配置 (Save)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<input type="hidden" id="editRoleId">
|
||||||
|
<div class="grid grid-cols-2 gap-6">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">角色名称</label>
|
||||||
|
<input type="text" id="editRoleName" placeholder="如: 运营专员"
|
||||||
|
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 text-sm font-bold outline-none focus:border-indigo-500 transition-all">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label
|
||||||
|
class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">描述备注</label>
|
||||||
|
<input type="text" id="editRoleDesc" placeholder="简要描述职能"
|
||||||
|
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 text-sm font-medium outline-none focus:border-indigo-500 transition-all">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="space-y-4">
|
||||||
<button id="cancelEditBtn" class="hidden flex-1 px-4 py-4 rounded-2xl border border-slate-100 text-slate-400 font-bold hover:bg-slate-50 transition-all">取消</button>
|
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">权限分配
|
||||||
<button id="saveRoleBtn" class="flex-[2] btn-primary py-4 rounded-2xl shadow-lg font-bold">保存角色</button>
|
(Permissions)</label>
|
||||||
|
<div id="permissionsMatrix" class="grid grid-cols-2 gap-6">
|
||||||
|
<!-- Dynamic Permissions Groups -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 右侧:现有角色 & 用户管理 -->
|
<!-- Tab 2: Users Authorization -->
|
||||||
<div class="lg:col-span-2 space-y-8">
|
<div id="view-users"
|
||||||
<!-- 角色列表 -->
|
class="hidden flex-1 flex flex-col min-h-0 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div class="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden">
|
<div class="bg-white rounded-3xl shadow-xl border border-slate-100 flex-1 flex flex-col overflow-hidden">
|
||||||
<div class="p-8 border-b border-slate-50">
|
<!-- Toolbar -->
|
||||||
<h3 class="text-lg font-black text-slate-900 flex items-center gap-2">
|
<div class="p-6 border-b border-slate-50 flex items-center justify-between bg-slate-50/30">
|
||||||
<i data-lucide="users" class="w-5 h-5 text-indigo-500"></i>角色资产列表
|
<div class="relative w-96">
|
||||||
</h3>
|
<i data-lucide="search"
|
||||||
|
class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
|
||||||
|
<input type="text" id="userSearch" onkeyup="if(event.key === 'Enter') searchUsers()"
|
||||||
|
placeholder="搜索用户手机号..."
|
||||||
|
class="w-full pl-10 pr-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold outline-none focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 transition-all">
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="flex items-center gap-3">
|
||||||
<table class="w-full text-left">
|
<button onclick="searchUsers()"
|
||||||
<thead>
|
class="px-5 py-3 bg-slate-900 text-white rounded-xl font-bold text-sm hover:bg-slate-800 transition-all shadow-lg">查询</button>
|
||||||
<tr class="bg-slate-50">
|
|
||||||
<th class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">角色名称</th>
|
|
||||||
<th class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">描述</th>
|
|
||||||
<th class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">权限集</th>
|
|
||||||
<th class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="roleTableBody">
|
|
||||||
<!-- 动态加载 -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 用户列表 -->
|
<!-- Table -->
|
||||||
<div class="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden">
|
<div class="flex-1 overflow-auto">
|
||||||
<div class="p-8 border-b border-slate-50">
|
<table class="w-full text-left border-collapse">
|
||||||
<h3 class="text-lg font-black text-slate-900 flex items-center gap-2">
|
<thead
|
||||||
<i data-lucide="user-cog" class="w-5 h-5 text-indigo-500"></i>用户角色分配
|
class="sticky top-0 bg-white z-10 after:content-[''] after:absolute after:bottom-0 after:left-0 after:right-0 after:border-b after:border-slate-100">
|
||||||
</h3>
|
<tr>
|
||||||
</div>
|
<th class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">用户
|
||||||
<table class="w-full text-left">
|
ID</th>
|
||||||
<thead>
|
<th class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||||
<tr class="bg-slate-50">
|
手机号</th>
|
||||||
<th class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">用户手机</th>
|
<th class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||||
<th class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">当前角色</th>
|
当前角色</th>
|
||||||
<th class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">操作</th>
|
<th
|
||||||
|
class="px-8 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">
|
||||||
|
操作 (分配角色)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="userTableBody">
|
<tbody id="usersTableBody">
|
||||||
<!-- 动态加载 -->
|
<!-- Dynamic Users -->
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="p-4 border-t border-slate-50 flex items-center justify-between bg-slate-50/50">
|
||||||
|
<span class="text-xs font-bold text-slate-400" id="pageInfo">共 0 条数据</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="changePage(-1)"
|
||||||
|
class="p-2 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50"><i
|
||||||
|
data-lucide="chevron-left" class="w-4 h-4"></i></button>
|
||||||
|
<button onclick="changePage(1)"
|
||||||
|
class="p-2 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50"><i
|
||||||
|
data-lucide="chevron-right" class="w-4 h-4"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
// State
|
||||||
|
let currentTab = 'roles';
|
||||||
let allRoles = [];
|
let allRoles = [];
|
||||||
|
let allPermissions = [];
|
||||||
|
let currentRole = null;
|
||||||
|
|
||||||
async function loadPermissions() {
|
let userPage = 1;
|
||||||
const r = await fetch('/api/admin/permissions');
|
let userTotalPages = 1;
|
||||||
const d = await r.json();
|
|
||||||
const container = document.getElementById('permissionCheckboxes');
|
// --- Tab Logic ---
|
||||||
container.innerHTML = d.permissions.map(p => `
|
function switchTab(tab) {
|
||||||
<label class="flex items-center gap-3 cursor-pointer group">
|
currentTab = tab;
|
||||||
<input type="checkbox" value="${p.name}" class="w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
document.querySelectorAll('[id^="tab-"]').forEach(el => {
|
||||||
<span class="text-xs font-bold text-slate-600 group-hover:text-slate-900">${p.description}</span>
|
if (el.id === `tab-${tab}`) {
|
||||||
</label>
|
el.classList.remove('bg-white', 'text-slate-500');
|
||||||
`).join('');
|
el.classList.add('bg-white', 'text-slate-900', 'shadow-sm'); // Actually active doesn't have bg-gray-100, just white + shadow
|
||||||
|
} else {
|
||||||
|
el.classList.add('text-slate-500');
|
||||||
|
el.classList.remove('bg-white', 'text-slate-900', 'shadow-sm');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('view-roles').classList.toggle('hidden', tab !== 'roles');
|
||||||
|
document.getElementById('view-users').classList.toggle('hidden', tab !== 'users');
|
||||||
|
|
||||||
|
if (tab === 'users') loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRoles() {
|
// --- Logic: Roles ---
|
||||||
const r = await fetch('/api/admin/roles');
|
async function initRolesView() {
|
||||||
const d = await r.json();
|
const [pRes, rRes] = await Promise.all([
|
||||||
allRoles = d.roles;
|
fetch('/api/admin/permissions').then(r => r.json()),
|
||||||
const container = document.getElementById('roleTableBody');
|
fetch('/api/admin/roles').then(r => r.json())
|
||||||
container.innerHTML = d.roles.map(role => `
|
]);
|
||||||
<tr class="border-b border-slate-50 hover:bg-slate-50/50 transition-colors">
|
|
||||||
<td class="px-8 py-5">
|
allPermissions = pRes.permissions;
|
||||||
<div class="font-black text-slate-900">${role.name}</div>
|
allRoles = rRes.roles;
|
||||||
<div class="text-[10px] text-slate-400 font-bold uppercase tracking-tighter">ID: ${role.id}</div>
|
renderRolesList();
|
||||||
</td>
|
renderPermissionsMatrix(); // With no checked
|
||||||
<td class="px-8 py-5 text-xs text-slate-500 max-w-[150px] truncate">${role.description || '-'}</td>
|
}
|
||||||
<td class="px-8 py-5">
|
|
||||||
<div class="flex flex-wrap gap-1 max-w-[240px]">
|
function renderRolesList() {
|
||||||
${role.permissions.map(p => `<span class="px-1.5 py-0.5 bg-indigo-50 text-indigo-500 text-[9px] font-black rounded uppercase border border-indigo-100/50">${p}</span>`).join('')}
|
const container = document.getElementById('rolesList');
|
||||||
</div>
|
container.innerHTML = allRoles.map(role => `
|
||||||
</td>
|
<div onclick='selectRole(${role.id})'
|
||||||
<td class="px-8 py-5">
|
class="p-4 rounded-2xl cursor-pointer border-2 transition-all group hover:bg-slate-50
|
||||||
<div class="flex gap-2">
|
${currentRole && currentRole.id === role.id ? 'border-indigo-600 bg-indigo-50/30' : 'border-transparent bg-slate-50'}">
|
||||||
<button onclick='editRole(${JSON.stringify(role)})' class="p-2 text-indigo-400 hover:bg-indigo-50 rounded-xl transition-all">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<i data-lucide="edit-2" class="w-4 h-4"></i>
|
<span class="font-bold text-slate-800">${role.name}</span>
|
||||||
</button>
|
<i data-lucide="chevron-right" class="w-4 h-4 text-slate-300 ${currentRole && currentRole.id === role.id ? 'text-indigo-600' : ''}"></i>
|
||||||
${role.name !== '超级管理员' ? `
|
</div>
|
||||||
<button onclick="deleteRole(${role.id})" class="p-2 text-rose-300 hover:bg-rose-50 rounded-xl transition-all">
|
<div class="text-xs text-slate-400 truncate">${role.description || '无描述'}</div>
|
||||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
</div>
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
`).join('');
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function editRole(role) {
|
function selectRole(id) {
|
||||||
document.getElementById('formTitle').innerHTML = `<i data-lucide="edit-3" class="w-5 h-5 text-indigo-500"></i>编辑角色`;
|
currentRole = allRoles.find(r => r.id === id);
|
||||||
document.getElementById('editRoleId').value = role.id;
|
renderRolesList(); // Refresh active state
|
||||||
document.getElementById('newRoleName').value = role.name;
|
|
||||||
document.getElementById('newRoleDesc').value = role.description || '';
|
|
||||||
document.getElementById('cancelEditBtn').classList.remove('hidden');
|
|
||||||
|
|
||||||
// 重置并勾选权限
|
document.getElementById('roleEditorEmpty').classList.add('hidden');
|
||||||
document.querySelectorAll('#permissionCheckboxes input').forEach(cb => {
|
document.getElementById('editorTitle').innerText = '编辑角色';
|
||||||
cb.checked = role.permissions.includes(cb.value);
|
document.getElementById('editRoleId').value = currentRole.id;
|
||||||
});
|
document.getElementById('editRoleName').value = currentRole.name;
|
||||||
lucide.createIcons();
|
document.getElementById('editRoleDesc').value = currentRole.description || '';
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('cancelEditBtn').onclick = resetForm;
|
// Delete button
|
||||||
|
const delBtn = document.getElementById('btnDeleteRole');
|
||||||
function resetForm() {
|
if (currentRole.name === '超级管理员') {
|
||||||
document.getElementById('formTitle').innerHTML = `<i data-lucide="plus-circle" class="w-5 h-5 text-indigo-500"></i>创建新角色`;
|
delBtn.classList.add('hidden');
|
||||||
document.getElementById('editRoleId').value = '';
|
|
||||||
document.getElementById('newRoleName').value = '';
|
|
||||||
document.getElementById('newRoleDesc').value = '';
|
|
||||||
document.getElementById('cancelEditBtn').classList.add('hidden');
|
|
||||||
document.querySelectorAll('#permissionCheckboxes input').forEach(cb => cb.checked = false);
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteRole(id) {
|
|
||||||
if(!confirm('确定要删除此角色吗?所有关联该角色的用户将失去权限。')) return;
|
|
||||||
const r = await fetch('/api/admin/roles/delete', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ id })
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
if(d.message) {
|
|
||||||
showToast(d.message, 'success');
|
|
||||||
loadRoles();
|
|
||||||
loadUsers();
|
|
||||||
} else {
|
} else {
|
||||||
showToast(d.error, 'error');
|
delBtn.classList.remove('hidden');
|
||||||
|
delBtn.onclick = () => deleteRole(currentRole.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
document.querySelectorAll('.perm-check').forEach(cb => {
|
||||||
|
cb.checked = currentRole.permissions.includes(cb.value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
function createNewRole() {
|
||||||
const r = await fetch('/api/admin/users');
|
currentRole = { id: '', name: '', description: '', permissions: [] };
|
||||||
const d = await r.json();
|
renderRolesList(); // Clear active selection visual
|
||||||
const body = document.getElementById('userTableBody');
|
|
||||||
body.innerHTML = d.users.map(user => `
|
document.getElementById('roleEditorEmpty').classList.add('hidden');
|
||||||
<tr class="border-b border-slate-50">
|
document.getElementById('editorTitle').innerText = '创建新角色';
|
||||||
<td class="px-8 py-4 text-sm font-bold text-slate-700">${user.phone}</td>
|
document.getElementById('editRoleId').value = '';
|
||||||
<td class="px-8 py-4">
|
document.getElementById('editRoleName').value = '';
|
||||||
<span class="px-3 py-1 bg-indigo-50 text-indigo-600 rounded-full text-xs font-black">${user.role}</span>
|
document.getElementById('editRoleDesc').value = '';
|
||||||
</td>
|
document.getElementById('btnDeleteRole').classList.add('hidden');
|
||||||
<td class="px-8 py-4">
|
|
||||||
<select onchange="assignRole(${user.id}, this.value)" class="bg-white border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold outline-none">
|
document.querySelectorAll('.perm-check').forEach(cb => cb.checked = false);
|
||||||
<option value="">更改角色...</option>
|
document.getElementById('editRoleName').focus();
|
||||||
${allRoles.map(role => `<option value="${role.id}">${role.name}</option>`).join('')}
|
}
|
||||||
</select>
|
|
||||||
</td>
|
function renderPermissionsMatrix() {
|
||||||
</tr>
|
// Simple grouping logic based on name prefix
|
||||||
|
const groups = {};
|
||||||
|
allPermissions.forEach(p => {
|
||||||
|
const prefix = p.name.split('_')[0]; // manage, view, etc.
|
||||||
|
let groupName = '其他权限';
|
||||||
|
if (p.name.includes('user')) groupName = '用户管理';
|
||||||
|
else if (p.name.includes('role') || p.name.includes('rbac')) groupName = '角色权限';
|
||||||
|
else if (p.name.includes('dict') || p.name.includes('system') || p.name.includes('notif')) groupName = '系统配置';
|
||||||
|
else if (p.name.includes('log')) groupName = '日志审计';
|
||||||
|
else if (p.name.includes('order')) groupName = '订单财务';
|
||||||
|
|
||||||
|
if (!groups[groupName]) groups[groupName] = [];
|
||||||
|
groups[groupName].push(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = document.getElementById('permissionsMatrix');
|
||||||
|
container.innerHTML = Object.keys(groups).map(gName => `
|
||||||
|
<div class="bg-slate-50 p-5 rounded-2xl border border-slate-100">
|
||||||
|
<h4 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4 border-b border-slate-200 pb-2">${gName}</h4>
|
||||||
|
<div class="grid grid-cols-1 gap-3">
|
||||||
|
${groups[gName].map(p => `
|
||||||
|
<label class="flex items-center justify-between cursor-pointer group">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-bold text-slate-700">${p.description || p.name}</div>
|
||||||
|
<div class="text-[10px] text-slate-400 font-mono">${p.name}</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" value="${p.name}" class="perm-check sr-only peer">
|
||||||
|
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none ring-4 ring-indigo-500/10 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assignRole(userId, roleId) {
|
async function saveRole() {
|
||||||
if(!roleId) return;
|
|
||||||
const r = await fetch('/api/admin/users/assign', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({ user_id: userId, role_id: roleId })
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
if(d.message) {
|
|
||||||
showToast(d.message, 'success');
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('saveRoleBtn').onclick = async () => {
|
|
||||||
const id = document.getElementById('editRoleId').value;
|
const id = document.getElementById('editRoleId').value;
|
||||||
const name = document.getElementById('newRoleName').value;
|
const name = document.getElementById('editRoleName').value;
|
||||||
const description = document.getElementById('newRoleDesc').value;
|
const description = document.getElementById('editRoleDesc').value;
|
||||||
const permissions = Array.from(document.querySelectorAll('#permissionCheckboxes input:checked')).map(i => i.value);
|
const permissions = Array.from(document.querySelectorAll('.perm-check:checked')).map(cb => cb.value);
|
||||||
|
|
||||||
if(!name) return showToast('请输入角色名称', 'warning');
|
if (!name) return showToast('角色名不能为空', 'warning');
|
||||||
|
|
||||||
const r = await fetch('/api/admin/roles', {
|
const r = await fetch('/api/admin/roles', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, name, description, permissions })
|
body: JSON.stringify({ id, name, description, permissions })
|
||||||
});
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if(d.message) {
|
|
||||||
showToast(d.message, 'success');
|
if (d.message) {
|
||||||
loadRoles();
|
showToast('✅ 保存成功', 'success');
|
||||||
loadUsers();
|
// Reload roles but keep editing
|
||||||
resetForm();
|
const rRes = await fetch('/api/admin/roles').then(r => r.json());
|
||||||
|
allRoles = rRes.roles;
|
||||||
|
|
||||||
|
// If created new, find it
|
||||||
|
if (!id) {
|
||||||
|
const newRole = allRoles.find(r => r.name === name);
|
||||||
|
if (newRole) selectRole(newRole.id);
|
||||||
|
else renderRolesList();
|
||||||
|
} else {
|
||||||
|
renderRolesList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRole(id) {
|
||||||
|
if (!confirm('确定删除此角色吗?')) return;
|
||||||
|
const r = await fetch('/api/admin/roles/delete', {
|
||||||
|
method: 'POST', body: JSON.stringify({ id }), headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.message) {
|
||||||
|
showToast('已删除', 'success');
|
||||||
|
const rRes = await fetch('/api/admin/roles').then(r => r.json());
|
||||||
|
allRoles = rRes.roles;
|
||||||
|
createNewRole(); // Reset view
|
||||||
|
renderRolesList();
|
||||||
} else {
|
} else {
|
||||||
showToast(d.error, 'error');
|
showToast(d.error, 'error');
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
await loadPermissions();
|
|
||||||
await loadRoles();
|
|
||||||
await loadUsers();
|
|
||||||
}
|
}
|
||||||
init();
|
|
||||||
|
// --- Logic: Users ---
|
||||||
|
async function loadUsers() {
|
||||||
|
const q = document.getElementById('userSearch').value;
|
||||||
|
const r = await fetch(`/api/admin/users?page=${userPage}&per_page=10&q=${encodeURIComponent(q)}`);
|
||||||
|
const d = await r.json();
|
||||||
|
|
||||||
|
userTotalPages = d.pages;
|
||||||
|
document.getElementById('pageInfo').innerText = `第 ${d.current_page} / ${d.pages} 页 (共 ${d.total} 人)`;
|
||||||
|
|
||||||
|
const tbody = document.getElementById('usersTableBody');
|
||||||
|
tbody.innerHTML = d.users.map(user => `
|
||||||
|
<tr class="border-b border-slate-50 hover:bg-slate-50/50 ${user.is_banned ? 'opacity-50' : ''}">
|
||||||
|
<td class="px-8 py-4 text-xs font-mono text-slate-400">${user.id}</td>
|
||||||
|
<td class="px-8 py-4 text-sm font-bold ${user.is_banned ? 'text-rose-500' : 'text-slate-700'}">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${user.phone}
|
||||||
|
${user.is_banned ? '<span class="px-1.5 py-0.5 bg-rose-50 text-rose-500 text-[9px] font-black rounded uppercase border border-rose-100/50">BANNED</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-8 py-4">
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold ${user.role === '未分配' ? 'bg-slate-100 text-slate-500' : 'bg-indigo-50 text-indigo-600'}">
|
||||||
|
${user.role === '未分配' ? '<i data-lucide="alert-circle" class="w-3 h-3"></i>' : '<i data-lucide="shield-check" class="w-3 h-3"></i>'}
|
||||||
|
${user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-8 py-4 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button onclick="toggleBan(${user.id})" class="px-3 py-1.5 rounded-lg text-[10px] font-black uppercase transition-all ${user.is_banned ? 'bg-emerald-50 text-emerald-600 hover:bg-emerald-100' : 'bg-rose-50 text-rose-500 hover:bg-rose-100'}">
|
||||||
|
${user.is_banned ? '解封 (Unban)' : '封禁 (Ban)'}
|
||||||
|
</button>
|
||||||
|
<select onchange="assignUserRole(${user.id}, this.value)" class="bg-white border border-slate-200 rounded-lg px-2 py-1.5 text-xs font-bold outline-none hover:border-indigo-400 focus:border-indigo-500 transition-colors cursor-pointer">
|
||||||
|
<option value="">${user.role_id ? '变更角色...' : '分配角色...'}</option>
|
||||||
|
${allRoles.map(role => `<option value="${role.id}" ${user.role_id === role.id ? 'selected hidden' : ''}>${role.name}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchUsers() {
|
||||||
|
userPage = 1;
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(delta) {
|
||||||
|
const newPage = userPage + delta;
|
||||||
|
if (newPage >= 1 && newPage <= userTotalPages) {
|
||||||
|
userPage = newPage;
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignUserRole(uid, rid) {
|
||||||
|
if (!rid) return;
|
||||||
|
const r = await fetch('/api/admin/users/assign', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ user_id: uid, role_id: rid }),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.message) {
|
||||||
|
showToast('✅ 授权成功', 'success');
|
||||||
|
loadUsers(); // Refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleBan(uid) {
|
||||||
|
const r = await fetch('/api/admin/users/toggle_ban', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ user_id: uid }),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.message) {
|
||||||
|
showToast(d.message, 'success');
|
||||||
|
loadUsers();
|
||||||
|
} else {
|
||||||
|
showToast(d.error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
initRolesView();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -14,75 +14,88 @@
|
|||||||
查看您的所有积分充值历史
|
查看您的所有积分充值历史
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('auth.buy_page') }}" class="btn-primary px-6 py-3 rounded-2xl flex items-center gap-2 shadow-xl shadow-indigo-200">
|
<a href="{{ url_for('auth.buy_page') }}"
|
||||||
|
class="btn-primary px-6 py-3 rounded-2xl flex items-center gap-2 shadow-xl shadow-indigo-200">
|
||||||
<i data-lucide="plus-circle" class="w-5 h-5"></i>
|
<i data-lucide="plus-circle" class="w-5 h-5"></i>
|
||||||
立即充值
|
立即充值
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 记录列表 -->
|
<!-- 记录列表 -->
|
||||||
<div class="bg-white/70 backdrop-blur-xl rounded-[2.5rem] border border-slate-200/50 shadow-2xl overflow-hidden">
|
<div
|
||||||
|
class="bg-white/70 backdrop-blur-xl rounded-[2.5rem] border border-slate-200/50 shadow-2xl overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-left border-collapse">
|
<table class="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-slate-100 bg-slate-50/50">
|
<tr class="border-b border-slate-100 bg-slate-50/50">
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">订单号</th>
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">订单号
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">积分</th>
|
</th>
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">金额</th>
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">积分
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">状态</th>
|
</th>
|
||||||
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">支付时间</th>
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">金额
|
||||||
|
</th>
|
||||||
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">状态
|
||||||
|
</th>
|
||||||
|
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">支付时间
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
{% if orders %}
|
{% if orders %}
|
||||||
{% for order in orders %}
|
{% for order in orders %}
|
||||||
<tr class="hover:bg-slate-50/50 transition-colors group">
|
<tr class="hover:bg-slate-50/50 transition-colors group">
|
||||||
<td class="px-8 py-5">
|
<td class="px-8 py-5">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-sm font-bold text-slate-700">{{ order.out_trade_no }}</span>
|
<span class="text-sm font-bold text-slate-700">{{ order.out_trade_no }}</span>
|
||||||
<span class="text-[10px] text-slate-400 font-mono">Ali: {{ order.trade_no or '-' }}</span>
|
<span class="text-[10px] text-slate-400 font-mono">Ali: {{ order.trade_no or '-'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-8 py-5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 bg-amber-50 text-amber-500 rounded-lg flex items-center justify-center">
|
||||||
|
<i data-lucide="zap" class="w-4 h-4"></i>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<span class="text-sm font-black text-slate-900">+{{ order.points }}</span>
|
||||||
<td class="px-8 py-5">
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
</td>
|
||||||
<div class="w-8 h-8 bg-amber-50 text-amber-500 rounded-lg flex items-center justify-center">
|
<td class="px-8 py-5">
|
||||||
<i data-lucide="zap" class="w-4 h-4"></i>
|
<span class="text-sm font-bold text-slate-600">¥{{ order.amount }}</span>
|
||||||
</div>
|
</td>
|
||||||
<span class="text-sm font-black text-slate-900">+{{ order.points }}</span>
|
<td class="px-8 py-5">
|
||||||
</div>
|
{% if order.status == 'PAID' %}
|
||||||
</td>
|
<span
|
||||||
<td class="px-8 py-5">
|
class="px-3 py-1 bg-emerald-50 text-emerald-600 text-[10px] font-black rounded-lg border border-emerald-100">已完成</span>
|
||||||
<span class="text-sm font-bold text-slate-600">¥{{ order.amount }}</span>
|
{% elif order.status == 'PENDING' %}
|
||||||
</td>
|
<span
|
||||||
<td class="px-8 py-5">
|
class="px-3 py-1 bg-amber-50 text-amber-600 text-[10px] font-black rounded-lg border border-amber-100">待支付</span>
|
||||||
{% if order.status == 'PAID' %}
|
{% else %}
|
||||||
<span class="px-3 py-1 bg-emerald-50 text-emerald-600 text-[10px] font-black rounded-lg border border-emerald-100">已完成</span>
|
<span
|
||||||
{% elif order.status == 'PENDING' %}
|
class="px-3 py-1 bg-slate-50 text-slate-400 text-[10px] font-black rounded-lg border border-slate-100">已取消</span>
|
||||||
<span class="px-3 py-1 bg-amber-50 text-amber-600 text-[10px] font-black rounded-lg border border-amber-100">待支付</span>
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-8 py-5">
|
||||||
|
<span class="text-xs font-bold text-slate-400">
|
||||||
|
{% if order.paid_at %}
|
||||||
|
{{ (order.paid_at + modules.datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M')
|
||||||
|
}}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="px-3 py-1 bg-slate-50 text-slate-400 text-[10px] font-black rounded-lg border border-slate-100">已取消</span>
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</span>
|
||||||
<td class="px-8 py-5">
|
</td>
|
||||||
<span class="text-xs font-bold text-slate-400">
|
</tr>
|
||||||
{% if order.paid_at %}
|
{% endfor %}
|
||||||
{{ (order.paid_at + modules.datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M') }}
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-8 py-20 text-center">
|
<td colspan="5" class="px-8 py-20 text-center">
|
||||||
<div class="flex flex-col items-center gap-4 opacity-20">
|
<div class="flex flex-col items-center gap-4 opacity-20">
|
||||||
<i data-lucide="inbox" class="w-16 h-16"></i>
|
<i data-lucide="inbox" class="w-16 h-16"></i>
|
||||||
<p class="font-black text-xl">暂无充值记录</p>
|
<p class="font-black text-xl">暂无充值记录</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@ -1,165 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
支付宝签名验证测试脚本
|
|
||||||
用于诊断支付宝回调验证失败的问题
|
|
||||||
"""
|
|
||||||
|
|
||||||
from alipay import AliPay
|
|
||||||
from config import Config
|
|
||||||
import logging
|
|
||||||
|
|
||||||
# 配置日志
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG,
|
|
||||||
format='[%(asctime)s] %(levelname)s - %(message)s'
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def test_alipay_config():
|
|
||||||
"""测试支付宝配置是否正确"""
|
|
||||||
print("="*60)
|
|
||||||
print("支付宝配置信息检查")
|
|
||||||
print("="*60)
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
|
|
||||||
print(f"AppID: {config.ALIPAY_APP_ID}")
|
|
||||||
print(f"调试模式: {config.ALIPAY_DEBUG}")
|
|
||||||
print(f"回调地址: {config.ALIPAY_RETURN_URL}")
|
|
||||||
print(f"异步通知地址: {config.ALIPAY_NOTIFY_URL}")
|
|
||||||
print(f"应用私钥长度: {len(config.ALIPAY_APP_PRIVATE_KEY)}")
|
|
||||||
print(f"支付宝公钥长度: {len(config.ALIPAY_PUBLIC_KEY)}")
|
|
||||||
|
|
||||||
# 检查密钥格式
|
|
||||||
if "BEGIN RSA PRIVATE KEY" in config.ALIPAY_APP_PRIVATE_KEY:
|
|
||||||
print("✓ 应用私钥格式正确")
|
|
||||||
else:
|
|
||||||
print("✗ 应用私钥格式错误")
|
|
||||||
|
|
||||||
if "BEGIN PUBLIC KEY" in config.ALIPAY_PUBLIC_KEY:
|
|
||||||
print("✓ 支付宝公钥格式正确")
|
|
||||||
else:
|
|
||||||
print("✗ 支付宝公钥格式错误")
|
|
||||||
|
|
||||||
print("\n")
|
|
||||||
|
|
||||||
def test_alipay_client():
|
|
||||||
"""测试支付宝客户端初始化"""
|
|
||||||
print("="*60)
|
|
||||||
print("支付宝客户端初始化测试")
|
|
||||||
print("="*60)
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
|
|
||||||
try:
|
|
||||||
alipay = AliPay(
|
|
||||||
appid=config.ALIPAY_APP_ID,
|
|
||||||
app_notify_url=config.ALIPAY_NOTIFY_URL,
|
|
||||||
app_private_key_string=config.ALIPAY_APP_PRIVATE_KEY,
|
|
||||||
alipay_public_key_string=config.ALIPAY_PUBLIC_KEY,
|
|
||||||
sign_type="RSA2",
|
|
||||||
debug=config.ALIPAY_DEBUG
|
|
||||||
)
|
|
||||||
print("✓ 支付宝客户端初始化成功")
|
|
||||||
return alipay
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ 支付宝客户端初始化失败: {str(e)}")
|
|
||||||
logger.error("客户端初始化失败", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def test_signature_verification():
|
|
||||||
"""测试签名验证功能"""
|
|
||||||
print("\n")
|
|
||||||
print("="*60)
|
|
||||||
print("签名验证功能测试")
|
|
||||||
print("="*60)
|
|
||||||
|
|
||||||
alipay = test_alipay_client()
|
|
||||||
if not alipay:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 模拟支付宝回调数据(这是一个示例,需要替换为真实的回调数据)
|
|
||||||
test_data = {
|
|
||||||
"gmt_create": "2024-01-01 00:00:00",
|
|
||||||
"charset": "utf-8",
|
|
||||||
"seller_email": "test@example.com",
|
|
||||||
"subject": "测试订单",
|
|
||||||
"sign_type": "RSA2",
|
|
||||||
"buyer_id": "2088000000000000",
|
|
||||||
"invoice_amount": "10.00",
|
|
||||||
"notify_id": "test_notify_id",
|
|
||||||
"fund_bill_list": "[{\"amount\":\"10.00\",\"fundChannel\":\"ALIPAYACCOUNT\"}]",
|
|
||||||
"notify_type": "trade_status_sync",
|
|
||||||
"trade_status": "TRADE_SUCCESS",
|
|
||||||
"receipt_amount": "10.00",
|
|
||||||
"buyer_pay_amount": "10.00",
|
|
||||||
"app_id": Config().ALIPAY_APP_ID,
|
|
||||||
"notify_time": "2024-01-01 00:00:00",
|
|
||||||
"point_amount": "0.00",
|
|
||||||
"total_amount": "10.00",
|
|
||||||
"seller_id": "2088000000000000",
|
|
||||||
"trade_no": "2024010100000000000",
|
|
||||||
"auth_app_id": Config().ALIPAY_APP_ID,
|
|
||||||
"buyer_logon_id": "test***@example.com",
|
|
||||||
"out_trade_no": "test_order_123456"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 注意: 这里无法进行真实的签名验证测试,因为需要真实的签名值
|
|
||||||
print("提示: 需要真实的支付宝回调数据才能进行完整测试")
|
|
||||||
print("请在日志中查看实际回调时的数据和签名")
|
|
||||||
print("\n")
|
|
||||||
|
|
||||||
def print_troubleshooting_guide():
|
|
||||||
"""打印故障排查指南"""
|
|
||||||
print("="*60)
|
|
||||||
print("支付宝验证失败常见问题排查")
|
|
||||||
print("="*60)
|
|
||||||
print("""
|
|
||||||
1. 密钥配置问题:
|
|
||||||
- 确认应用私钥和支付宝公钥是否匹配
|
|
||||||
- 检查密钥格式是否包含完整的BEGIN/END标记
|
|
||||||
- 确认沙箱环境/正式环境的配置一致性
|
|
||||||
|
|
||||||
2. 签名验证问题:
|
|
||||||
- 确认签名类型为RSA2
|
|
||||||
- 检查是否正确移除了sign和sign_type参数
|
|
||||||
- 验证参数顺序和编码格式
|
|
||||||
|
|
||||||
3. 环境配置问题:
|
|
||||||
- ALIPAY_DEBUG设置: True=沙箱环境, False=正式环境
|
|
||||||
- 回调地址必须是外网可访问的地址
|
|
||||||
- 检查防火墙/端口是否开放
|
|
||||||
|
|
||||||
4. 调试建议:
|
|
||||||
- 开启详细日志,查看完整的回调参数
|
|
||||||
- 使用支付宝开放平台的"验签工具"验证签名
|
|
||||||
- 检查服务器时间是否准确
|
|
||||||
- 确认订单金额格式正确(字符串类型,保留两位小数)
|
|
||||||
|
|
||||||
5. 沙箱环境测试:
|
|
||||||
- 确保使用沙箱环境的AppID和密钥
|
|
||||||
- 使用沙箱账号进行测试
|
|
||||||
- 沙箱环境域名: https://openapi-sandbox.dl.alipaydev.com/gateway.do
|
|
||||||
|
|
||||||
6. 查看日志:
|
|
||||||
- 检查 logs/system.log 文件
|
|
||||||
- 查看控制台输出的详细错误信息
|
|
||||||
- 分析支付宝返回的错误码和错误信息
|
|
||||||
""")
|
|
||||||
print("="*60)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("\n支付宝签名验证诊断工具\n")
|
|
||||||
|
|
||||||
# 运行测试
|
|
||||||
test_alipay_config()
|
|
||||||
test_alipay_client()
|
|
||||||
test_signature_verification()
|
|
||||||
print_troubleshooting_guide()
|
|
||||||
|
|
||||||
print("\n测试完成!")
|
|
||||||
print("如果仍然有问题,请:")
|
|
||||||
print("1. 运行实际支付流程并查看日志")
|
|
||||||
print("2. 复制日志中的回调数据和签名")
|
|
||||||
print("3. 使用支付宝官方验签工具验证\n")
|
|
||||||
Loading…
Reference in New Issue
Block a user