feat(api): 实现图像生成及后台同步功能

- 新增图像生成接口,支持试用、积分和自定义API Key模式
- 实现生成图片结果异步上传至MinIO存储,带重试机制
- 优化积分预扣除和异常退还逻辑,保障用户积分准确
- 添加获取生成历史记录接口,支持时间范围和分页
- 提供本地字典配置接口,支持模型、比例、提示模板和尺寸
- 实现图片批量上传接口,支持S3兼容对象存储

feat(admin): 增加管理员角色管理与权限分配接口

- 实现角色列表查询、角色创建、更新及删除功能
- 增加权限列表查询接口
- 实现用户角色分配接口,便于统一管理用户权限
- 增加系统字典增删查改接口,支持分类过滤和排序
- 权限控制全面覆盖管理接口,保证安全访问

feat(auth): 完善用户登录注册及权限相关接口与页面

- 实现手机号验证码发送及校验功能,保障注册安全
- 支持手机号注册、登录及退出接口,集成日志记录
- 增加修改密码功能,验证原密码后更新
- 提供动态导航菜单接口,基于权限展示不同菜单
- 实现管理界面路由及日志、角色、字典管理页面访问权限控制
- 添加系统日志查询接口,支持关键词和等级筛选

feat(app): 初始化Flask应用并配置蓝图与数据库

- 创建应用程序工厂,加载配置,初始化数据库和Redis客户端
- 注册认证、API及管理员蓝图,整合路由
- 根路由渲染主页模板
- 应用上下文中自动创建数据库表,保证运行环境准备完毕

feat(database): 提供数据库创建与迁移支持脚本

- 新增数据库创建脚本,支持自动检测是否已存在
- 添加数据库表初始化脚本,支持创建和删除所有表
- 实现RBAC权限初始化,包含基础权限和角色创建
- 新增字段手动修复脚本,添加用户API Key和积分字段
- 强制迁移脚本支持清理连接和修复表结构,初始化默认数据及角色分配

feat(config): 新增系统配置参数

- 配置数据库、Redis、Session和MinIO相关参数
- 添加AI接口地址及试用Key配置
- 集成阿里云短信服务配置及开发模式相关参数

feat(extensions): 初始化数据库、Redis和MinIO客户端

- 创建全局SQLAlchemy数据库实例和Redis客户端
- 配置基于boto3的MinIO兼容S3客户端

chore(logs): 添加示例系统日志文件

- 记录用户请求、验证码发送成功与失败的日志信息
This commit is contained in:
24024 2026-01-12 00:53:31 +08:00
commit af7c11d7f9
6371 changed files with 953781 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

41
app.py Normal file
View File

@ -0,0 +1,41 @@
from flask import Flask, render_template
from config import Config
from extensions import db, redis_client
from blueprints.auth import auth_bp
from blueprints.api import api_bp
from blueprints.admin import admin_bp
import threading
import time
# 导入模型(必须在 db.create_all() 之前导入)
import models
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
# 初始化扩展
db.init_app(app)
redis_client.init_app(app)
# 注册蓝图
app.register_blueprint(auth_bp)
app.register_blueprint(api_bp)
app.register_blueprint(admin_bp)
@app.route('/')
def index():
return render_template('index.html')
# 自动创建数据库表
with app.app_context():
print("🔧 正在检查并创建数据库表...")
db.create_all()
print("✅ 数据库表已就绪")
return app
app = create_app()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)

Binary file not shown.

Binary file not shown.

Binary file not shown.

144
blueprints/admin.py Normal file
View File

@ -0,0 +1,144 @@
from flask import Blueprint, request, jsonify
from extensions import db
from models import User, Role, Permission, SystemDict
from middlewares.auth import permission_required
admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin')
# --- 角色管理 ---
@admin_bp.route('/roles', methods=['GET'])
@permission_required('manage_rbac')
def get_roles():
roles = Role.query.all()
return jsonify({
"roles": [{
"id": r.id,
"name": r.name,
"description": r.description,
"permissions": [p.name for p in r.permissions]
} for r in roles]
})
@admin_bp.route('/roles', methods=['POST'])
@permission_required('manage_rbac')
def save_role():
data = request.json
role_id = data.get('id')
if role_id:
role = Role.query.get(role_id)
if not role: return jsonify({"error": "角色不存在"}), 404
role.name = data['name']
role.description = data.get('description')
else:
role = Role(name=data['name'], description=data.get('description'))
db.session.add(role)
if 'permissions' in data:
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
role.permissions = perms
db.session.commit()
return jsonify({"message": "角色保存成功"})
@admin_bp.route('/roles/delete', methods=['POST'])
@permission_required('manage_rbac')
def delete_role():
data = request.json
role = Role.query.get(data.get('id'))
if role:
if role.name == '超级管理员':
return jsonify({"error": "不能删除超级管理员角色"}), 400
db.session.delete(role)
db.session.commit()
return jsonify({"message": "角色删除成功"})
return jsonify({"error": "角色不存在"}), 404
# --- 权限管理 ---
@admin_bp.route('/permissions', methods=['GET'])
@permission_required('manage_rbac')
def get_permissions():
perms = Permission.query.all()
return jsonify({
"permissions": [{"name": p.name, "description": p.description} for p in perms]
})
# --- 用户角色分配 ---
@admin_bp.route('/users', methods=['GET'])
@permission_required('manage_users')
def get_users():
users = User.query.all()
return jsonify({
"users": [{
"id": u.id,
"phone": u.phone,
"role": u.role.name if u.role else "未分配"
} for u in users]
})
@admin_bp.route('/users/assign', methods=['POST'])
@permission_required('manage_users')
def assign_role():
data = request.json
user = User.query.get(data['user_id'])
role = Role.query.get(data['role_id'])
if user and role:
user.role = role
db.session.commit()
return jsonify({"message": "角色分配成功"})
return jsonify({"error": "用户或角色不存在"}), 404
# --- 字典管理 ---
@admin_bp.route('/dicts', methods=['GET'])
@permission_required('manage_dicts')
def get_dicts():
dict_type = request.args.get('type')
query = SystemDict.query
if dict_type:
query = query.filter_by(dict_type=dict_type)
dicts = query.order_by(SystemDict.dict_type, SystemDict.sort_order.desc()).all()
return jsonify({
"dicts": [{
"id": d.id,
"dict_type": d.dict_type,
"label": d.label,
"value": d.value,
"cost": d.cost,
"is_active": d.is_active,
"sort_order": d.sort_order
} for d in dicts]
})
@admin_bp.route('/dicts', methods=['POST'])
@permission_required('manage_dicts')
def save_dict():
data = request.json
dict_id = data.get('id')
if dict_id:
d = SystemDict.query.get(dict_id)
if not d: return jsonify({"error": "记录不存在"}), 404
else:
d = SystemDict()
db.session.add(d)
d.dict_type = data['dict_type']
d.label = data['label']
d.value = data['value']
d.cost = data.get('cost', 0)
d.is_active = data.get('is_active', True)
d.sort_order = data.get('sort_order', 0)
db.session.commit()
return jsonify({"message": "保存成功"})
@admin_bp.route('/dicts/delete', methods=['POST'])
@permission_required('manage_dicts')
def delete_dict():
data = request.json
d = SystemDict.query.get(data.get('id'))
if d:
db.session.delete(d)
db.session.commit()
return jsonify({"message": "删除成功"})
return jsonify({"error": "记录不存在"}), 404

260
blueprints/api.py Normal file
View File

@ -0,0 +1,260 @@
import os
import uuid
import json
import requests
import io
import threading
import time
from flask import Blueprint, request, jsonify, session, current_app
from urllib.parse import quote
from config import Config
from extensions import s3_client, redis_client, db
from models import GenerationRecord, User, SystemDict
from middlewares.auth import login_required
from services.logger import system_logger
api_bp = Blueprint('api', __name__)
def sync_images_background(app, record_id, raw_urls):
"""后台同步图片至 MinIO带重试机制"""
with app.app_context():
minio_urls = []
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:
filename = f"gen-{uuid.uuid4().hex}.png"
s3_client.upload_fileobj(
io.BytesIO(img_resp.content),
Config.MINIO["bucket"],
filename,
ExtraArgs={"ContentType": "image/png"}
)
minio_urls.append(f"{Config.MINIO['public_url']}{quote(filename)}")
success = True
break
except Exception as e:
print(f"⚠️ 第 {attempt+1} 次同步失败: {e}")
time.sleep(2 ** attempt) # 指数退避
if not success:
# 如果最终失败,保留原始 URL 以便至少有内容可看
minio_urls.append(raw_url)
# 更新数据库记录为持久化 URL
try:
record = GenerationRecord.query.get(record_id)
if record:
record.image_urls = json.dumps(minio_urls)
db.session.commit()
print(f"✅ 记录 {record_id} 图片已完成持久化同步")
except Exception as e:
print(f"❌ 更新记录失败: {e}")
@api_bp.route('/api/config')
def get_config():
"""从本地数据库字典获取配置"""
try:
dicts = SystemDict.query.filter_by(is_active=True).order_by(SystemDict.sort_order.desc()).all()
config = {
"models": [],
"ratios": [],
"prompts": [],
"sizes": []
}
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)
return jsonify(config)
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/upload', methods=['POST'])
@login_required
def upload():
try:
files = request.files.getlist('images')
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 jsonify({"urls": img_urls})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/generate', methods=['POST'])
@login_required
def generate():
try:
user_id = session.get('user_id')
user = User.query.get(user_id)
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
api_key = None
use_trial = False
if mode == 'key':
# 自定义 Key 模式:优先使用本次输入的,否则使用数据库存的
api_key = input_key or user.api_key
if not api_key:
return jsonify({"error": "请先输入您的 API 密钥"}), 400
else:
# 积分/试用模式
if user.points > 0:
# 核心修复:优质模式使用专属 Key
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
# 如果是 Key 模式且输入了新 Key则自动更新到数据库保存
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_dict = SystemDict.query.filter_by(dict_type='ai_model', value=model_value).first()
cost = model_dict.cost if model_dict else 1
# 核心修复:优质模式积分消耗 X2
if use_trial and is_premium:
cost *= 2
# --- 积分预扣除逻辑 (点击即扣) ---
if use_trial:
if user.points < cost:
return jsonify({"error": f"可用积分不足,优质模式需要 {cost} 积分,您当前剩余 {user.points} 积分"}), 400
user.points -= cost
db.session.commit()
system_logger.info(f"积分预扣除 ({'优质' if is_premium else '普通'}试用)", phone=user.phone, cost=cost, remaining_points=user.points)
try:
prompt = data.get('prompt')
model = model_value
ratio = data.get('ratio')
size = data.get('size')
input_img_urls = data.get('image_urls', [])
payload = {
"prompt": prompt,
"model": model,
"response_format": "url",
"aspect_ratio": ratio,
"image": input_img_urls
}
if model == "nano-banana-2" and size:
payload["image_size"] = size
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
resp = requests.post(target_api, json=payload, headers=headers, timeout=300)
if resp.status_code != 200:
# API 报错,退还积分
if use_trial:
user.points += cost
db.session.commit()
system_logger.warning(f"API 报错,积分已退还", phone=user.phone, status_code=resp.status_code)
return jsonify({"error": resp.text}), resp.status_code
api_result = resp.json()
raw_urls = [item['url'] for item in api_result.get('data', [])]
# 立即写入数据库(先存原始 URL
new_record = GenerationRecord(
user_id=session.get('user_id'),
prompt=prompt,
model=model,
image_urls=json.dumps(raw_urls)
)
db.session.add(new_record)
db.session.commit()
# 写入系统日志
system_logger.info(
f"用户生成图片成功",
phone=user.phone,
model=model,
record_id=new_record.id
)
# 启动后台线程同步图片,不阻塞前端返回
app = current_app._get_current_object()
threading.Thread(
target=sync_images_background,
args=(app, new_record.id, raw_urls)
).start()
# 立即返回原始 URL 给前端展示
return jsonify({
"data": [{"url": url} for url in raw_urls],
"message": "生成成功!作品正在后台同步至云存储。"
})
except Exception as e:
# 发生系统异常,退还积分
if use_trial:
user.points += cost
db.session.commit()
system_logger.error(f"生成异常,积分已退还", phone=user.phone, error=str(e))
return jsonify({"error": str(e)}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/history', methods=['GET'])
@login_required
def get_history():
"""获取用户的历史生成记录 (支持分页,限 90 天内)"""
try:
from datetime import datetime, timedelta
user_id = session.get('user_id')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
# 计算 90 天前的时间
ninety_days_ago = datetime.utcnow() - timedelta(days=90)
pagination = GenerationRecord.query.filter(
GenerationRecord.user_id == user_id,
GenerationRecord.created_at >= ninety_days_ago
).order_by(GenerationRecord.created_at.desc())\
.paginate(page=page, per_page=per_page, error_out=False)
return jsonify({
"history": [{
"id": r.id,
"prompt": r.prompt,
"model": r.model,
"urls": json.loads(r.image_urls),
"time": r.created_at.strftime('%Y-%m-%d %H:%M')
} for r in pagination.items],
"has_next": pagination.has_next,
"total": pagination.total
})
except Exception as e:
return jsonify({"error": str(e)}), 500

199
blueprints/auth.py Normal file
View File

@ -0,0 +1,199 @@
from flask import Blueprint, request, jsonify, session, render_template, redirect, url_for
from extensions import db
from models import User
from services.sms_service import SMSService
from services.logger import system_logger
from middlewares.auth import admin_required
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login')
def login_page():
"""登录页面"""
if 'user_id' in session:
return redirect(url_for('index'))
return render_template('login.html')
@auth_bp.route('/logs')
@admin_required
def logs_page():
"""日志查看页面"""
return render_template('logs.html')
@auth_bp.route('/rbac')
@admin_required
def rbac_page():
"""角色权限管理页面"""
return render_template('rbac.html')
@auth_bp.route('/dicts')
@admin_required
def dicts_page():
"""系统字典管理页面"""
return render_template('dicts.html')
@auth_bp.route('/api/auth/send_code', methods=['POST'])
def send_code():
data = request.json
phone = data.get('phone')
if not phone:
return jsonify({"error": "请输入手机号"}), 400
system_logger.info(f"用户请求发送验证码", phone=phone)
success, msg = SMSService.send_code(phone)
if success:
system_logger.info(f"验证码发送成功", phone=phone)
return jsonify({"message": "验证码已发送"})
system_logger.warning(f"验证码发送失败: {msg}", phone=phone)
return jsonify({"error": f"发送失败: {msg}"}), 500
@auth_bp.route('/api/auth/register', methods=['POST'])
def register():
data = request.json
phone = data.get('phone')
code = data.get('code')
password = data.get('password')
system_logger.info(f"用户注册请求", phone=phone)
if not SMSService.verify_code(phone, code):
system_logger.warning(f"注册失败: 验证码错误", phone=phone)
return jsonify({"error": "验证码错误或已过期"}), 400
if User.query.filter_by(phone=phone).first():
system_logger.warning(f"注册失败: 手机号已存在", phone=phone)
return jsonify({"error": "该手机号已注册"}), 400
# 获取默认角色:普通用户
from models import Role
user_role = Role.query.filter_by(name='普通用户').first()
user = User(phone=phone, role=user_role)
user.set_password(password)
db.session.add(user)
db.session.commit()
system_logger.info(f"用户注册成功", phone=phone, user_id=user.id)
return jsonify({"message": "注册成功"})
@auth_bp.route('/api/auth/login', methods=['POST'])
def login():
data = request.json
phone = data.get('phone')
password = data.get('password')
system_logger.info(f"用户登录尝试", phone=phone)
user = User.query.filter_by(phone=phone).first()
if user and user.check_password(password):
session.permanent = True # 开启持久化会话 (受 Config.PERMANENT_SESSION_LIFETIME 控制)
session['user_id'] = user.id
system_logger.info(f"用户登录成功", phone=phone, user_id=user.id)
return jsonify({"message": "登录成功", "phone": phone})
system_logger.warning(f"登录失败: 手机号或密码错误", phone=phone)
return jsonify({"error": "手机号或密码错误"}), 401
@auth_bp.route('/api/auth/logout', methods=['POST'])
def logout():
session.pop('user_id', None)
return jsonify({"message": "已退出登录"})
@auth_bp.route('/api/auth/me', methods=['GET'])
def me():
user_id = session.get('user_id')
if not user_id:
return jsonify({"logged_in": False})
user = User.query.get(user_id)
return jsonify({
"logged_in": True,
"phone": user.phone,
"api_key": user.api_key, # 返回已保存的 API Key
"points": user.points # 返回剩余试用积分
})
@auth_bp.route('/api/auth/change_password', methods=['POST'])
def change_password():
user_id = session.get('user_id')
if not user_id:
return jsonify({"error": "请先登录"}), 401
data = request.json
old_password = data.get('old_password')
new_password = data.get('new_password')
if not old_password or not new_password:
return jsonify({"error": "请填写完整信息"}), 400
user = User.query.get(user_id)
if not user.check_password(old_password):
return jsonify({"error": "原密码错误"}), 400
user.set_password(new_password)
db.session.commit()
system_logger.info(f"用户修改密码成功", phone=user.phone, user_id=user.id)
return jsonify({"message": "密码修改成功"})
@auth_bp.route('/api/auth/menu', methods=['GET'])
def get_menu():
"""获取动态导航菜单"""
user_id = session.get('user_id')
if not user_id:
return jsonify({"menu": []})
user = User.query.get(user_id)
if not user:
return jsonify({"menu": []})
# 菜单定义库:名称, 图标, 链接, 所需权限
all_menus = [
{"name": "创作工作台", "icon": "wand-2", "url": "/", "perm": None},
{"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"},
{"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"},
{"name": "系统日志审计", "icon": "terminal", "url": "/logs", "perm": "view_logs"},
]
# 根据权限过滤
accessible_menu = []
for item in all_menus:
if item["perm"] is None or user.has_permission(item["perm"]):
accessible_menu.append(item)
return jsonify({"menu": accessible_menu})
@auth_bp.route('/api/auth/logs', methods=['GET'])
def get_logs():
"""获取系统日志(支持搜索和筛选)"""
user_id = session.get('user_id')
if not user_id:
return jsonify({"error": "请先登录"}), 401
from extensions import redis_client
import json
# 筛选参数
level_filter = request.args.get('level')
search_query = request.args.get('search', '').lower()
# 从 Redis 获取所有日志 (目前上限 2000 条)
logs = redis_client.lrange('system_logs', 0, -1)
log_list = []
for log in logs:
item = json.loads(log.decode('utf-8'))
# 级别过滤
if level_filter and item['level'] != level_filter:
continue
# 关键词搜索 (搜索内容、手机号或其它 Extra 字段)
if search_query:
message_match = search_query in item['message'].lower()
extra_match = any(search_query in str(v).lower() for v in item.get('extra', {}).values())
if not (message_match or extra_match):
continue
log_list.append(item)
return jsonify({"logs": log_list})

48
config.py Normal file
View File

@ -0,0 +1,48 @@
import os
class Config:
# 基础配置
SECRET_KEY = os.getenv("SECRET_KEY", "vision-ai-secret-key")
# PostgreSQL 配置
SQLALCHEMY_DATABASE_URI = "postgresql://user_xREpkJ:password_DZz8DQ@331002.xyz:2022/ai_vision"
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Redis 配置
REDIS_URL = "redis://:redis_WWjNyb@331002.xyz:2020/0"
# Session 配置
PERMANENT_SESSION_LIFETIME = 604800 # 7 天 (单位:秒)
SESSION_COOKIE_SECURE = True # 开发环境设为 False生产环境建议设为 True
SESSION_COOKIE_HTTPONLY = True # 防止 XSS 获取 Cookie
# MinIO 配置
MINIO = {
"endpoint": "http://331002.xyz:9000",
"access_key": "l0VlsxrkASbXN2YSQrJk",
"secret_key": "ZK8nXHieorl3fpbssUMGGfr8zZmbpXB5gAbma3z1",
"bucket": "images",
"public_url": "http://331002.xyz:9000/images/"
}
# AI API 配置
AI_API = "https://ai.t8star.cn/v1/images/generations"
# 试用模式配置
TRIAL_API = "https://ai.comfly.chat/v1/images/generations"
TRIAL_KEY = "sk-Rr8L5noW8Qga7K4jmey3yYZYL1a4SlhlNlo5iZrwqJRK1Pa1"
PREMIUM_KEY = "sk-168trRxnemem6nTpQn1rbmJ4SFKLwTMsZ0G6uk5OipP7FKAY"
DICT_URL = "https://nas.4x4g.com:10011/api/common/sys/dict"
PLATFORM = "lingmao"
# 阿里云短信配置
ALIBABA_CLOUD_ACCESS_KEY_ID = "LTAI5tAbHKxmPKVPYsABEdyq"
ALIBABA_CLOUD_ACCESS_KEY_SECRET = "v6URREddBqvGfwZrWH1DWoxs3w6RxZ"
SMS_SIGN_NAME = "速通互联验证码"
SMS_TEMPLATE_CODE = "100001"
SMS_NEED_PARAM = False # 该模板需要参数,如使用系统赠送模板请改为 False
# 开发模式配置
DEV_MODE = False # True=开发模式固定验证码False=生产模式(真实短信)
DEV_SMS_CODE = "888888" # 开发模式下的固定验证码

64
create_database.py Normal file
View File

@ -0,0 +1,64 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
数据库创建脚本
用于在 PostgreSQL 服务器上创建 ai_vision 数据库
"""
import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
# 数据库连接信息
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}...")
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
database="postgres" # 先连接到默认数据库
)
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor()
# 检查数据库是否存在
cursor.execute(f"SELECT 1 FROM pg_database WHERE datname = '{DB_NAME}'")
exists = cursor.fetchone()
if exists:
print(f"✅ 数据库 {DB_NAME} 已经存在")
else:
# 创建数据库
print(f"🔧 正在创建数据库 {DB_NAME}...")
cursor.execute(f'CREATE DATABASE {DB_NAME}')
print(f"✅ 数据库 {DB_NAME} 创建成功!")
cursor.close()
conn.close()
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 psycopg2.Error as e:
print(f"❌ 数据库操作失败: {e}")
print(f"\n可能的原因:")
print(f" 1. 用户 {DB_USER} 没有创建数据库的权限")
print(f" 2. 网络连接问题")
print(f" 3. 数据库服务器配置限制")
except Exception as e:
print(f"❌ 发生错误: {e}")
if __name__ == '__main__':
create_database()

16
extensions.py Normal file
View File

@ -0,0 +1,16 @@
from flask_sqlalchemy import SQLAlchemy
from flask_redis import FlaskRedis
import boto3
from config import Config
db = SQLAlchemy()
redis_client = FlaskRedis()
# MinIO Client
s3_client = boto3.client(
"s3",
endpoint_url=Config.MINIO["endpoint"],
aws_access_key_id=Config.MINIO["access_key"],
aws_secret_access_key=Config.MINIO["secret_key"],
region_name="us-east-1"
)

25
fix_db_manual.py Normal file
View File

@ -0,0 +1,25 @@
import psycopg2
from config import Config
def migrate():
# 从 URI 解析连接参数
# postgresql://user:pass@host:port/dbname
uri = Config.SQLALCHEMY_DATABASE_URI
print(f"正在手动连接数据库进行迁移...")
try:
conn = psycopg2.connect(uri)
cur = conn.cursor()
# 添加 api_key 字段
cur.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS api_key VARCHAR(255);")
conn.commit()
cur.close()
conn.close()
print("✅ 数据库字段 users.api_key 手动添加成功")
except Exception as e:
print(f"❌ 迁移失败: {e}")
if __name__ == "__main__":
migrate()

33
fix_db_manual_points.py Normal file
View File

@ -0,0 +1,33 @@
import psycopg2
from config import Config
def fix_db():
# 从 SQLALCHEMY_DATABASE_URI 提取连接信息
# 格式: postgresql://user:pass@host:port/db
uri = Config.SQLALCHEMY_DATABASE_URI
print(f"🔗 正在尝试连接数据库...")
try:
conn = psycopg2.connect(uri)
cur = conn.cursor()
# 检查并添加 points 字段
cur.execute("""
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()
cur.close()
conn.close()
print("✅ 数据库字段 points 处理完成 (默认值 2)")
except Exception as e:
print(f"❌ 数据库修复失败: {e}")
if __name__ == "__main__":
fix_db()

81
force_init.py Normal file
View File

@ -0,0 +1,81 @@
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 Normal file
View File

@ -0,0 +1,53 @@
#!/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()

54
init_dicts.py Normal file
View File

@ -0,0 +1,54 @@
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()

54
init_rbac.py Normal file
View File

@ -0,0 +1,54 @@
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_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)
# 普通用户角色
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()

57
logs/system.log Normal file
View File

@ -0,0 +1,57 @@
[2026-01-11 17:43:34] INFO - 用户请求发送验证码
[2026-01-11 17:43:36] WARNING - 验证码发送失败: Error: InvalidAccessKeyId.NotFound code: 404, Specified access key is not found. request id: 912B43E3-5393-53A4-92B7-669BA1DF61A3 Response: {'RequestId': '912B43E3-5393-53A4-92B7-669BA1DF61A3', 'Message': 'Specified access key is not found.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=InvalidAccessKeyId.NotFound&product=Dysmsapi&requestId=912B43E3-5393-53A4-92B7-669BA1DF61A3', 'HostId': 'dysmsapi.aliyuncs.com', 'Code': 'InvalidAccessKeyId.NotFound', 'statusCode': 404}
[2026-01-11 17:50:36] INFO - 用户请求发送验证码
[2026-01-11 17:50:37] WARNING - 验证码发送失败: Error: InvalidAccessKeyId.NotFound code: 404, Specified access key is not found. request id: 762395A5-97D5-569F-9A50-8D2AD36009C1 Response: {'RequestId': '762395A5-97D5-569F-9A50-8D2AD36009C1', 'Message': 'Specified access key is not found.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=InvalidAccessKeyId.NotFound&product=Dypnsapi&requestId=762395A5-97D5-569F-9A50-8D2AD36009C1', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'InvalidAccessKeyId.NotFound', 'statusCode': 404}
[2026-01-11 17:52:03] INFO - 用户注册请求
[2026-01-11 17:52:03] WARNING - 注册失败: 验证码错误
[2026-01-11 17:58:53] INFO - 用户请求发送验证码
[2026-01-11 17:58:53] INFO - 验证码发送成功
[2026-01-11 17:59:51] INFO - 用户请求发送验证码
[2026-01-11 17:59:51] WARNING - 验证码发送失败: 请2秒后再试
[2026-01-11 17:59:56] INFO - 用户请求发送验证码
[2026-01-11 17:59:57] WARNING - 验证码发送失败: Error: MissingTemplateParam code: 400, TemplateParam is mandatory for this action. request id: E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4 Response: {'RequestId': 'E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4', 'Message': 'TemplateParam is mandatory for this action.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=MissingTemplateParam&product=Dypnsapi&requestId=E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'MissingTemplateParam', 'statusCode': 400}
[2026-01-11 18:02:00] INFO - 用户请求发送验证码
[2026-01-11 18:02:01] WARNING - 验证码发送失败: Error: MissingTemplateParam code: 400, TemplateParam is mandatory for this action. request id: BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8 Response: {'RequestId': 'BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8', 'Message': 'TemplateParam is mandatory for this action.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=MissingTemplateParam&product=Dypnsapi&requestId=BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'MissingTemplateParam', 'statusCode': 400}
[2026-01-11 18:04:11] INFO - 用户请求发送验证码
[2026-01-11 18:04:12] WARNING - 验证码发送失败: 请检查模板内容与模板参数是否匹配
[2026-01-11 18:05:41] INFO - 用户请求发送验证码
[2026-01-11 18:05:43] WARNING - 验证码发送失败: 非法参数
[2026-01-11 18:06:41] INFO - 用户请求发送验证码
[2026-01-11 18:06:42] WARNING - 验证码发送失败: check frequency failed
[2026-01-11 18:07:53] INFO - 用户请求发送验证码
[2026-01-11 18:07:53] INFO - 验证码发送成功
[2026-01-11 18:12:40] INFO - 用户请求发送验证码
[2026-01-11 18:12:42] WARNING - 验证码发送失败: 非法参数
[2026-01-11 18:13:42] INFO - 用户请求发送验证码
[2026-01-11 18:13:44] INFO - 验证码发送成功
[2026-01-11 18:14:18] INFO - 用户注册请求
[2026-01-11 18:14:19] WARNING - 注册失败: 验证码错误
[2026-01-11 18:16:05] INFO - 用户注册请求
[2026-01-11 18:16:07] WARNING - 注册失败: 验证码错误
[2026-01-11 18:17:34] INFO - 用户注册请求
[2026-01-11 18:17:35] WARNING - 注册失败: 验证码错误
[2026-01-11 18:20:57] INFO - 用户请求发送验证码
[2026-01-11 18:20:59] INFO - 验证码发送成功
[2026-01-11 18:21:11] INFO - 用户注册请求
[2026-01-11 18:21:12] INFO - 用户注册成功
[2026-01-11 18:21:14] INFO - 用户登录尝试
[2026-01-11 18:21:14] INFO - 用户登录成功
[2026-01-11 18:33:40] INFO - 用户登录尝试
[2026-01-11 18:33:40] WARNING - 登录失败: 手机号或密码错误
[2026-01-11 18:33:47] INFO - 用户登录尝试
[2026-01-11 18:33:47] INFO - 用户登录成功
[2026-01-11 18:34:19] INFO - 用户登录尝试
[2026-01-11 18:34:19] INFO - 用户登录成功
[2026-01-11 19:05:37] INFO - 用户登录尝试
[2026-01-11 19:05:37] INFO - 用户登录成功
[2026-01-11 19:14:10] INFO - 用户登录尝试
[2026-01-11 19:14:10] INFO - 用户登录成功
[2026-01-11 21:51:06] INFO - 用户登录尝试
[2026-01-11 21:51:06] INFO - 用户登录成功
[2026-01-11 21:59:14] INFO - 试用模式生成
[2026-01-11 21:59:14] INFO - 用户发起图片生成
[2026-01-11 22:09:52] INFO - 积分预扣除 (试用模式)
[2026-01-11 22:10:10] INFO - 用户生成图片成功
[2026-01-11 23:41:00] INFO - 用户登录尝试
[2026-01-11 23:41:23] INFO - 用户登录尝试
[2026-01-11 23:41:23] INFO - 用户登录成功

Binary file not shown.

41
middlewares/auth.py Normal file
View File

@ -0,0 +1,41 @@
from functools import wraps
from flask import session, jsonify, redirect, url_for, request
from models import User
def login_required(f):
"""登录验证装饰器"""
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = session.get('user_id')
if not user_id:
if request.path.startswith('/api/'):
return jsonify({"error": "请先登录", "code": 401}), 401
# 记录当前路径以便登录后跳转
return redirect(url_for('auth.login_page', next=request.path))
return f(*args, **kwargs)
return decorated_function
def permission_required(perm_name):
"""动态权限验证装饰器"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
user_id = session.get('user_id')
if not user_id:
if request.path.startswith('/api/'):
return jsonify({"error": "请先登录", "code": 401}), 401
return redirect(url_for('auth.login_page', next=request.path))
user = User.query.get(user_id)
if not user or not user.has_permission(perm_name):
if request.path.startswith('/api/'):
return jsonify({"error": f"需要权限: {perm_name}", "code": 403}), 403
# 如果没有权限,重定向到首页并提示
return redirect(url_for('index', error="权限不足"))
return f(*args, **kwargs)
return decorated_function
return decorator
def admin_required(f):
"""兼容旧版的管理员验证(内部调用 manage_system 权限)"""
return permission_required('manage_system')(f)

13
migrate_api_key.py Normal file
View File

@ -0,0 +1,13 @@
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}")

23
migrate_db.py Normal file
View File

@ -0,0 +1,23 @@
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()

29
migrate_rbac.py Normal file
View File

@ -0,0 +1,29 @@
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()

84
models.py Normal file
View File

@ -0,0 +1,84 @@
from extensions import db
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
# 角色与权限的多对多关联表
role_permissions = db.Table('role_permissions',
db.Column('role_id', db.Integer, db.ForeignKey('roles.id'), primary_key=True),
db.Column('permission_id', db.Integer, db.ForeignKey('permissions.id'), primary_key=True)
)
class Permission(db.Model):
__tablename__ = 'permissions'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False) # 如: 'view_logs', 'manage_users'
description = db.Column(db.String(100))
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False) # 如: '超级管理员', '普通用户'
description = db.Column(db.String(100))
# 角色拥有的权限
permissions = db.relationship('Permission', secondary=role_permissions, backref=db.backref('roles', lazy='dynamic'))
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
phone = db.Column(db.String(20), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
api_key = db.Column(db.String(255)) # 存储用户的 API Key
points = db.Column(db.Integer, default=2) # 账户积分默认赠送2次试用
# 关联角色 ID
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# 关系映射
role = db.relationship('Role', backref=db.backref('users', lazy='dynamic'))
def has_permission(self, perm_name):
"""动态检查用户是否拥有某项权限"""
if not self.role:
return False
# 获取用户拥有的所有权限名称
perms = [p.name for p in self.role.permissions]
# 核心修复:如果是超级管理员(拥有 manage_system则豁免所有具体权限检查
if 'manage_system' in perms:
return True
return perm_name in perms
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class SystemDict(db.Model):
"""通用字典管理系统"""
__tablename__ = 'system_dicts'
id = db.Column(db.Integer, primary_key=True)
dict_type = db.Column(db.String(50), nullable=False) # 如: 'ai_model', 'aspect_ratio', 'prompt_tpl'
label = db.Column(db.String(100), nullable=False) # 显示名称
value = db.Column(db.Text, nullable=False) # 存储值或提示词内容
cost = db.Column(db.Integer, default=0) # 消耗积分 (仅针对 ai_model 有效)
is_active = db.Column(db.Boolean, default=True)
sort_order = db.Column(db.Integer, default=0) # 排序权重
class GenerationRecord(db.Model):
"""AI 生成记录"""
__tablename__ = 'generation_records'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
prompt = db.Column(db.Text)
model = db.Column(db.String(100))
# 存储生成的图片 URL 列表 (JSON 字符串)
image_urls = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
user = db.relationship('User', backref=db.backref('records', lazy='dynamic', order_by='GenerationRecord.created_at.desc()'))

BIN
requirements.txt Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

73
services/logger.py Normal file
View File

@ -0,0 +1,73 @@
import logging
import os
from datetime import datetime
from logging.handlers import RotatingFileHandler
from extensions import redis_client
import json
# 创建日志目录
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs')
os.makedirs(LOG_DIR, exist_ok=True)
class SystemLogger:
def __init__(self):
self.logger = logging.getLogger('vision_ai')
self.logger.setLevel(logging.INFO)
# 文件处理器 (自动轮转最大10MB保留5个备份)
file_handler = RotatingFileHandler(
os.path.join(LOG_DIR, 'system.log'),
maxBytes=10*1024*1024,
backupCount=5,
encoding='utf-8'
)
file_handler.setLevel(logging.INFO)
# 控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
# 格式化
formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
self.logger.addHandler(file_handler)
self.logger.addHandler(console_handler)
def _push_to_redis(self, level, message, extra=None):
"""同时推送到 Redis 用于前端实时查看"""
try:
log_entry = {
"time": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
"level": level,
"message": message,
"extra": extra or {}
}
# 保留最近 2000 条日志
redis_client.lpush('system_logs', json.dumps(log_entry, ensure_ascii=False))
redis_client.ltrim('system_logs', 0, 1999)
except:
pass
def info(self, message, **kwargs):
self.logger.info(message)
self._push_to_redis('INFO', message, kwargs)
def warning(self, message, **kwargs):
self.logger.warning(message)
self._push_to_redis('WARNING', message, kwargs)
def error(self, message, **kwargs):
self.logger.error(message)
self._push_to_redis('ERROR', message, kwargs)
def debug(self, message, **kwargs):
self.logger.debug(message)
self._push_to_redis('DEBUG', message, kwargs)
# 全局日志实例
system_logger = SystemLogger()

131
services/sms_service.py Normal file
View File

@ -0,0 +1,131 @@
import random
import json
from extensions import redis_client
from config import Config
# 阿里云号码认证服务 SDK
try:
from alibabacloud_dypnsapi20170525.client import Client as Dypnsapi20170525Client
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_dypnsapi20170525 import models as dypnsapi_20170525_models
from alibabacloud_tea_util import models as util_models
ALIYUN_SDK_AVAILABLE = True
except ImportError:
ALIYUN_SDK_AVAILABLE = False
print("⚠️ 阿里云号码认证服务 SDK 未安装,请运行: pip install alibabacloud_dypnsapi20170525")
class SMSService:
@staticmethod
def _create_client():
"""创建号码认证服务客户端"""
config = open_api_models.Config(
access_key_id=Config.ALIBABA_CLOUD_ACCESS_KEY_ID,
access_key_secret=Config.ALIBABA_CLOUD_ACCESS_KEY_SECRET
)
config.endpoint = 'dypnsapi.aliyuncs.com'
return Dypnsapi20170525Client(config)
@staticmethod
def send_code(phone):
# 检查是否在冷却期内
cooldown_key = f"sms_cooldown:{phone}"
if redis_client.exists(cooldown_key):
ttl = redis_client.ttl(cooldown_key)
return False, f"{ttl}秒后再试"
# 开发模式:使用固定验证码
if Config.DEV_MODE:
code = Config.DEV_SMS_CODE
redis_client.set(f"sms_code:{phone}", code, ex=1800)
redis_client.set(cooldown_key, "1", ex=60)
print(f"🟡 [开发模式] 验证码: {code} (手机: {phone})")
return True, "验证码已发送(开发模式)"
# 生产模式:真实发送
# 调用阿里云号码认证服务(自动生成验证码)
success, biz_id = SMSService._call_aliyun(phone, None)
if success:
# 发送成功后存储 BizId 用于校验
redis_client.set(f"sms_biz_id:{phone}", biz_id, ex=1800)
redis_client.set(cooldown_key, "1", ex=60)
print(f"✅ 短信已发送至 {phone}, BizId: {biz_id}")
return True, "发送成功"
else:
print(f"❌ 短信发送失败: {biz_id}") # biz_id 此时是错误信息
return False, biz_id
@staticmethod
def verify_code(phone, code):
"""校验验证码 - 统一使用本地 Redis 校验"""
# 从 Redis 获取验证码
cached_code = redis_client.get(f"sms_code:{phone}")
if not cached_code:
print(f"⚠️ 未找到验证码,手机号: {phone}")
return False
# 比对验证码
stored_code = cached_code.decode('utf-8') if isinstance(cached_code, bytes) else str(cached_code)
input_code = str(code)
print(f"🔍 校验验证码: 输入={input_code}, 存储={stored_code}")
if stored_code == input_code:
# 校验成功,删除验证码(一次性使用)
redis_client.delete(f"sms_code:{phone}")
redis_client.delete(f"sms_biz_id:{phone}") # 也删除 BizId
print(f"✅ 验证码校验成功")
return True
else:
print(f"❌ 验证码不匹配")
return False
@staticmethod
def _call_aliyun(phone, code):
"""调用阿里云号码认证服务发送验证码"""
if not ALIYUN_SDK_AVAILABLE:
return False, "SDK未安装"
try:
client = SMSService._create_client()
# 使用 SendSmsVerifyCode 接口
request = dypnsapi_20170525_models.SendSmsVerifyCodeRequest(
phone_number=phone,
sign_name=Config.SMS_SIGN_NAME,
template_code=Config.SMS_TEMPLATE_CODE,
# 模板需要 code 和 min 两个参数
template_param='{"code":"##code##","min":"30"}',
code_type=1, # 1=纯数字
code_length=6, # 6位验证码
interval=60, # 60秒重发间隔
valid_time=1800, # 30分钟有效期
return_verify_code=True # 返回验证码用于本地校验
)
runtime = util_models.RuntimeOptions()
resp = client.send_sms_verify_code_with_options(request, runtime)
if resp.body.code == "OK":
# 获取阿里云生成的验证码
verify_code = resp.body.model.verify_code if hasattr(resp.body.model, 'verify_code') else None
biz_id = resp.body.model.biz_id
if verify_code:
# 将验证码存储到 Redis 用于本地校验
redis_client.set(f"sms_code:{phone}", verify_code, ex=1800)
print(f"✅ 短信已发送至 {phone}, 验证码: {verify_code}, BizId: {biz_id}")
else:
# 如果没有返回验证码,只存储 BizId
redis_client.set(f"sms_biz_id:{phone}", biz_id, ex=1800)
print(f"✅ 短信已发送至 {phone}, BizId: {biz_id}")
return True, biz_id
else:
return False, resp.body.message
except Exception as e:
import traceback
error_detail = traceback.format_exc()
print(f"❌ 详细错误信息:\n{error_detail}")
return False, str(e)

130
static/css/style.css Normal file
View File

@ -0,0 +1,130 @@
:root {
--primary: #6366f1;
--primary-soft: rgba(99, 102, 241, 0.08);
--surface: #ffffff;
--background: #fcfdfe;
}
body {
font-family: 'Plus Jakarta Sans', 'Noto Sans SC', sans-serif;
background-color: var(--background);
color: #1e293b;
letter-spacing: -0.01em;
}
/* 布局装饰 */
.bg-mesh {
position: fixed;
inset: 0;
z-index: -1;
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.03) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(139, 92, 246, 0.03) 0px, transparent 50%);
}
/* 侧边栏玻璃拟态 */
.glass-sidebar {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px) saturate(180%);
}
.auth-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.auth-card {
background: white;
width: 100%;
max-width: 400px;
padding: 2.5rem;
border-radius: 2.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 10px; }
.btn-primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
transition: all 0.3s ease;
}
.logo-box {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.image-frame {
background: #ffffff;
padding: 12px;
border-radius: 3rem;
box-shadow: 0 20px 50px -10px rgba(0,0,0,0.1);
}
/* 现代化通知系统 */
.toast-container {
position: fixed;
top: 2rem;
right: 2rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 1rem;
pointer-events: none;
}
.toast {
background: white;
padding: 1rem 1.5rem;
border-radius: 1rem;
box-shadow: 0 10px 40px -10px rgba(0,0,0,0.2);
border-left: 4px solid #6366f1;
min-width: 300px;
max-width: 400px;
pointer-events: auto;
animation: slideInRight 0.3s ease-out;
display: flex;
align-items: center;
gap: 0.75rem;
}
.toast.success { border-left-color: #10b981; }
.toast.error { border-left-color: #ef4444; }
.toast.warning { border-left-color: #f59e0b; }
.toast.info { border-left-color: #3b82f6; }
@keyframes slideInRight {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}

74
static/js/auth.js Normal file
View File

@ -0,0 +1,74 @@
let isRegisterMode = false;
document.getElementById('authSwitchBtn').onclick = () => {
isRegisterMode = !isRegisterMode;
document.getElementById('authTitle').innerText = isRegisterMode ? "加入视界 AI" : "欢迎回来";
document.getElementById('authSub').innerText = isRegisterMode ? "注册并开启创作" : "请登录以开启 AI 创作之旅";
document.getElementById('authSubmitBtn').querySelector('span').innerText = isRegisterMode ? "立即注册" : "立即登录";
document.getElementById('authSwitchBtn').innerText = isRegisterMode ? "已有账号?返回登录" : "没有账号?立即注册";
document.getElementById('smsGroup').classList.toggle('hidden', !isRegisterMode);
};
document.getElementById('sendSmsBtn').onclick = async () => {
const phone = document.getElementById('authPhone').value;
const btn = document.getElementById('sendSmsBtn');
if(!phone) return showToast('请输入手机号', 'warning');
btn.disabled = true;
const originalText = btn.innerText;
const r = await fetch('/api/auth/send_code', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ phone })
});
const d = await r.json();
if(d.error) {
showToast(d.error, 'error');
btn.disabled = false;
} else {
showToast(d.message, 'success');
let countdown = 60;
const timer = setInterval(() => {
btn.innerText = `${countdown}秒后重试`;
countdown--;
if(countdown < 0) {
clearInterval(timer);
btn.innerText = originalText;
btn.disabled = false;
}
}, 1000);
}
};
document.getElementById('authSubmitBtn').onclick = async () => {
const phone = document.getElementById('authPhone').value;
const password = document.getElementById('authPass').value;
const code = document.getElementById('authCode').value;
const url = isRegisterMode ? '/api/auth/register' : '/api/auth/login';
const body = isRegisterMode ? { phone, password, code } : { phone, password };
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
const d = await r.json();
if(d.error) {
showToast(d.error, 'error');
} else {
showToast(d.message, 'success');
if(isRegisterMode) {
isRegisterMode = true;
document.getElementById('authSwitchBtn').click();
} else {
// 获取来源页面路径
const urlParams = new URLSearchParams(window.location.search);
const nextUrl = urlParams.get('next') || '/';
window.location.href = nextUrl;
}
}
};

472
static/js/main.js Normal file
View File

@ -0,0 +1,472 @@
lucide.createIcons();
async function checkAuth() {
const r = await fetch('/api/auth/me');
const d = await r.json();
const profile = document.getElementById('userProfile');
const entry = document.getElementById('loginEntryBtn');
const loginHint = document.getElementById('loginHint');
const submitBtn = document.getElementById('submitBtn');
if(d.logged_in) {
if(profile) profile.classList.remove('hidden');
if(entry) entry.classList.add('hidden');
if(loginHint) loginHint.classList.add('hidden');
submitBtn.disabled = false;
submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
const phoneDisp = document.getElementById('userPhoneDisplay');
if(phoneDisp) phoneDisp.innerText = d.phone;
// 处理积分显示
const pointsBadge = document.getElementById('pointsBadge');
const pointsDisplay = document.getElementById('pointsDisplay');
if(pointsBadge && pointsDisplay) {
pointsBadge.classList.remove('hidden');
pointsDisplay.innerText = d.points;
}
const headerPoints = document.getElementById('headerPoints');
if(headerPoints) headerPoints.innerText = d.points;
// 如果用户已经有绑定的 Key且当前没手动输入则默认切到 Key 模式
if(d.api_key) {
switchMode('key');
const keyInput = document.getElementById('apiKey');
if(keyInput && !keyInput.value) keyInput.value = d.api_key;
} else {
switchMode('trial');
}
} else {
if(profile) profile.classList.add('hidden');
if(entry) entry.classList.remove('hidden');
if(loginHint) loginHint.classList.remove('hidden');
submitBtn.disabled = true;
submitBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
lucide.createIcons();
}
// 移除 redundant logout 监听,因为 base.html 已处理全局登出
// 历史记录分页状态
let currentHistoryPage = 1;
let hasMoreHistory = true;
let isHistoryLoading = false;
// 存储当前生成的所有图片 URL
let currentGeneratedUrls = [];
let currentMode = 'trial'; // 'trial' 或 'key'
function switchMode(mode) {
currentMode = mode;
const trialBtn = document.getElementById('modeTrialBtn');
const keyBtn = document.getElementById('modeKeyBtn');
const keyInputGroup = document.getElementById('keyInputGroup');
const premiumToggle = document.getElementById('premiumToggle');
if(mode === 'trial') {
trialBtn.classList.add('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
trialBtn.classList.remove('border-slate-200', 'text-slate-400');
keyBtn.classList.remove('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
keyBtn.classList.add('border-slate-200', 'text-slate-400');
keyInputGroup.classList.add('hidden');
if(premiumToggle) premiumToggle.classList.remove('hidden');
} else {
keyBtn.classList.add('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
keyBtn.classList.remove('border-slate-200', 'text-slate-400');
trialBtn.classList.remove('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
trialBtn.classList.add('border-slate-200', 'text-slate-400');
keyInputGroup.classList.remove('hidden');
if(premiumToggle) premiumToggle.classList.add('hidden');
}
updateCostPreview(); // 切换模式时同步计费预览
}
async function downloadImage(url) {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
// 从 URL 提取文件名
const filename = url.split('/').pop().split('?')[0] || 'ai-vision-image.png';
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error('下载失败:', e);
showToast('下载失败,请尝试右键保存', 'error');
}
}
async function loadHistory(isLoadMore = false) {
if (isHistoryLoading || (!hasMoreHistory && isLoadMore)) return;
isHistoryLoading = true;
if (!isLoadMore) {
currentHistoryPage = 1;
document.getElementById('historyList').innerHTML = '';
}
const footer = document.getElementById('historyFooter');
footer.classList.remove('hidden');
try {
const r = await fetch(`/api/history?page=${currentHistoryPage}&per_page=10`);
const d = await r.json();
const list = document.getElementById('historyList');
if (d.history && d.history.length > 0) {
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="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-bold text-indigo-500">${item.model}</span>
</div>
<p class="text-[11px] text-slate-600 line-clamp-2 leading-relaxed">${item.prompt}</p>
<div class="grid grid-cols-3 gap-2">
${item.urls.map(url => `
<div class="aspect-square rounded-lg overflow-hidden border border-slate-100 cursor-pointer transition-transform hover:scale-105" onclick="window.open('${url}')">
<img src="${url}" class="w-full h-full object-cover" loading="lazy">
</div>
`).join('')}
</div>
</div>
`).join('');
if (isLoadMore) {
list.insertAdjacentHTML('beforeend', html);
} else {
list.innerHTML = html;
}
hasMoreHistory = d.has_next;
currentHistoryPage++;
} else if (!isLoadMore) {
list.innerHTML = `<div class="flex flex-col items-center justify-center h-64 text-slate-300">
<i data-lucide="inbox" class="w-12 h-12 mb-4"></i>
<span class="text-xs font-bold">暂无生成记录</span>
</div>`;
}
lucide.createIcons();
} catch (e) {
console.error('加载历史失败:', e);
if (!isLoadMore) {
document.getElementById('historyList').innerHTML = `<div class="text-center text-rose-400 text-xs font-bold py-10">加载失败: ${e.message}</div>`;
}
} finally {
isHistoryLoading = false;
footer.classList.add('hidden');
}
}
async function init() {
checkAuth();
// 模式切换监听
const modeTrialBtn = document.getElementById('modeTrialBtn');
const modeKeyBtn = document.getElementById('modeKeyBtn');
const isPremiumCheckbox = document.getElementById('isPremium');
if(modeTrialBtn) modeTrialBtn.onclick = () => switchMode('trial');
if(modeKeyBtn) modeKeyBtn.onclick = () => switchMode('key');
if(isPremiumCheckbox) isPremiumCheckbox.onchange = () => updateCostPreview();
// 历史记录控制
const historyDrawer = document.getElementById('historyDrawer');
const showHistoryBtn = document.getElementById('showHistoryBtn');
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
const historyList = document.getElementById('historyList');
if(showHistoryBtn) {
showHistoryBtn.onclick = () => {
historyDrawer.classList.remove('translate-x-full');
loadHistory(false);
};
}
if(closeHistoryBtn) {
closeHistoryBtn.onclick = () => {
historyDrawer.classList.add('translate-x-full');
};
}
// 瀑布流滚动加载
if (historyList) {
historyList.onscroll = () => {
const threshold = 100;
if (historyList.scrollTop + historyList.clientHeight >= historyList.scrollHeight - threshold) {
loadHistory(true);
}
};
}
// 全部下载按钮逻辑
const downloadAllBtn = document.getElementById('downloadAllBtn');
if(downloadAllBtn) {
downloadAllBtn.onclick = async () => {
if(currentGeneratedUrls.length === 0) return;
showToast(`正在准备下载 ${currentGeneratedUrls.length} 张作品...`, 'info');
for(const url of currentGeneratedUrls) {
await downloadImage(url);
// 稍微延迟一下,防止浏览器拦截
await new Promise(r => setTimeout(r, 300));
}
};
}
// 重新生成按钮逻辑
const regenBtn = document.getElementById('regenBtn');
if(regenBtn) {
regenBtn.onclick = () => {
const submitBtn = document.getElementById('submitBtn');
if(submitBtn) submitBtn.click();
};
}
// 检查是否有来自 URL 的错误提示
const urlParams = new URLSearchParams(window.location.search);
if(urlParams.has('error')) {
showToast(urlParams.get('error'), 'error');
// 清理 URL 参数以防刷新时重复提示
window.history.replaceState({}, document.title, window.location.pathname);
}
try {
const r = await fetch('/api/config');
const d = await r.json();
fillSelect('modelSelect', d.models);
fillSelect('ratioSelect', d.ratios);
fillSelect('sizeSelect', d.sizes);
fillSelect('promptTpl', [{label:'✨ 自定义创作', value:'manual'}, ...d.prompts]);
updateCostPreview(); // 初始化时显示默认模型的积分
} catch(e) { console.error(e); }
}
function fillSelect(id, list) {
const el = document.getElementById(id);
if(!el) return;
// 如果是模型选择,增加积分显示
if(id === 'modelSelect') {
el.innerHTML = list.map(i => `<option value="${i.value}" data-cost="${i.cost || 0}">${i.label} (${i.cost || 0}积分)</option>`).join('');
} else {
el.innerHTML = list.map(i => `<option value="${i.value}">${i.label}</option>`).join('');
}
}
// 更新计费预览显示
function updateCostPreview() {
const modelSelect = document.getElementById('modelSelect');
const costPreview = document.getElementById('costPreview');
const isPremium = document.getElementById('isPremium')?.checked || false;
const selectedOption = modelSelect.options[modelSelect.selectedIndex];
if (currentMode === 'trial' && selectedOption) {
let cost = parseInt(selectedOption.getAttribute('data-cost') || 0);
if(isPremium) cost *= 2; // 优质模式 2 倍积分
costPreview.innerText = `本次生成将消耗 ${cost} 积分`;
costPreview.classList.remove('hidden');
} else {
costPreview.classList.add('hidden');
}
}
document.getElementById('fileInput').onchange = (e) => {
const prev = document.getElementById('imagePreview');
prev.innerHTML = '';
Array.from(e.target.files).forEach(file => {
const reader = new FileReader();
reader.onload = (ev) => {
const d = document.createElement('div');
d.className = 'w-20 h-20 rounded-2xl overflow-hidden flex-shrink-0 border-2 border-white shadow-md';
d.innerHTML = `<img src="${ev.target.result}" class="w-full h-full object-cover">`;
prev.appendChild(d);
};
reader.readAsDataURL(file);
});
};
document.getElementById('modelSelect').onchange = (e) => {
document.getElementById('sizeGroup').classList.toggle('hidden', e.target.value !== 'nano-banana-2');
updateCostPreview(); // 切换模型时更新计费预览
};
document.getElementById('promptTpl').onchange = (e) => {
const area = document.getElementById('manualPrompt');
if(e.target.value !== 'manual') {
area.value = e.target.value;
area.readOnly = true;
} else {
area.value = '';
area.readOnly = false;
}
};
document.getElementById('submitBtn').onclick = async () => {
const btn = document.getElementById('submitBtn');
const files = document.getElementById('fileInput').files;
const apiKey = document.getElementById('apiKey').value;
const num = parseInt(document.getElementById('numSelect').value);
// 检查登录状态并获取积分
const authCheck = await fetch('/api/auth/me');
const authData = await authCheck.json();
if(!authData.logged_in) {
showToast('请先登录后再生成作品', 'warning');
return;
}
// 根据模式验证
if(currentMode === 'key') {
if(!apiKey) return showToast('请输入您的 API 密钥', 'warning');
} else {
if(authData.points <= 0) return showToast('可用积分已耗尽,请充值或切换至自定义 Key 模式', 'warning');
}
if(files.length === 0) return showToast('请上传照片', 'warning');
// UI 锁定
btn.disabled = true;
const btnText = btn.querySelector('span');
btnText.innerText = "正在同步参考图...";
document.getElementById('statusInfo').classList.remove('hidden');
document.getElementById('placeholder').classList.add('hidden');
document.getElementById('finalWrapper').classList.remove('hidden');
const grid = document.getElementById('imageGrid');
grid.innerHTML = ''; // 清空
currentGeneratedUrls = []; // 重置当前生成列表
try {
// 1. 先上传参考图获取持久化 URL
const uploadData = new FormData();
for(let f of files) uploadData.append('images', f);
const upR = await fetch('/api/upload', { method: 'POST', body: uploadData });
const upRes = await upR.json();
if(upRes.error) throw new Error(upRes.error);
const image_urls = upRes.urls;
// 2. 并行启动多个生成任务
btnText.innerText = `AI 构思中 (0/${num})...`;
let finishedCount = 0;
const startTask = async (index) => {
const slot = document.createElement('div');
slot.className = 'image-frame relative bg-white/50 animate-pulse min-h-[300px] flex items-center justify-center rounded-[2.5rem] border border-slate-100 shadow-sm';
slot.innerHTML = `<div class="text-slate-400 text-xs font-bold italic">正在创作第 ${index + 1} 张...</div>`;
grid.appendChild(slot);
try {
const r = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: currentMode,
is_premium: document.getElementById('isPremium')?.checked || false,
apiKey: currentMode === 'key' ? apiKey : '',
prompt: document.getElementById('manualPrompt').value,
model: document.getElementById('modelSelect').value,
ratio: document.getElementById('ratioSelect').value,
size: document.getElementById('sizeSelect').value,
image_urls
})
});
const res = await r.json();
if(res.error) throw new Error(res.error);
if(res.message) showToast(res.message, 'success');
const imgUrl = res.data[0].url;
currentGeneratedUrls.push(imgUrl);
slot.className = 'image-frame group relative animate-in zoom-in-95 duration-700';
slot.innerHTML = `
<img src="${imgUrl}" class="w-full h-auto rounded-[2.5rem] object-contain shadow-xl">
<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/20 rounded-[2.5rem]">
<button onclick="downloadImage('${imgUrl}')" class="bg-white/90 p-4 rounded-full text-indigo-600 shadow-xl hover:scale-110 transition-transform">
<i data-lucide="download-cloud" class="w-6 h-6"></i>
</button>
</div>
`;
lucide.createIcons();
} catch (e) {
slot.className = 'image-frame relative bg-red-50/50 flex items-center justify-center rounded-[2.5rem] border border-red-100 p-6';
if(e.message.includes('401') || e.message.includes('请先登录')) {
slot.innerHTML = `<div class="text-red-400 text-[10px] font-bold text-center">登录已过期,请重新登录</div>`;
} else {
slot.innerHTML = `<div class="text-red-400 text-[10px] font-bold text-center">渲染异常: ${e.message}</div>`;
}
} finally {
finishedCount++;
btnText.innerText = `AI 构思中 (${finishedCount}/${num})...`;
// 每次生成任务结束后,刷新一次积分显示
if(currentMode === 'trial') checkAuth();
}
};
const tasks = Array.from({ length: num }, (_, i) => startTask(i));
await Promise.all(tasks);
} catch (e) {
showToast('创作引擎中断: ' + e.message, 'error');
document.getElementById('placeholder').classList.remove('hidden');
} finally {
btn.disabled = false;
btnText.innerText = "立即生成作品";
document.getElementById('statusInfo').classList.add('hidden');
}
};
init();
// 修改密码弹窗控制
function openPwdModal() {
const modal = document.getElementById('pwdModal');
if(!modal) return;
modal.classList.remove('hidden');
setTimeout(() => {
modal.classList.add('opacity-100');
modal.querySelector('div').classList.remove('scale-95');
}, 10);
}
function closePwdModal() {
const modal = document.getElementById('pwdModal');
if(!modal) return;
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => {
modal.classList.add('hidden');
document.getElementById('pwdForm').reset();
}, 300);
}
document.addEventListener('click', (e) => {
if(e.target.closest('#openPwdModalBtn')) {
openPwdModal();
}
});
document.getElementById('pwdForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const old_password = document.getElementById('oldPwd').value;
const new_password = document.getElementById('newPwd').value;
try {
const r = await fetch('/api/auth/change_password', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({old_password, new_password})
});
const d = await r.json();
if(r.ok) {
showToast('密码修改成功,请记牢新密码', 'success');
closePwdModal();
} else {
showToast(d.error || '修改失败', 'error');
}
} catch(err) {
showToast('网络连接失败', 'error');
}
});

10
sync_history_db.py Normal file
View File

@ -0,0 +1,10 @@
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}")

98
templates/base.html Normal file
View File

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AI 视界{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block head %}{% endblock %}
</head>
<body class="text-slate-700 antialiased bg-slate-50 overflow-hidden">
<div class="bg-mesh"></div>
<div id="toastContainer" class="toast-container"></div>
<div class="flex h-screen w-screen overflow-hidden">
<!-- 全局系统菜单栏 (默认隐藏,仅在 initGlobalNav 成功后显示) -->
<nav id="globalNav" class="hidden w-20 flex-shrink-0 bg-slate-900 flex flex-col items-center py-8 z-40 shadow-2xl transition-all duration-500">
<div class="w-12 h-12 btn-primary logo-box rounded-2xl flex items-center justify-center mb-12 rotate-3">
<i data-lucide="scan-eye" class="w-7 h-7"></i>
</div>
<div id="dynamicMenuList" class="flex-1 w-full px-2 space-y-4"></div>
<div id="globalUserProfile" class="flex flex-col items-center gap-4 mb-4">
<div class="w-10 h-10 bg-slate-800 rounded-xl flex items-center justify-center text-slate-400 border border-slate-700">
<i data-lucide="user" class="w-5 h-5"></i>
</div>
<button id="globalLogoutBtn" class="text-slate-500 hover:text-rose-400 transition-colors">
<i data-lucide="log-out" class="w-5 h-5"></i>
</button>
</div>
</nav>
<!-- 主内容区域 (全屏) -->
<main class="flex-1 flex overflow-hidden relative w-full h-full">
{% block content %}{% endblock %}
</main>
</div>
<script>
lucide.createIcons();
// 自动加载菜单
async function initGlobalNav() {
try {
const r = await fetch('/api/auth/menu');
const d = await r.json();
if(d.menu && d.menu.length > 0) {
document.getElementById('globalNav').classList.remove('hidden');
const list = document.getElementById('dynamicMenuList');
list.innerHTML = d.menu.map(item => {
const isActive = window.location.pathname === item.url;
return `
<div class="relative group flex justify-center">
<a href="${item.url}"
class="w-12 h-12 flex items-center justify-center rounded-2xl transition-all duration-300 ${
isActive
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/40'
: 'text-slate-500 hover:bg-slate-800 hover:text-white'
}">
<i data-lucide="${item.icon}" class="w-5 h-5"></i>
</a>
<div class="absolute left-full ml-4 px-3 py-2 bg-slate-800 text-white text-[10px] font-black rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 shadow-xl">
${item.name}
<div class="absolute right-full top-1/2 -translate-y-1/2 border-8 border-transparent border-r-slate-800"></div>
</div>
</div>
`;
}).join('');
lucide.createIcons();
}
} catch(e) { console.error('菜单加载失败', e); }
}
document.getElementById('globalLogoutBtn').onclick = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
};
initGlobalNav();
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const colors = { success: 'text-emerald-500', error: 'text-rose-500', warning: 'text-amber-500', info: 'text-indigo-500' };
const icons = { success: 'check-circle', error: 'x-circle', warning: 'alert-triangle', info: 'info' };
toast.innerHTML = `<i data-lucide="${icons[type]}" class="w-5 h-5 ${colors[type]} flex-shrink-0"></i><span class="text-sm font-medium text-slate-700">${message}</span>`;
container.appendChild(toast);
lucide.createIcons();
setTimeout(() => { toast.style.animation = 'slideOutRight 0.3s ease-in'; setTimeout(() => toast.remove(), 300); }, 3000);
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

237
templates/dicts.html Normal file
View File

@ -0,0 +1,237 @@
{% extends "base.html" %}
{% block title %}系统字典管理 - AI 视界{% endblock %}
{% block content %}
<div class="min-h-screen p-8 lg:p-12">
<div class="max-w-6xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<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">
<i data-lucide="book-open" class="w-7 h-7"></i>
</div>
<div>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">系统字典管理</h1>
<p class="text-slate-400 text-sm">统一维护模型、比例、提示词等系统参数</p>
</div>
</div>
<button onclick="openModal()" class="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>
新增字典项
</button>
</div>
<!-- 筛选栏 -->
<div class="flex gap-4 bg-white p-4 rounded-3xl shadow-sm border border-slate-100">
<button onclick="loadDicts('')" class="dict-filter-btn px-4 py-2 rounded-xl text-xs font-bold transition-all bg-slate-100 text-slate-600 active-filter">全部</button>
<button onclick="loadDicts('ai_model')" class="dict-filter-btn px-4 py-2 rounded-xl text-xs font-bold transition-all hover:bg-slate-50 text-slate-400">AI 模型</button>
<button onclick="loadDicts('aspect_ratio')" class="dict-filter-btn px-4 py-2 rounded-xl text-xs font-bold transition-all hover:bg-slate-50 text-slate-400">画面比例</button>
<button onclick="loadDicts('ai_image_size')" class="dict-filter-btn px-4 py-2 rounded-xl text-xs font-bold transition-all hover:bg-slate-50 text-slate-400">输出尺寸</button>
<button onclick="loadDicts('prompt_tpl')" class="dict-filter-btn px-4 py-2 rounded-xl text-xs font-bold transition-all hover:bg-slate-50 text-slate-400">提示词模板</button>
</div>
<!-- 表格 -->
<div class="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-50 border-b border-slate-100">
<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>
<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>
</thead>
<tbody id="dictTableBody" class="text-sm font-medium">
<!-- 动态加载 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div id="dictModal" 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">
<div class="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl p-10 space-y-8 transform scale-95 transition-transform duration-300">
<h2 id="modalTitle" class="text-2xl font-black text-slate-900">新增字典项</h2>
<form id="dictForm" class="space-y-5">
<input type="hidden" id="dictId">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">字典类型</label>
<select id="dictType" class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
<option value="ai_model">AI 模型 (ai_model)</option>
<option value="aspect_ratio">画面比例 (aspect_ratio)</option>
<option value="ai_image_size">输出尺寸 (ai_image_size)</option>
<option value="prompt_tpl">提示词模板 (prompt_tpl)</option>
</select>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">显示名称 (Label)</label>
<input type="text" id="dictLabel" required class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">存储值/内容 (Value)</label>
<textarea id="dictValue" required rows="3" class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold resize-none"></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">积分消耗</label>
<input type="number" id="dictCost" value="0" class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">排序权重</label>
<input type="number" id="dictOrder" value="0" class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
</div>
<div class="flex items-center gap-2 pt-2">
<input type="checkbox" id="dictActive" checked class="w-4 h-4 rounded border-slate-200 text-indigo-600 focus:ring-indigo-500">
<label for="dictActive" class="text-sm font-bold text-slate-600">立即启用</label>
</div>
<div class="flex gap-4 pt-4">
<button type="button" onclick="closeModal()" class="flex-1 px-8 py-4 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button type="submit" class="flex-1 btn-primary px-8 py-4 rounded-2xl font-bold shadow-lg shadow-indigo-100">保存配置</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentType = '';
async function loadDicts(type = '') {
currentType = type;
// 更新按钮样式
document.querySelectorAll('.dict-filter-btn').forEach(btn => {
const btnType = btn.getAttribute('onclick').match(/'(.*)'/)[1];
if(btnType === type) {
btn.classList.add('bg-slate-100', 'text-slate-600');
btn.classList.remove('hover:bg-slate-50', 'text-slate-400');
} else {
btn.classList.remove('bg-slate-100', 'text-slate-600');
btn.classList.add('hover:bg-slate-50', 'text-slate-400');
}
});
try {
const r = await fetch(`/api/admin/dicts?type=${type}`);
const d = await r.json();
const body = document.getElementById('dictTableBody');
body.innerHTML = d.dicts.map(item => `
<tr class="border-b border-slate-50 hover:bg-slate-50/50 transition-colors">
<td class="px-8 py-5">
<span class="px-2 py-0.5 rounded text-[10px] font-black uppercase ${
item.dict_type === 'ai_model' ? 'bg-indigo-50 text-indigo-600' :
item.dict_type === 'aspect_ratio' ? 'bg-emerald-50 text-emerald-600' :
item.dict_type === 'ai_image_size' ? 'bg-rose-50 text-rose-600' : 'bg-amber-50 text-amber-600'
}">${item.dict_type}</span>
</td>
<td class="px-8 py-5 text-slate-700 font-bold">${item.label}</td>
<td class="px-8 py-5 text-slate-400 text-xs truncate max-w-xs" title="${item.value}">${item.value}</td>
<td class="px-8 py-5">
<span class="text-amber-600 font-black">${item.cost}</span>
</td>
<td class="px-8 py-5">
<span class="${item.is_active ? 'text-emerald-500' : 'text-slate-300'}">
<i data-lucide="${item.is_active ? 'check-circle-2' : 'circle'}" class="w-4 h-4"></i>
</span>
</td>
<td class="px-8 py-5">
<div class="flex gap-3">
<button onclick='editDict(${JSON.stringify(item)})' class="text-indigo-400 hover:text-indigo-600 transition-colors">
<i data-lucide="edit-3" class="w-4 h-4"></i>
</button>
<button onclick="deleteDict(${item.id})" class="text-rose-300 hover:text-rose-500 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</td>
</tr>
`).join('');
lucide.createIcons();
} catch (e) {
console.error(e);
}
}
function openModal(data = null) {
const modal = document.getElementById('dictModal');
const form = document.getElementById('dictForm');
document.getElementById('modalTitle').innerText = data ? '编辑字典项' : '新增字典项';
document.getElementById('dictId').value = data ? data.id : '';
document.getElementById('dictType').value = data ? data.dict_type : 'ai_model';
document.getElementById('dictLabel').value = data ? data.label : '';
document.getElementById('dictValue').value = data ? data.value : '';
document.getElementById('dictCost').value = data ? data.cost : 0;
document.getElementById('dictOrder').value = data ? data.sort_order : 0;
document.getElementById('dictActive').checked = data ? data.is_active : true;
modal.classList.remove('hidden');
setTimeout(() => {
modal.classList.remove('opacity-0');
modal.querySelector('div').classList.remove('scale-95');
}, 10);
}
function closeModal() {
const modal = document.getElementById('dictModal');
modal.classList.add('opacity-0');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => modal.classList.add('hidden'), 300);
}
function editDict(item) {
openModal(item);
}
async function deleteDict(id) {
if(!confirm('确定要删除此字典项吗?')) return;
try {
const r = await fetch('/api/admin/dicts/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
const d = await r.json();
if(d.message) {
showToast(d.message, 'success');
loadDicts(currentType);
}
} catch (e) { console.error(e); }
}
document.getElementById('dictForm').onsubmit = async (e) => {
e.preventDefault();
const payload = {
id: document.getElementById('dictId').value,
dict_type: document.getElementById('dictType').value,
label: document.getElementById('dictLabel').value,
value: document.getElementById('dictValue').value,
cost: parseInt(document.getElementById('dictCost').value),
sort_order: parseInt(document.getElementById('dictOrder').value),
is_active: document.getElementById('dictActive').checked
};
try {
const r = await fetch('/api/admin/dicts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const d = await r.json();
if(d.message) {
showToast(d.message, 'success');
closeModal();
loadDicts(currentType);
}
} catch (e) { console.error(e); }
};
loadDicts();
</script>
{% endblock %}

226
templates/index.html Normal file
View File

@ -0,0 +1,226 @@
{% extends "base.html" %}
{% block title %}工作台 - AI 视界{% endblock %}
{% block content %}
<!-- 中间AI 功能设定区 -->
<aside class="w-80 lg:w-[340px] flex-shrink-0 glass-sidebar flex flex-col z-30 border-r border-slate-200/60 shadow-xl bg-white/50 backdrop-blur-xl">
<div class="p-6 pb-2">
<h2 class="text-xl font-black text-slate-900 tracking-tight">创作工作台</h2>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">智能试戴引擎配置</p>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6 custom-scrollbar">
<section class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">01</span>
<h3 class="text-sm font-bold text-slate-800">授权验证</h3>
</div>
<!-- 积分显示 -->
<div id="pointsBadge" class="hidden px-2 py-0.5 bg-amber-50 text-amber-600 border border-amber-100 rounded-lg text-[10px] font-black uppercase">
可用积分: <span id="pointsDisplay">0</span>
</div>
</div>
<div class="flex gap-2">
<button id="modeTrialBtn" class="flex-1 py-2 rounded-xl text-[10px] font-bold border-2 border-indigo-500 bg-indigo-50 text-indigo-600 transition-all">积分/试用模式</button>
<button id="modeKeyBtn" class="flex-1 py-2 rounded-xl text-[10px] font-bold border border-slate-200 text-slate-400 hover:bg-slate-50 transition-all">自定义 Key</button>
</div>
<div id="premiumToggle" class="flex items-center justify-between bg-amber-50/50 border border-amber-100/50 p-3 rounded-2xl animate-in fade-in duration-500">
<div class="flex items-center gap-2">
<div class="w-7 h-7 bg-amber-100 text-amber-600 rounded-lg flex items-center justify-center">
<i data-lucide="sparkles" class="w-4 h-4"></i>
</div>
<div>
<div class="text-[10px] font-black text-amber-700 uppercase tracking-tight">优质渲染模式</div>
<div class="text-[8px] text-amber-500 font-bold">使用专属通道 · 积分消耗 X2</div>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="isPremium" class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[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-amber-500"></div>
</label>
</div>
<div id="keyInputGroup" class="hidden relative group animate-in slide-in-from-top-2 duration-300">
<input id="apiKey" type="password" placeholder="输入您的 OpenAI API 密钥" class="w-full bg-white border border-slate-200 rounded-2xl p-3.5 pl-11 text-xs outline-none focus:border-indigo-500 transition-all shadow-sm">
<i data-lucide="key-round" class="w-4 h-4 absolute left-4 top-1/2 -translate-y-1/2 text-indigo-400"></i>
</div>
</section>
<section class="space-y-4">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">02</span>
<h3 class="text-sm font-bold text-slate-800">渲染设定</h3>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i data-lucide="cpu" class="w-3 h-3"></i>计算模型</label>
<select id="modelSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i data-lucide="layout" class="w-3 h-3"></i>画面比例</label>
<select id="ratioSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
</div>
<div id="sizeGroup" class="hidden space-y-1.5">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i data-lucide="maximize" class="w-3 h-3"></i>输出尺寸</label>
<select id="sizeSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i data-lucide="copy" class="w-3 h-3"></i>生成数量</label>
<select id="numSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all">
<option value="1">1 张</option><option value="2">2 张</option><option value="3">3 张</option><option value="4">4 张</option>
</select>
</div>
</div>
<div class="rounded-2xl border border-slate-100 overflow-hidden bg-white shadow-sm">
<select id="promptTpl" class="w-full bg-slate-50 border-b border-slate-100 p-3 text-[10px] font-bold text-indigo-600 outline-none cursor-pointer">
<option value="manual">✨ 自定义创作</option>
</select>
<textarea id="manualPrompt" rows="2" class="w-full p-3 text-xs outline-none resize-none leading-relaxed" placeholder="描述您的需求..."></textarea>
</div>
</section>
<section class="space-y-4 pb-2">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">03</span>
<h3 class="text-sm font-bold text-slate-800">参考底图</h3>
</div>
<div class="relative group">
<div class="border-2 border-dashed border-slate-100 rounded-3xl p-6 text-center bg-white/30 hover:border-indigo-200 transition-all cursor-pointer">
<i data-lucide="image-plus" class="w-6 h-6 mx-auto mb-2 text-slate-300"></i>
<p class="text-[10px] text-slate-400 font-bold">点击选择肖像照片</p>
</div>
<input id="fileInput" type="file" multiple class="absolute inset-0 opacity-0 cursor-pointer">
</div>
<div id="imagePreview" class="flex gap-3 overflow-x-auto py-1"></div>
</section>
</div>
<div class="p-6 bg-white/95 border-t border-slate-100">
<button id="submitBtn" class="w-full btn-primary py-4 rounded-2xl shadow-lg hover:scale-[1.02] active:scale-[0.98] gap-2 flex items-center justify-center">
<i data-lucide="wand-2" class="w-5 h-5"></i>
<span class="text-base font-bold tracking-widest">立即生成作品</span>
</button>
<p id="costPreview" class="text-center text-[10px] text-amber-600 font-bold mt-2 hidden"></p>
<p id="loginHint" class="text-center text-[10px] text-slate-400 mt-2 hidden">
<i data-lucide="lock" class="w-2.5 h-2.5 inline"></i> 请先登录以使用生成功能
</p>
</div>
</aside>
<!-- 右侧:主展示 -->
<main class="flex-1 relative flex flex-col bg-slate-50 overflow-hidden">
<div class="h-24 flex items-center justify-between px-12 relative z-10">
<div class="flex items-center gap-3">
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full animate-ping"></div>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest text-xs">引擎就绪</span>
</div>
<div class="flex items-center gap-6">
<!-- 历史记录触发按钮 -->
<button id="showHistoryBtn" class="flex items-center gap-2 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm cursor-pointer hover:bg-white transition-all text-xs font-bold text-slate-600">
<i data-lucide="history" class="w-4 h-4 text-indigo-500"></i>
历史记录
</button>
<div id="userProfile" class="hidden 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">
<div class="w-8 h-8 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600">
<i data-lucide="user" class="w-4 h-4"></i>
</div>
<div class="flex flex-col">
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">--</span>
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">0</span> 积分</span>
</div>
<button id="openPwdModalBtn" title="修改密码" 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>
</button>
</div>
<a id="loginEntryBtn" href="/login" class="bg-indigo-600 text-white px-6 py-2.5 rounded-2xl text-xs font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all">
立即登录
</a>
</div>
</div>
<div class="flex-1 flex items-center justify-center p-8 relative">
<div id="statusInfo" class="absolute top-8 left-1/2 -translate-x-1/2 hidden z-20">
<div class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div>
<span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在处理您的请求...</span>
</div>
</div>
<div id="resultCanvas" class="w-full h-full flex items-center justify-center">
<div id="placeholder" class="text-center max-w-lg">
<div class="w-48 h-48 bg-white rounded-[4.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-12 border border-slate-100">
<i data-lucide="glasses" class="w-20 h-20 text-indigo-500"></i>
</div>
<h2 class="text-4xl font-black text-slate-900 mb-8">视界进化 · 艺术呈现</h2>
<p class="text-slate-500 text-lg font-medium">在左侧完成设定,开启 AI 试戴体验</p>
</div>
<div id="finalWrapper" class="hidden w-full h-full flex flex-col items-center justify-center py-6">
<div id="imageGrid" class="grid grid-cols-1 md:grid-cols-2 gap-6 w-full max-w-4xl p-4"></div>
<div class="mt-14 flex items-center gap-8">
<!-- 全部下载按钮 -->
<button id="downloadAllBtn" class="bg-indigo-600 text-white px-8 py-4 rounded-[1.8rem] text-sm font-bold shadow-xl shadow-indigo-200 hover:bg-indigo-700 hover:scale-105 active:scale-95 transition-all flex items-center gap-3">
<i data-lucide="download" class="w-5 h-5"></i>
<span>全部下载</span>
</button>
<!-- 重新生成按钮 -->
<button id="regenBtn" class="w-16 h-16 bg-white border border-slate-100 rounded-[1.8rem] flex items-center justify-center text-slate-400 shadow-xl hover:text-indigo-600 transition-all hover:scale-110 active:scale-95 group">
<i data-lucide="refresh-cw" class="w-6 h-6 group-hover:rotate-180 transition-transform duration-500"></i>
</button>
</div>
</div>
</div>
</div>
<!-- 历史记录滑出抽屉 (子页面) -->
<div id="historyDrawer" class="absolute inset-y-0 right-0 w-96 bg-white/95 backdrop-blur-2xl border-l border-slate-100 shadow-2xl z-50 translate-x-full transition-transform duration-500 flex flex-col">
<div class="p-8 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 class="text-xl font-black text-slate-900">创作历史</h3>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">展示 90 天内的生成记录</p>
</div>
<button id="closeHistoryBtn" class="w-10 h-10 rounded-xl hover:bg-slate-100 flex items-center justify-center text-slate-400 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div id="historyList" class="flex-1 overflow-y-auto p-6 space-y-4 custom-scrollbar">
<!-- 历史记录项 -->
</div>
<!-- 加载更多触发器 -->
<div id="historyFooter" class="p-4 border-t border-slate-50 flex justify-center hidden">
<div class="flex items-center gap-2 text-[10px] font-bold text-slate-400 animate-pulse">
<i data-lucide="loader-2" class="w-3 h-3 animate-spin"></i>
正在加载更多...
</div>
</div>
</div>
</main>
<!-- 修改密码弹窗 -->
<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">
<div class="bg-white w-full max-w-sm rounded-[2.5rem] shadow-2xl p-10 space-y-8 transform scale-95 transition-transform duration-300">
<h2 class="text-2xl font-black text-slate-900">修改登录密码</h2>
<form id="pwdForm" class="space-y-5">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">原密码</label>
<input type="password" id="oldPwd" required class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">新密码</label>
<input type="password" id="newPwd" required class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
<div class="flex gap-4 pt-4">
<button type="button" onclick="closePwdModal()" class="flex-1 px-8 py-4 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button type="submit" class="flex-1 btn-primary px-8 py-4 rounded-2xl font-bold shadow-lg shadow-indigo-100">确认修改</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% endblock %}

55
templates/login.html Normal file
View File

@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}登录 - AI 视界{% endblock %}
{% block content %}
<div class="w-full h-full flex items-center justify-center p-6 bg-slate-50/50 backdrop-blur-sm">
<div class="auth-card w-full max-w-md space-y-8 bg-white p-10 rounded-[2.5rem] shadow-2xl border border-slate-100 relative z-10">
<div class="text-center space-y-4">
<div class="w-16 h-16 btn-primary mx-auto rounded-2xl flex items-center justify-center shadow-lg rotate-3">
<i data-lucide="scan-eye" class="w-10 h-10"></i>
</div>
<div>
<h2 id="authTitle" class="text-3xl font-black text-slate-900 tracking-tight">欢迎回来</h2>
<p id="authSub" class="text-slate-400 text-sm mt-2">请登录以开启 AI 创作之旅</p>
</div>
</div>
<div class="space-y-4">
<div class="relative group">
<input id="authPhone" type="text" placeholder="手机号" class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all">
<i data-lucide="phone" class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
</div>
<div id="smsGroup" class="hidden relative group">
<input id="authCode" type="text" placeholder="验证码" class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all">
<i data-lucide="shield-check" class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
<button id="sendSmsBtn" class="absolute right-4 top-1/2 -translate-y-1/2 text-indigo-600 text-xs font-bold hover:text-indigo-700">发送验证码</button>
</div>
<div class="relative group">
<input id="authPass" type="password" placeholder="密码" class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all">
<i data-lucide="lock" class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
</div>
</div>
<button id="authSubmitBtn" class="w-full btn-primary py-5 rounded-2xl shadow-xl shadow-indigo-100 hover:scale-[1.02] active:scale-[0.98] transition-all">
<span class="text-lg font-bold">立即登录</span>
</button>
<div class="text-center pt-2">
<button id="authSwitchBtn" class="text-sm text-slate-400 hover:text-indigo-600 font-bold transition-colors">没有账号?立即注册</button>
</div>
<div class="text-center">
<a href="/" class="text-xs text-slate-300 hover:text-slate-500 flex items-center justify-center gap-1">
<i data-lucide="arrow-left" class="w-3 h-3"></i> 返回首页
</a>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
{% endblock %}

114
templates/logs.html Normal file
View File

@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}系统日志 - AI 视界{% endblock %}
{% block content %}
<div class="min-h-screen p-8 lg:p-12">
<div class="max-w-6xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-slate-900 text-white rounded-2xl flex items-center justify-center shadow-lg">
<i data-lucide="terminal" class="w-7 h-7"></i>
</div>
<div>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">系统审计日志</h1>
<p class="text-slate-400 text-sm">实时监控系统运行状态与安全事件</p>
</div>
</div>
<div class="flex items-center gap-4">
<!-- 筛选工具栏 -->
<div class="flex items-center bg-white border border-slate-200 rounded-xl px-4 py-2 shadow-sm">
<i data-lucide="search" class="w-4 h-4 text-slate-400 mr-2"></i>
<input type="text" id="logSearch" placeholder="搜索消息或手机号..."
class="outline-none text-sm w-48 text-slate-600" oninput="loadLogs()">
</div>
<select id="logLevel" class="bg-white border border-slate-200 rounded-xl px-4 py-2 text-sm text-slate-600 outline-none shadow-sm" onchange="loadLogs()">
<option value="">全部级别</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<button onclick="loadLogs()" class="bg-white border border-slate-200 p-3 rounded-xl hover:bg-slate-50 transition-all shadow-sm">
<i data-lucide="refresh-cw" class="w-5 h-5 text-slate-600"></i>
</button>
<a href="/" class="btn-primary px-6 py-3 rounded-xl text-sm font-bold shadow-lg">返回工作台</a>
</div>
</div>
<div class="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-50 border-b border-slate-100">
<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>
</thead>
<tbody id="logTableBody" class="text-sm font-medium">
<!-- 动态加载 -->
<tr>
<td colspan="4" class="px-8 py-20 text-center text-slate-400 italic">正在连接日志服务器...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function loadLogs() {
const search = document.getElementById('logSearch')?.value || '';
const level = document.getElementById('logLevel')?.value || '';
const url = `/api/auth/logs?search=${encodeURIComponent(search)}&level=${level}`;
try {
const r = await fetch(url);
const d = await r.json();
const body = document.getElementById('logTableBody');
if (d.error) {
body.innerHTML = `<tr><td colspan="4" class="px-8 py-20 text-center text-rose-500 font-bold">${d.error}</td></tr>`;
return;
}
if (d.logs.length === 0) {
body.innerHTML = `<tr><td colspan="4" class="px-8 py-20 text-center text-slate-400 italic">没有找到符合条件的日志</td></tr>`;
return;
}
body.innerHTML = d.logs.map(log => `
<tr class="border-b border-slate-50 hover:bg-slate-50/50 transition-colors">
<td class="px-8 py-5 text-slate-400 font-mono text-xs">${log.time}</td>
<td class="px-8 py-5">
<span class="px-2.5 py-1 rounded-lg text-[10px] font-black uppercase ${
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}</span>
</td>
<td class="px-8 py-5 text-slate-700 font-bold">${log.message}</td>
<td class="px-8 py-5 text-slate-400 font-mono text-[10px]">
${Object.entries(log.extra).map(([k, v]) => `<span class="inline-block bg-slate-100 rounded px-1.5 py-0.5 mr-1 mb-1">${k}: ${v}</span>`).join('')}
</td>
</tr>
`).join('');
} catch (e) {
console.error(e);
}
}
// 初始化加载
loadLogs();
// 自动刷新逻辑如果搜索框为空则每5秒刷新一次
setInterval(() => {
const search = document.getElementById('logSearch')?.value || '';
if (!search) loadLogs();
}, 5000);
</script>
{% endblock %}

260
templates/rbac.html Normal file
View File

@ -0,0 +1,260 @@
{% extends "base.html" %}
{% block title %}权限管理 - AI 视界{% endblock %}
{% block content %}
<div class="min-h-screen p-8 lg:p-12">
<div class="max-w-6xl mx-auto space-y-12">
<!-- 头部 -->
<div class="flex items-center justify-between">
<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">
<i data-lucide="shield-check" class="w-7 h-7"></i>
</div>
<div>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">动态 RBAC 权限中心</h1>
<p class="text-slate-400 text-sm">动态配置系统角色与权限资产</p>
</div>
</div>
<a href="/" class="btn-primary px-6 py-3 rounded-xl text-sm font-bold shadow-lg">返回工作台</a>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- 左侧:编辑/创建角色 -->
<div class="lg:col-span-1 space-y-6">
<div class="bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100">
<h3 id="formTitle" class="text-lg font-black text-slate-900 mb-6 flex items-center gap-2">
<i data-lucide="plus-circle" class="w-5 h-5 text-indigo-500"></i>创建新角色
</h3>
<div class="space-y-4">
<input type="hidden" id="editRoleId">
<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">
<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>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">分配权限</label>
<div id="permissionCheckboxes" class="grid grid-cols-1 gap-2 bg-slate-50 p-4 rounded-2xl">
<!-- 动态加载 -->
</div>
</div>
<div class="flex gap-3">
<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>
<button id="saveRoleBtn" class="flex-[2] btn-primary py-4 rounded-2xl shadow-lg font-bold">保存角色</button>
</div>
</div>
</div>
</div>
<!-- 右侧:现有角色 & 用户管理 -->
<div class="lg:col-span-2 space-y-8">
<!-- 角色列表 -->
<div class="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden">
<div class="p-8 border-b border-slate-50">
<h3 class="text-lg font-black text-slate-900 flex items-center gap-2">
<i data-lucide="users" class="w-5 h-5 text-indigo-500"></i>角色资产列表
</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<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 class="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden">
<div class="p-8 border-b border-slate-50">
<h3 class="text-lg font-black text-slate-900 flex items-center gap-2">
<i data-lucide="user-cog" class="w-5 h-5 text-indigo-500"></i>用户角色分配
</h3>
</div>
<table class="w-full text-left">
<thead>
<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>
</tr>
</thead>
<tbody id="userTableBody">
<!-- 动态加载 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let allRoles = [];
async function loadPermissions() {
const r = await fetch('/api/admin/permissions');
const d = await r.json();
const container = document.getElementById('permissionCheckboxes');
container.innerHTML = d.permissions.map(p => `
<label class="flex items-center gap-3 cursor-pointer group">
<input type="checkbox" value="${p.name}" class="w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span class="text-xs font-bold text-slate-600 group-hover:text-slate-900">${p.description}</span>
</label>
`).join('');
}
async function loadRoles() {
const r = await fetch('/api/admin/roles');
const d = await r.json();
allRoles = d.roles;
const container = document.getElementById('roleTableBody');
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">
<div class="font-black text-slate-900">${role.name}</div>
<div class="text-[10px] text-slate-400 font-bold uppercase tracking-tighter">ID: ${role.id}</div>
</td>
<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]">
${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('')}
</div>
</td>
<td class="px-8 py-5">
<div class="flex gap-2">
<button onclick='editRole(${JSON.stringify(role)})' class="p-2 text-indigo-400 hover:bg-indigo-50 rounded-xl transition-all">
<i data-lucide="edit-2" class="w-4 h-4"></i>
</button>
${role.name !== '超级管理员' ? `
<button onclick="deleteRole(${role.id})" class="p-2 text-rose-300 hover:bg-rose-50 rounded-xl transition-all">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
` : ''}
</div>
</td>
</tr>
`).join('');
lucide.createIcons();
}
function editRole(role) {
document.getElementById('formTitle').innerHTML = `<i data-lucide="edit-3" class="w-5 h-5 text-indigo-500"></i>编辑角色`;
document.getElementById('editRoleId').value = role.id;
document.getElementById('newRoleName').value = role.name;
document.getElementById('newRoleDesc').value = role.description || '';
document.getElementById('cancelEditBtn').classList.remove('hidden');
// 重置并勾选权限
document.querySelectorAll('#permissionCheckboxes input').forEach(cb => {
cb.checked = role.permissions.includes(cb.value);
});
lucide.createIcons();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
document.getElementById('cancelEditBtn').onclick = resetForm;
function resetForm() {
document.getElementById('formTitle').innerHTML = `<i data-lucide="plus-circle" class="w-5 h-5 text-indigo-500"></i>创建新角色`;
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 {
showToast(d.error, 'error');
}
}
async function loadUsers() {
const r = await fetch('/api/admin/users');
const d = await r.json();
const body = document.getElementById('userTableBody');
body.innerHTML = d.users.map(user => `
<tr class="border-b border-slate-50">
<td class="px-8 py-4 text-sm font-bold text-slate-700">${user.phone}</td>
<td class="px-8 py-4">
<span class="px-3 py-1 bg-indigo-50 text-indigo-600 rounded-full text-xs font-black">${user.role}</span>
</td>
<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">
<option value="">更改角色...</option>
${allRoles.map(role => `<option value="${role.id}">${role.name}</option>`).join('')}
</select>
</td>
</tr>
`).join('');
}
async function assignRole(userId, roleId) {
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 name = document.getElementById('newRoleName').value;
const description = document.getElementById('newRoleDesc').value;
const permissions = Array.from(document.querySelectorAll('#permissionCheckboxes input:checked')).map(i => i.value);
if(!name) return showToast('请输入角色名称', 'warning');
const r = await fetch('/api/admin/roles', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ id, name, description, permissions })
});
const d = await r.json();
if(d.message) {
showToast(d.message, 'success');
loadRoles();
loadUsers();
resetForm();
} else {
showToast(d.error, 'error');
}
};
async function init() {
await loadPermissions();
await loadRoles();
await loadUsers();
}
init();
</script>
{% endblock %}

View File

@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */

View File

@ -0,0 +1 @@
__version__ = "0.4.3"

View File

@ -0,0 +1,381 @@
import asyncio
import logging
import os
import ssl
import time
from enum import Enum
from typing import Any, Dict, Optional
from urllib.parse import urlencode, urlparse
import aiohttp
import certifi
from requests import Session, PreparedRequest, adapters, status_codes
from Tea.exceptions import RequiredArgumentException, RetryError
from Tea.model import TeaModel
from Tea.request import TeaRequest
from Tea.response import TeaResponse
from Tea.stream import BaseStream
DEFAULT_CONNECT_TIMEOUT = 5000
DEFAULT_READ_TIMEOUT = 10000
DEFAULT_POOL_SIZE = 10
logger = logging.getLogger('alibabacloud-tea')
logger.setLevel(logging.DEBUG)
ch = logging.StreamHandler()
logger.addHandler(ch)
class TLSVersion(Enum):
TLSv1 = 'TLSv1'
TLSv1_1 = 'TLSv1.1'
TLSv1_2 = 'TLSv1.2'
TLSv1_3 = 'TLSv1.3'
class _TLSAdapter(adapters.HTTPAdapter):
"""A HTTPAdapter that uses an arbitrary TLS version."""
def __init__(self, ssl_context=None, **kwargs):
self.ssl_context = ssl_context
super().__init__(**kwargs)
def init_poolmanager(self, *args, **kwargs):
"""Override the init_poolmanager method to set the SSL."""
kwargs['ssl_context'] = self.ssl_context
super().init_poolmanager(*args, **kwargs)
class TeaCore:
_sessions = {}
http_adapter = adapters.HTTPAdapter(pool_connections=DEFAULT_POOL_SIZE, pool_maxsize=DEFAULT_POOL_SIZE * 4)
https_adapter = adapters.HTTPAdapter(pool_connections=DEFAULT_POOL_SIZE, pool_maxsize=DEFAULT_POOL_SIZE * 4)
@staticmethod
def _set_tls_minimum_version(sls_context, tls_min_version):
context = sls_context
if tls_min_version is not None:
if tls_min_version == 'TLSv1':
context.minimum_version = ssl.TLSVersion.TLSv1
elif tls_min_version == 'TLSv1.1':
context.minimum_version = ssl.TLSVersion.TLSv1_1
elif tls_min_version == 'TLSv1.2':
context.minimum_version = ssl.TLSVersion.TLSv1_2
elif tls_min_version == 'TLSv1.3':
context.minimum_version = ssl.TLSVersion.TLSv1_3
return context
@staticmethod
def get_adapter(prefix, tls_min_version: str = None):
ca_cert = certifi.where()
context = ssl.create_default_context()
if ca_cert and prefix.upper() == 'HTTPS':
context = TeaCore._set_tls_minimum_version(context, tls_min_version)
context.load_verify_locations(ca_cert)
adapter = _TLSAdapter(ssl_context=context, pool_connections=DEFAULT_POOL_SIZE,
pool_maxsize=DEFAULT_POOL_SIZE * 4)
return adapter
@staticmethod
def _get_session(session_key: str, protocol: str, tls_min_version: str = None, verify: bool = True):
if session_key not in TeaCore._sessions:
session = Session()
adapter = TeaCore.get_adapter(protocol, tls_min_version)
if protocol.upper() == 'HTTPS':
if verify:
session.mount('https://', adapter)
else:
session.mount('https://', TeaCore.https_adapter)
else:
session.mount('http://', adapter)
TeaCore._sessions[session_key] = session
return TeaCore._sessions[session_key]
@staticmethod
def _prepare_http_debug(request, symbol):
base = ''
for key, value in request.headers.items():
base += f'\n{symbol} {key} : {value}'
return base
@staticmethod
def _do_http_debug(request, response):
# logger the request
url = urlparse(request.url)
request_base = f'\n> {request.method.upper()} {url.path + url.query} HTTP/1.1'
logger.debug(request_base + TeaCore._prepare_http_debug(request, '>'))
# logger the response
response_base = f'\n< HTTP/1.1 {response.status_code}' \
f' {status_codes._codes.get(response.status_code)[0].upper()}'
logger.debug(response_base + TeaCore._prepare_http_debug(response, '<'))
@staticmethod
def compose_url(request):
host = request.headers.get('host')
if not host:
raise RequiredArgumentException('endpoint')
else:
host = host.rstrip('/')
protocol = f'{request.protocol.lower()}://'
pathname = request.pathname
if host.startswith(('http://', 'https://')):
protocol = ''
if request.port == 80:
port = ''
else:
port = f':{request.port}'
url = protocol + host + port + pathname
if request.query:
if "?" in url:
if not url.endswith("&"):
url += "&"
else:
url += "?"
encode_query = {}
for key in request.query:
value = request.query[key]
if value is not None:
encode_query[key] = str(value)
url += urlencode(encode_query)
return url.rstrip("?&")
@staticmethod
async def async_do_action(
request: TeaRequest,
runtime_option=None
) -> TeaResponse:
runtime_option = runtime_option or {}
url = TeaCore.compose_url(request)
verify = not runtime_option.get('ignoreSSL', False)
tls_min_version = runtime_option.get('tlsMinVersion')
if isinstance(tls_min_version, Enum):
tls_min_version = tls_min_version.value
timeout = runtime_option.get('timeout')
connect_timeout = runtime_option.get('connectTimeout') or timeout or DEFAULT_CONNECT_TIMEOUT
read_timeout = runtime_option.get('readTimeout') or timeout or DEFAULT_READ_TIMEOUT
connect_timeout, read_timeout = (int(connect_timeout) / 1000, int(read_timeout) / 1000)
proxy = None
if request.protocol.upper() == 'HTTP':
proxy = runtime_option.get('httpProxy')
if not proxy:
proxy = os.environ.get('HTTP_PROXY') or os.environ.get('http_proxy')
elif request.protocol.upper() == 'HTTPS':
proxy = runtime_option.get('httpsProxy')
if not proxy:
proxy = os.environ.get('HTTPS_PROXY') or os.environ.get('https_proxy')
connector = None
ca_cert = certifi.where()
if ca_cert and request.protocol.upper() == 'HTTPS':
ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
ssl_context = TeaCore._set_tls_minimum_version(ssl_context, tls_min_version)
ssl_context.load_verify_locations(ca_cert)
connector = aiohttp.TCPConnector(
ssl=ssl_context,
)
else:
verify = False
timeout = aiohttp.ClientTimeout(
sock_read=read_timeout,
sock_connect=connect_timeout
)
async with aiohttp.ClientSession(
connector=connector
) as s:
body = b''
if isinstance(request.body, BaseStream):
for content in request.body:
body += content
elif isinstance(request.body, str):
body = request.body.encode('utf-8')
else:
body = request.body
try:
async with s.request(request.method, url,
data=body,
headers=request.headers,
ssl=verify,
proxy=proxy,
timeout=timeout) as response:
tea_resp = TeaResponse()
tea_resp.body = await response.read()
tea_resp.headers = {k.lower(): v for k, v in response.headers.items()}
tea_resp.status_code = response.status
tea_resp.status_message = response.reason
tea_resp.response = response
except IOError as e:
raise RetryError(str(e))
return tea_resp
@staticmethod
def do_action(
request: TeaRequest,
runtime_option=None
) -> TeaResponse:
url = TeaCore.compose_url(request)
runtime_option = runtime_option or {}
verify = not runtime_option.get('ignoreSSL', False)
tls_min_version = runtime_option.get('tlsMinVersion')
if isinstance(tls_min_version, Enum):
tls_min_version = tls_min_version.value
if verify:
verify = runtime_option.get('ca', True) if runtime_option.get('ca', True) is not None else True
cert = runtime_option.get('cert', None)
timeout = runtime_option.get('timeout')
connect_timeout = runtime_option.get('connectTimeout') or timeout or DEFAULT_CONNECT_TIMEOUT
read_timeout = runtime_option.get('readTimeout') or timeout or DEFAULT_READ_TIMEOUT
timeout = (int(connect_timeout) / 1000, int(read_timeout) / 1000)
if isinstance(request.body, str):
request.body = request.body.encode('utf-8')
p = PreparedRequest()
p.prepare(
method=request.method.upper(),
url=url,
data=request.body,
headers=request.headers,
)
proxies = {}
http_proxy = runtime_option.get('httpProxy')
https_proxy = runtime_option.get('httpsProxy')
no_proxy = runtime_option.get('noProxy')
if not http_proxy:
http_proxy = os.environ.get('HTTP_PROXY') or os.environ.get('http_proxy')
if not https_proxy:
https_proxy = os.environ.get('HTTPS_PROXY') or os.environ.get('https_proxy')
if http_proxy:
proxies['http'] = http_proxy
if https_proxy:
proxies['https'] = https_proxy
if no_proxy:
proxies['no_proxy'] = no_proxy
host = request.headers.get('host')
host = host.rstrip('/')
session_key = f'{request.protocol.lower()}://{host}:{request.port}'
session = TeaCore._get_session(session_key=session_key, protocol=request.protocol,
tls_min_version=tls_min_version, verify=verify)
try:
resp = session.send(
p,
proxies=proxies,
timeout=timeout,
verify=verify,
cert=cert,
)
except IOError as e:
raise RetryError(str(e))
debug = runtime_option.get('debug') or os.getenv('DEBUG')
if debug and debug.lower() == 'sdk':
TeaCore._do_http_debug(p, resp)
response = TeaResponse()
response.status_message = resp.reason
response.status_code = resp.status_code
response.headers = {k.lower(): v for k, v in resp.headers.items()}
response.body = resp.content
response.response = resp
return response
@staticmethod
def get_response_body(resp) -> str:
return resp.content.decode("utf-8")
@staticmethod
def allow_retry(dic, retry_times, now=None) -> bool:
if retry_times == 0:
return True
if dic is None or not dic.__contains__("maxAttempts") or \
dic.get('retryable') is not True and retry_times >= 1:
return False
else:
retry = 0 if dic.get("maxAttempts") is None else int(
dic.get("maxAttempts"))
return retry >= retry_times
@staticmethod
def get_backoff_time(dic, retry_times) -> int:
default_back_off_time = 0
if dic is None or not dic.get("policy") or dic.get("policy") == "no":
return default_back_off_time
back_off_time = dic.get('period', default_back_off_time)
if not isinstance(back_off_time, int) and \
not (isinstance(back_off_time, str) and back_off_time.isdigit()):
return default_back_off_time
back_off_time = int(back_off_time)
if back_off_time < 0:
return retry_times
return back_off_time
@staticmethod
async def sleep_async(t):
await asyncio.sleep(t)
@staticmethod
def sleep(t):
time.sleep(t)
@staticmethod
def is_retryable(ex) -> bool:
return isinstance(ex, RetryError)
@staticmethod
def bytes_readable(body):
return body
@staticmethod
def merge(*dic_list) -> dict:
dic_result = {}
for item in dic_list:
if isinstance(item, dict):
dic_result.update(item)
elif isinstance(item, TeaModel):
dic_result.update(item.to_map())
return dic_result
@staticmethod
def to_map(model: Optional[TeaModel]) -> Dict[str, Any]:
if isinstance(model, TeaModel):
return model.to_map()
else:
return dict()
@staticmethod
def from_map(
model: TeaModel,
dic: Dict[str, Any]
) -> TeaModel:
if isinstance(model, TeaModel):
try:
return model.from_map(dic)
except Exception:
model._map = dic
return model
else:
return model

View File

@ -0,0 +1,30 @@
import warnings
import functools
def deprecated(reason):
"""This is a decorator which can be used to mark functions as deprecated.
It will result in a warning being emitted when the function is used.
Args:
reason (str): Explanation of why the function is deprecated.
"""
def decorator(func):
original_func = func.__func__ if isinstance(func, staticmethod) or isinstance(func, classmethod) else func
@functools.wraps(original_func)
def decorated_function(*args, **kwargs):
warnings.warn(f"Call to deprecated function {original_func.__name__}. {reason}",
category=DeprecationWarning,
stacklevel=2)
return original_func(*args, **kwargs)
if isinstance(func, staticmethod):
return staticmethod(decorated_function)
elif isinstance(func, classmethod):
return classmethod(decorated_function)
else:
return decorated_function
return decorator

View File

@ -0,0 +1,56 @@
from .request import TeaRequest
class TeaException(Exception):
def __init__(self, dic):
self.code = dic.get("code")
self.message = dic.get("message")
self.data = dic.get("data")
self.description = dic.get("description")
self.accessDeniedDetail = dic.get("accessDeniedDetail")
if isinstance(dic.get("data"), dict) and dic.get("data").get("statusCode") is not None:
self.statusCode = dic.get("data").get("statusCode")
def __str__(self):
return f'Error: {self.code} {self.message} Response: {self.data}'
class ValidateException(Exception):
pass
class RequiredArgumentException(ValidateException):
def __init__(self, arg):
self.arg = arg
def __str__(self):
return f'"{self.arg}" is required.'
class RetryError(Exception):
def __init__(self, message):
self.message = message
self.data = None
class UnretryableException(TeaException):
def __init__(
self,
request: TeaRequest,
ex: Exception
):
self.last_request = request
self.inner_exception = ex
if isinstance(ex, TeaException):
super().__init__({
'code': ex.code,
'message': ex.message,
'data': ex.data
})
else:
super().__init__({
'message': repr(ex),
})
def __str__(self):
return str(self.inner_exception)

View File

@ -0,0 +1,53 @@
import re
from .exceptions import RequiredArgumentException, ValidateException
class TeaModel:
_map = None
def validate(self):
pass
def to_map(self):
return self._map
def from_map(self, map=None):
pass
@staticmethod
def validate_required(prop, prop_name):
if prop is None:
raise RequiredArgumentException(prop_name)
@staticmethod
def validate_max_length(prop, prop_name, max_length):
if len(prop) > max_length:
raise ValidateException(f'{prop_name} is exceed max-length: {max_length}')
@staticmethod
def validate_min_length(prop, prop_name, min_length):
if len(prop) < min_length:
raise ValidateException(f'{prop_name} is less than min-length: {min_length}')
@staticmethod
def validate_pattern(prop, prop_name, pattern):
match_obj = re.search(pattern, str(prop), re.M | re.I)
if not match_obj:
raise ValidateException(f'{prop_name} is not match: {pattern}')
@staticmethod
def validate_maximum(num, prop_name, maximum):
if num > maximum:
raise ValidateException(f'{prop_name} is greater than the maximum: {maximum}')
@staticmethod
def validate_minimum(num, prop_name, minimum):
if num < minimum:
raise ValidateException(f'{prop_name} is less than the minimum: {minimum}')
def __str__(self):
s = self.to_map()
if s:
return str(s)
else:
return object.__str__(self)

View File

@ -0,0 +1,29 @@
class TeaRequest:
_PROPERTY_DEFAULT_MAP = {
'query': {},
'protocol': 'http',
'port': 80,
'method': 'GET',
'headers': {},
'pathname': "",
'body': None,
}
def __init__(self):
self.query = {}
self.protocol = "http"
self.port = 80
self.method = "GET"
self.headers = {}
self.pathname = ""
self.body = None
def __setattr__(self, key, value):
if key in self._PROPERTY_DEFAULT_MAP:
if not value:
if isinstance(self._PROPERTY_DEFAULT_MAP[key], (list, dict)):
self.__dict__[key] = self._PROPERTY_DEFAULT_MAP[key].copy()
else:
self.__dict__[key] = self._PROPERTY_DEFAULT_MAP[key]
return
self.__dict__[key] = value

View File

@ -0,0 +1,8 @@
class TeaResponse:
# status
status_code = None
# reason
status_message = None
headers = None
response = None
body = None

View File

@ -0,0 +1,38 @@
class BaseStream:
def __init__(self, size=1024):
self.size = size
def read(self, size=1024):
raise NotImplementedError('read method must be overridden')
def __len__(self):
raise NotImplementedError('__len__ method must be overridden')
def __next__(self):
raise NotImplementedError('__next__ method must be overridden')
def __iter__(self):
return self
class _ReadableMc(type):
def __instancecheck__(self, instance):
if hasattr(instance, 'read') and hasattr(instance, '__iter__'):
return True
class READABLE(metaclass=_ReadableMc):
pass
class _WriteableMc(type):
def __instancecheck__(self, instance):
if hasattr(instance, 'write'):
return True
class WRITABLE(metaclass=_WriteableMc):
pass
STREAM_CLASS = (READABLE, WRITABLE)

Binary file not shown.

View File

@ -0,0 +1 @@
pip

View File

@ -0,0 +1,318 @@
Metadata-Version: 2.3
Name: aiofiles
Version: 24.1.0
Summary: File support for asyncio.
Project-URL: Changelog, https://github.com/Tinche/aiofiles#history
Project-URL: Bug Tracker, https://github.com/Tinche/aiofiles/issues
Project-URL: repository, https://github.com/Tinche/aiofiles
Author-email: Tin Tvrtkovic <tinchester@gmail.com>
License: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: AsyncIO
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Requires-Python: >=3.8
Description-Content-Type: text/markdown
# aiofiles: file support for asyncio
[![PyPI](https://img.shields.io/pypi/v/aiofiles.svg)](https://pypi.python.org/pypi/aiofiles)
[![Build](https://github.com/Tinche/aiofiles/workflows/CI/badge.svg)](https://github.com/Tinche/aiofiles/actions)
[![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/Tinche/882f02e3df32136c847ba90d2688f06e/raw/covbadge.json)](https://github.com/Tinche/aiofiles/actions/workflows/main.yml)
[![Supported Python versions](https://img.shields.io/pypi/pyversions/aiofiles.svg)](https://github.com/Tinche/aiofiles)
[![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
**aiofiles** is an Apache2 licensed library, written in Python, for handling local
disk files in asyncio applications.
Ordinary local file IO is blocking, and cannot easily and portably be made
asynchronous. This means doing file IO may interfere with asyncio applications,
which shouldn't block the executing thread. aiofiles helps with this by
introducing asynchronous versions of files that support delegating operations to
a separate thread pool.
```python
async with aiofiles.open('filename', mode='r') as f:
contents = await f.read()
print(contents)
'My file contents'
```
Asynchronous iteration is also supported.
```python
async with aiofiles.open('filename') as f:
async for line in f:
...
```
Asynchronous interface to tempfile module.
```python
async with aiofiles.tempfile.TemporaryFile('wb') as f:
await f.write(b'Hello, World!')
```
## Features
- a file API very similar to Python's standard, blocking API
- support for buffered and unbuffered binary files, and buffered text files
- support for `async`/`await` ([PEP 492](https://peps.python.org/pep-0492/)) constructs
- async interface to tempfile module
## Installation
To install aiofiles, simply:
```bash
$ pip install aiofiles
```
## Usage
Files are opened using the `aiofiles.open()` coroutine, which in addition to
mirroring the builtin `open` accepts optional `loop` and `executor`
arguments. If `loop` is absent, the default loop will be used, as per the
set asyncio policy. If `executor` is not specified, the default event loop
executor will be used.
In case of success, an asynchronous file object is returned with an
API identical to an ordinary file, except the following methods are coroutines
and delegate to an executor:
- `close`
- `flush`
- `isatty`
- `read`
- `readall`
- `read1`
- `readinto`
- `readline`
- `readlines`
- `seek`
- `seekable`
- `tell`
- `truncate`
- `writable`
- `write`
- `writelines`
In case of failure, one of the usual exceptions will be raised.
`aiofiles.stdin`, `aiofiles.stdout`, `aiofiles.stderr`,
`aiofiles.stdin_bytes`, `aiofiles.stdout_bytes`, and
`aiofiles.stderr_bytes` provide async access to `sys.stdin`,
`sys.stdout`, `sys.stderr`, and their corresponding `.buffer` properties.
The `aiofiles.os` module contains executor-enabled coroutine versions of
several useful `os` functions that deal with files:
- `stat`
- `statvfs`
- `sendfile`
- `rename`
- `renames`
- `replace`
- `remove`
- `unlink`
- `mkdir`
- `makedirs`
- `rmdir`
- `removedirs`
- `link`
- `symlink`
- `readlink`
- `listdir`
- `scandir`
- `access`
- `getcwd`
- `path.abspath`
- `path.exists`
- `path.isfile`
- `path.isdir`
- `path.islink`
- `path.ismount`
- `path.getsize`
- `path.getatime`
- `path.getctime`
- `path.samefile`
- `path.sameopenfile`
### Tempfile
**aiofiles.tempfile** implements the following interfaces:
- TemporaryFile
- NamedTemporaryFile
- SpooledTemporaryFile
- TemporaryDirectory
Results return wrapped with a context manager allowing use with async with and async for.
```python
async with aiofiles.tempfile.NamedTemporaryFile('wb+') as f:
await f.write(b'Line1\n Line2')
await f.seek(0)
async for line in f:
print(line)
async with aiofiles.tempfile.TemporaryDirectory() as d:
filename = os.path.join(d, "file.ext")
```
### Writing tests for aiofiles
Real file IO can be mocked by patching `aiofiles.threadpool.sync_open`
as desired. The return type also needs to be registered with the
`aiofiles.threadpool.wrap` dispatcher:
```python
aiofiles.threadpool.wrap.register(mock.MagicMock)(
lambda *args, **kwargs: aiofiles.threadpool.AsyncBufferedIOBase(*args, **kwargs)
)
async def test_stuff():
write_data = 'data'
read_file_chunks = [
b'file chunks 1',
b'file chunks 2',
b'file chunks 3',
b'',
]
file_chunks_iter = iter(read_file_chunks)
mock_file_stream = mock.MagicMock(
read=lambda *args, **kwargs: next(file_chunks_iter)
)
with mock.patch('aiofiles.threadpool.sync_open', return_value=mock_file_stream) as mock_open:
async with aiofiles.open('filename', 'w') as f:
await f.write(write_data)
assert f.read() == b'file chunks 1'
mock_file_stream.write.assert_called_once_with(write_data)
```
### History
#### 24.1.0 (2024-06-24)
- Import `os.link` conditionally to fix importing on android.
[#175](https://github.com/Tinche/aiofiles/issues/175)
- Remove spurious items from `aiofiles.os.__all__` when running on Windows.
- Switch to more modern async idioms: Remove types.coroutine and make AiofilesContextManager an awaitable instead a coroutine.
- Add `aiofiles.os.path.abspath` and `aiofiles.os.getcwd`.
[#174](https://github.com/Tinche/aiofiles/issues/181)
- _aiofiles_ is now tested on Python 3.13 too.
[#184](https://github.com/Tinche/aiofiles/pull/184)
- Dropped Python 3.7 support. If you require it, use version 23.2.1.
#### 23.2.1 (2023-08-09)
- Import `os.statvfs` conditionally to fix importing on non-UNIX systems.
[#171](https://github.com/Tinche/aiofiles/issues/171) [#172](https://github.com/Tinche/aiofiles/pull/172)
- aiofiles is now also tested on Windows.
#### 23.2.0 (2023-08-09)
- aiofiles is now tested on Python 3.12 too.
[#166](https://github.com/Tinche/aiofiles/issues/166) [#168](https://github.com/Tinche/aiofiles/pull/168)
- On Python 3.12, `aiofiles.tempfile.NamedTemporaryFile` now accepts a `delete_on_close` argument, just like the stdlib version.
- On Python 3.12, `aiofiles.tempfile.NamedTemporaryFile` no longer exposes a `delete` attribute, just like the stdlib version.
- Added `aiofiles.os.statvfs` and `aiofiles.os.path.ismount`.
[#162](https://github.com/Tinche/aiofiles/pull/162)
- Use [PDM](https://pdm.fming.dev/latest/) instead of Poetry.
[#169](https://github.com/Tinche/aiofiles/pull/169)
#### 23.1.0 (2023-02-09)
- Added `aiofiles.os.access`.
[#146](https://github.com/Tinche/aiofiles/pull/146)
- Removed `aiofiles.tempfile.temptypes.AsyncSpooledTemporaryFile.softspace`.
[#151](https://github.com/Tinche/aiofiles/pull/151)
- Added `aiofiles.stdin`, `aiofiles.stdin_bytes`, and other stdio streams.
[#154](https://github.com/Tinche/aiofiles/pull/154)
- Transition to `asyncio.get_running_loop` (vs `asyncio.get_event_loop`) internally.
#### 22.1.0 (2022-09-04)
- Added `aiofiles.os.path.islink`.
[#126](https://github.com/Tinche/aiofiles/pull/126)
- Added `aiofiles.os.readlink`.
[#125](https://github.com/Tinche/aiofiles/pull/125)
- Added `aiofiles.os.symlink`.
[#124](https://github.com/Tinche/aiofiles/pull/124)
- Added `aiofiles.os.unlink`.
[#123](https://github.com/Tinche/aiofiles/pull/123)
- Added `aiofiles.os.link`.
[#121](https://github.com/Tinche/aiofiles/pull/121)
- Added `aiofiles.os.renames`.
[#120](https://github.com/Tinche/aiofiles/pull/120)
- Added `aiofiles.os.{listdir, scandir}`.
[#143](https://github.com/Tinche/aiofiles/pull/143)
- Switched to CalVer.
- Dropped Python 3.6 support. If you require it, use version 0.8.0.
- aiofiles is now tested on Python 3.11.
#### 0.8.0 (2021-11-27)
- aiofiles is now tested on Python 3.10.
- Added `aiofiles.os.replace`.
[#107](https://github.com/Tinche/aiofiles/pull/107)
- Added `aiofiles.os.{makedirs, removedirs}`.
- Added `aiofiles.os.path.{exists, isfile, isdir, getsize, getatime, getctime, samefile, sameopenfile}`.
[#63](https://github.com/Tinche/aiofiles/pull/63)
- Added `suffix`, `prefix`, `dir` args to `aiofiles.tempfile.TemporaryDirectory`.
[#116](https://github.com/Tinche/aiofiles/pull/116)
#### 0.7.0 (2021-05-17)
- Added the `aiofiles.tempfile` module for async temporary files.
[#56](https://github.com/Tinche/aiofiles/pull/56)
- Switched to Poetry and GitHub actions.
- Dropped 3.5 support.
#### 0.6.0 (2020-10-27)
- `aiofiles` is now tested on ppc64le.
- Added `name` and `mode` properties to async file objects.
[#82](https://github.com/Tinche/aiofiles/pull/82)
- Fixed a DeprecationWarning internally.
[#75](https://github.com/Tinche/aiofiles/pull/75)
- Python 3.9 support and tests.
#### 0.5.0 (2020-04-12)
- Python 3.8 support. Code base modernization (using `async/await` instead of `asyncio.coroutine`/`yield from`).
- Added `aiofiles.os.remove`, `aiofiles.os.rename`, `aiofiles.os.mkdir`, `aiofiles.os.rmdir`.
[#62](https://github.com/Tinche/aiofiles/pull/62)
#### 0.4.0 (2018-08-11)
- Python 3.7 support.
- Removed Python 3.3/3.4 support. If you use these versions, stick to aiofiles 0.3.x.
#### 0.3.2 (2017-09-23)
- The LICENSE is now included in the sdist.
[#31](https://github.com/Tinche/aiofiles/pull/31)
#### 0.3.1 (2017-03-10)
- Introduced a changelog.
- `aiofiles.os.sendfile` will now work if the standard `os` module contains a `sendfile` function.
### Contributing
Contributions are very welcome. Tests can be run with `tox`, please ensure
the coverage at least stays the same before you submit a pull request.

View File

@ -0,0 +1,26 @@
aiofiles-24.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
aiofiles-24.1.0.dist-info/METADATA,sha256=CvUJx21XclgI1Lp5Bt_4AyJesRYg0xCSx4exJZVmaSA,10708
aiofiles-24.1.0.dist-info/RECORD,,
aiofiles-24.1.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
aiofiles-24.1.0.dist-info/licenses/LICENSE,sha256=y16Ofl9KOYjhBjwULGDcLfdWBfTEZRXnduOspt-XbhQ,11325
aiofiles-24.1.0.dist-info/licenses/NOTICE,sha256=EExY0dRQvWR0wJ2LZLwBgnM6YKw9jCU-M0zegpRSD_E,55
aiofiles/__init__.py,sha256=1iAMJQyJtX3LGIS0AoFTJeO1aJ_RK2jpBSBhg0VoIrE,344
aiofiles/__pycache__/__init__.cpython-312.pyc,,
aiofiles/__pycache__/base.cpython-312.pyc,,
aiofiles/__pycache__/os.cpython-312.pyc,,
aiofiles/__pycache__/ospath.cpython-312.pyc,,
aiofiles/base.py,sha256=zo0FgkCqZ5aosjvxqIvDf2t-RFg1Lc6X8P6rZ56p6fQ,1784
aiofiles/os.py,sha256=0DrsG-eH4h7xRzglv9pIWsQuzqe7ZhVYw5FQS18fIys,1153
aiofiles/ospath.py,sha256=WaYelz_k6ykAFRLStr4bqYIfCVQ-5GGzIqIizykbY2Q,794
aiofiles/tempfile/__init__.py,sha256=hFSNTOjOUv371Ozdfy6FIxeln46Nm3xOVh4ZR3Q94V0,10244
aiofiles/tempfile/__pycache__/__init__.cpython-312.pyc,,
aiofiles/tempfile/__pycache__/temptypes.cpython-312.pyc,,
aiofiles/tempfile/temptypes.py,sha256=ddEvNjMLVlr7WUILCe6ypTqw77yREeIonTk16Uw_NVs,2093
aiofiles/threadpool/__init__.py,sha256=kt0hwwx3bLiYtnA1SORhW8mJ6z4W9Xr7MbY80UIJJrI,3133
aiofiles/threadpool/__pycache__/__init__.cpython-312.pyc,,
aiofiles/threadpool/__pycache__/binary.cpython-312.pyc,,
aiofiles/threadpool/__pycache__/text.cpython-312.pyc,,
aiofiles/threadpool/__pycache__/utils.cpython-312.pyc,,
aiofiles/threadpool/binary.py,sha256=hp-km9VCRu0MLz_wAEUfbCz7OL7xtn9iGAawabpnp5U,2315
aiofiles/threadpool/text.py,sha256=fNmpw2PEkj0BZSldipJXAgZqVGLxALcfOMiuDQ54Eas,1223
aiofiles/threadpool/utils.py,sha256=B59dSZwO_WZs2dFFycKeA91iD2Xq2nNw1EFF8YMBI5k,1868

View File

@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: hatchling 1.25.0
Root-Is-Purelib: true
Tag: py3-none-any

View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,2 @@
Asyncio support for files
Copyright 2016 Tin Tvrtkovic

View File

@ -0,0 +1,22 @@
"""Utilities for asyncio-friendly file handling."""
from .threadpool import (
open,
stdin,
stdout,
stderr,
stdin_bytes,
stdout_bytes,
stderr_bytes,
)
from . import tempfile
__all__ = [
"open",
"tempfile",
"stdin",
"stdout",
"stderr",
"stdin_bytes",
"stdout_bytes",
"stderr_bytes",
]

View File

@ -0,0 +1,69 @@
"""Various base classes."""
from collections.abc import Awaitable
from contextlib import AbstractAsyncContextManager
from asyncio import get_running_loop
class AsyncBase:
def __init__(self, file, loop, executor):
self._file = file
self._executor = executor
self._ref_loop = loop
@property
def _loop(self):
return self._ref_loop or get_running_loop()
def __aiter__(self):
"""We are our own iterator."""
return self
def __repr__(self):
return super().__repr__() + " wrapping " + repr(self._file)
async def __anext__(self):
"""Simulate normal file iteration."""
line = await self.readline()
if line:
return line
else:
raise StopAsyncIteration
class AsyncIndirectBase(AsyncBase):
def __init__(self, name, loop, executor, indirect):
self._indirect = indirect
self._name = name
super().__init__(None, loop, executor)
@property
def _file(self):
return self._indirect()
@_file.setter
def _file(self, v):
pass # discard writes
class AiofilesContextManager(Awaitable, AbstractAsyncContextManager):
"""An adjusted async context manager for aiofiles."""
__slots__ = ("_coro", "_obj")
def __init__(self, coro):
self._coro = coro
self._obj = None
def __await__(self):
if self._obj is None:
self._obj = yield from self._coro.__await__()
return self._obj
async def __aenter__(self):
return await self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await get_running_loop().run_in_executor(
None, self._obj._file.__exit__, exc_type, exc_val, exc_tb
)
self._obj = None

View File

@ -0,0 +1,58 @@
"""Async executor versions of file functions from the os module."""
import os
from . import ospath as path
from .ospath import wrap
__all__ = [
"path",
"stat",
"rename",
"renames",
"replace",
"remove",
"unlink",
"mkdir",
"makedirs",
"rmdir",
"removedirs",
"symlink",
"readlink",
"listdir",
"scandir",
"access",
"wrap",
"getcwd",
]
if hasattr(os, "link"):
__all__ += ["link"]
if hasattr(os, "sendfile"):
__all__ += ["sendfile"]
if hasattr(os, "statvfs"):
__all__ += ["statvfs"]
stat = wrap(os.stat)
rename = wrap(os.rename)
renames = wrap(os.renames)
replace = wrap(os.replace)
remove = wrap(os.remove)
unlink = wrap(os.unlink)
mkdir = wrap(os.mkdir)
makedirs = wrap(os.makedirs)
rmdir = wrap(os.rmdir)
removedirs = wrap(os.removedirs)
symlink = wrap(os.symlink)
readlink = wrap(os.readlink)
listdir = wrap(os.listdir)
scandir = wrap(os.scandir)
access = wrap(os.access)
getcwd = wrap(os.getcwd)
if hasattr(os, "link"):
link = wrap(os.link)
if hasattr(os, "sendfile"):
sendfile = wrap(os.sendfile)
if hasattr(os, "statvfs"):
statvfs = wrap(os.statvfs)

View File

@ -0,0 +1,30 @@
"""Async executor versions of file functions from the os.path module."""
import asyncio
from functools import partial, wraps
from os import path
def wrap(func):
@wraps(func)
async def run(*args, loop=None, executor=None, **kwargs):
if loop is None:
loop = asyncio.get_running_loop()
pfunc = partial(func, *args, **kwargs)
return await loop.run_in_executor(executor, pfunc)
return run
exists = wrap(path.exists)
isfile = wrap(path.isfile)
isdir = wrap(path.isdir)
islink = wrap(path.islink)
ismount = wrap(path.ismount)
getsize = wrap(path.getsize)
getmtime = wrap(path.getmtime)
getatime = wrap(path.getatime)
getctime = wrap(path.getctime)
samefile = wrap(path.samefile)
sameopenfile = wrap(path.sameopenfile)
abspath = wrap(path.abspath)

View File

@ -0,0 +1,357 @@
import asyncio
from functools import partial, singledispatch
from io import BufferedRandom, BufferedReader, BufferedWriter, FileIO, TextIOBase
from tempfile import NamedTemporaryFile as syncNamedTemporaryFile
from tempfile import SpooledTemporaryFile as syncSpooledTemporaryFile
from tempfile import TemporaryDirectory as syncTemporaryDirectory
from tempfile import TemporaryFile as syncTemporaryFile
from tempfile import _TemporaryFileWrapper as syncTemporaryFileWrapper
from ..base import AiofilesContextManager
from ..threadpool.binary import AsyncBufferedIOBase, AsyncBufferedReader, AsyncFileIO
from ..threadpool.text import AsyncTextIOWrapper
from .temptypes import AsyncSpooledTemporaryFile, AsyncTemporaryDirectory
import sys
__all__ = [
"NamedTemporaryFile",
"TemporaryFile",
"SpooledTemporaryFile",
"TemporaryDirectory",
]
# ================================================================
# Public methods for async open and return of temp file/directory
# objects with async interface
# ================================================================
if sys.version_info >= (3, 12):
def NamedTemporaryFile(
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
delete=True,
delete_on_close=True,
loop=None,
executor=None,
):
"""Async open a named temporary file"""
return AiofilesContextManager(
_temporary_file(
named=True,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
delete=delete,
delete_on_close=delete_on_close,
loop=loop,
executor=executor,
)
)
else:
def NamedTemporaryFile(
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
delete=True,
loop=None,
executor=None,
):
"""Async open a named temporary file"""
return AiofilesContextManager(
_temporary_file(
named=True,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
delete=delete,
loop=loop,
executor=executor,
)
)
def TemporaryFile(
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
loop=None,
executor=None,
):
"""Async open an unnamed temporary file"""
return AiofilesContextManager(
_temporary_file(
named=False,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
loop=loop,
executor=executor,
)
)
def SpooledTemporaryFile(
max_size=0,
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
loop=None,
executor=None,
):
"""Async open a spooled temporary file"""
return AiofilesContextManager(
_spooled_temporary_file(
max_size=max_size,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
loop=loop,
executor=executor,
)
)
def TemporaryDirectory(suffix=None, prefix=None, dir=None, loop=None, executor=None):
"""Async open a temporary directory"""
return AiofilesContextManagerTempDir(
_temporary_directory(
suffix=suffix, prefix=prefix, dir=dir, loop=loop, executor=executor
)
)
# =========================================================
# Internal coroutines to open new temp files/directories
# =========================================================
if sys.version_info >= (3, 12):
async def _temporary_file(
named=True,
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
delete=True,
delete_on_close=True,
loop=None,
executor=None,
max_size=0,
):
"""Async method to open a temporary file with async interface"""
if loop is None:
loop = asyncio.get_running_loop()
if named:
cb = partial(
syncNamedTemporaryFile,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
delete=delete,
delete_on_close=delete_on_close,
)
else:
cb = partial(
syncTemporaryFile,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
)
f = await loop.run_in_executor(executor, cb)
# Wrap based on type of underlying IO object
if type(f) is syncTemporaryFileWrapper:
# _TemporaryFileWrapper was used (named files)
result = wrap(f.file, f, loop=loop, executor=executor)
result._closer = f._closer
return result
else:
# IO object was returned directly without wrapper
return wrap(f, f, loop=loop, executor=executor)
else:
async def _temporary_file(
named=True,
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
delete=True,
loop=None,
executor=None,
max_size=0,
):
"""Async method to open a temporary file with async interface"""
if loop is None:
loop = asyncio.get_running_loop()
if named:
cb = partial(
syncNamedTemporaryFile,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
delete=delete,
)
else:
cb = partial(
syncTemporaryFile,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
)
f = await loop.run_in_executor(executor, cb)
# Wrap based on type of underlying IO object
if type(f) is syncTemporaryFileWrapper:
# _TemporaryFileWrapper was used (named files)
result = wrap(f.file, f, loop=loop, executor=executor)
# add delete property
result.delete = f.delete
return result
else:
# IO object was returned directly without wrapper
return wrap(f, f, loop=loop, executor=executor)
async def _spooled_temporary_file(
max_size=0,
mode="w+b",
buffering=-1,
encoding=None,
newline=None,
suffix=None,
prefix=None,
dir=None,
loop=None,
executor=None,
):
"""Open a spooled temporary file with async interface"""
if loop is None:
loop = asyncio.get_running_loop()
cb = partial(
syncSpooledTemporaryFile,
max_size=max_size,
mode=mode,
buffering=buffering,
encoding=encoding,
newline=newline,
suffix=suffix,
prefix=prefix,
dir=dir,
)
f = await loop.run_in_executor(executor, cb)
# Single interface provided by SpooledTemporaryFile for all modes
return AsyncSpooledTemporaryFile(f, loop=loop, executor=executor)
async def _temporary_directory(
suffix=None, prefix=None, dir=None, loop=None, executor=None
):
"""Async method to open a temporary directory with async interface"""
if loop is None:
loop = asyncio.get_running_loop()
cb = partial(syncTemporaryDirectory, suffix, prefix, dir)
f = await loop.run_in_executor(executor, cb)
return AsyncTemporaryDirectory(f, loop=loop, executor=executor)
class AiofilesContextManagerTempDir(AiofilesContextManager):
"""With returns the directory location, not the object (matching sync lib)"""
async def __aenter__(self):
self._obj = await self._coro
return self._obj.name
@singledispatch
def wrap(base_io_obj, file, *, loop=None, executor=None):
"""Wrap the object with interface based on type of underlying IO"""
raise TypeError("Unsupported IO type: {}".format(base_io_obj))
@wrap.register(TextIOBase)
def _(base_io_obj, file, *, loop=None, executor=None):
return AsyncTextIOWrapper(file, loop=loop, executor=executor)
@wrap.register(BufferedWriter)
def _(base_io_obj, file, *, loop=None, executor=None):
return AsyncBufferedIOBase(file, loop=loop, executor=executor)
@wrap.register(BufferedReader)
@wrap.register(BufferedRandom)
def _(base_io_obj, file, *, loop=None, executor=None):
return AsyncBufferedReader(file, loop=loop, executor=executor)
@wrap.register(FileIO)
def _(base_io_obj, file, *, loop=None, executor=None):
return AsyncFileIO(file, loop=loop, executor=executor)

View File

@ -0,0 +1,69 @@
"""Async wrappers for spooled temp files and temp directory objects"""
from functools import partial
from ..base import AsyncBase
from ..threadpool.utils import (
cond_delegate_to_executor,
delegate_to_executor,
proxy_property_directly,
)
@delegate_to_executor("fileno", "rollover")
@cond_delegate_to_executor(
"close",
"flush",
"isatty",
"read",
"readline",
"readlines",
"seek",
"tell",
"truncate",
)
@proxy_property_directly("closed", "encoding", "mode", "name", "newlines")
class AsyncSpooledTemporaryFile(AsyncBase):
"""Async wrapper for SpooledTemporaryFile class"""
async def _check(self):
if self._file._rolled:
return
max_size = self._file._max_size
if max_size and self._file.tell() > max_size:
await self.rollover()
async def write(self, s):
"""Implementation to anticipate rollover"""
if self._file._rolled:
cb = partial(self._file.write, s)
return await self._loop.run_in_executor(self._executor, cb)
else:
file = self._file._file # reference underlying base IO object
rv = file.write(s)
await self._check()
return rv
async def writelines(self, iterable):
"""Implementation to anticipate rollover"""
if self._file._rolled:
cb = partial(self._file.writelines, iterable)
return await self._loop.run_in_executor(self._executor, cb)
else:
file = self._file._file # reference underlying base IO object
rv = file.writelines(iterable)
await self._check()
return rv
@delegate_to_executor("cleanup")
@proxy_property_directly("name")
class AsyncTemporaryDirectory:
"""Async wrapper for TemporaryDirectory class"""
def __init__(self, file, loop, executor):
self._file = file
self._loop = loop
self._executor = executor
async def close(self):
await self.cleanup()

View File

@ -0,0 +1,139 @@
"""Handle files using a thread pool executor."""
import asyncio
import sys
from functools import partial, singledispatch
from io import (
BufferedIOBase,
BufferedRandom,
BufferedReader,
BufferedWriter,
FileIO,
TextIOBase,
)
from ..base import AiofilesContextManager
from .binary import (
AsyncBufferedIOBase,
AsyncBufferedReader,
AsyncFileIO,
AsyncIndirectBufferedIOBase,
)
from .text import AsyncTextIndirectIOWrapper, AsyncTextIOWrapper
sync_open = open
__all__ = (
"open",
"stdin",
"stdout",
"stderr",
"stdin_bytes",
"stdout_bytes",
"stderr_bytes",
)
def open(
file,
mode="r",
buffering=-1,
encoding=None,
errors=None,
newline=None,
closefd=True,
opener=None,
*,
loop=None,
executor=None,
):
return AiofilesContextManager(
_open(
file,
mode=mode,
buffering=buffering,
encoding=encoding,
errors=errors,
newline=newline,
closefd=closefd,
opener=opener,
loop=loop,
executor=executor,
)
)
async def _open(
file,
mode="r",
buffering=-1,
encoding=None,
errors=None,
newline=None,
closefd=True,
opener=None,
*,
loop=None,
executor=None,
):
"""Open an asyncio file."""
if loop is None:
loop = asyncio.get_running_loop()
cb = partial(
sync_open,
file,
mode=mode,
buffering=buffering,
encoding=encoding,
errors=errors,
newline=newline,
closefd=closefd,
opener=opener,
)
f = await loop.run_in_executor(executor, cb)
return wrap(f, loop=loop, executor=executor)
@singledispatch
def wrap(file, *, loop=None, executor=None):
raise TypeError("Unsupported io type: {}.".format(file))
@wrap.register(TextIOBase)
def _(file, *, loop=None, executor=None):
return AsyncTextIOWrapper(file, loop=loop, executor=executor)
@wrap.register(BufferedWriter)
@wrap.register(BufferedIOBase)
def _(file, *, loop=None, executor=None):
return AsyncBufferedIOBase(file, loop=loop, executor=executor)
@wrap.register(BufferedReader)
@wrap.register(BufferedRandom)
def _(file, *, loop=None, executor=None):
return AsyncBufferedReader(file, loop=loop, executor=executor)
@wrap.register(FileIO)
def _(file, *, loop=None, executor=None):
return AsyncFileIO(file, loop=loop, executor=executor)
stdin = AsyncTextIndirectIOWrapper("sys.stdin", None, None, indirect=lambda: sys.stdin)
stdout = AsyncTextIndirectIOWrapper(
"sys.stdout", None, None, indirect=lambda: sys.stdout
)
stderr = AsyncTextIndirectIOWrapper(
"sys.stderr", None, None, indirect=lambda: sys.stderr
)
stdin_bytes = AsyncIndirectBufferedIOBase(
"sys.stdin.buffer", None, None, indirect=lambda: sys.stdin.buffer
)
stdout_bytes = AsyncIndirectBufferedIOBase(
"sys.stdout.buffer", None, None, indirect=lambda: sys.stdout.buffer
)
stderr_bytes = AsyncIndirectBufferedIOBase(
"sys.stderr.buffer", None, None, indirect=lambda: sys.stderr.buffer
)

View File

@ -0,0 +1,104 @@
from ..base import AsyncBase, AsyncIndirectBase
from .utils import delegate_to_executor, proxy_method_directly, proxy_property_directly
@delegate_to_executor(
"close",
"flush",
"isatty",
"read",
"read1",
"readinto",
"readline",
"readlines",
"seek",
"seekable",
"tell",
"truncate",
"writable",
"write",
"writelines",
)
@proxy_method_directly("detach", "fileno", "readable")
@proxy_property_directly("closed", "raw", "name", "mode")
class AsyncBufferedIOBase(AsyncBase):
"""The asyncio executor version of io.BufferedWriter and BufferedIOBase."""
@delegate_to_executor("peek")
class AsyncBufferedReader(AsyncBufferedIOBase):
"""The asyncio executor version of io.BufferedReader and Random."""
@delegate_to_executor(
"close",
"flush",
"isatty",
"read",
"readall",
"readinto",
"readline",
"readlines",
"seek",
"seekable",
"tell",
"truncate",
"writable",
"write",
"writelines",
)
@proxy_method_directly("fileno", "readable")
@proxy_property_directly("closed", "name", "mode")
class AsyncFileIO(AsyncBase):
"""The asyncio executor version of io.FileIO."""
@delegate_to_executor(
"close",
"flush",
"isatty",
"read",
"read1",
"readinto",
"readline",
"readlines",
"seek",
"seekable",
"tell",
"truncate",
"writable",
"write",
"writelines",
)
@proxy_method_directly("detach", "fileno", "readable")
@proxy_property_directly("closed", "raw", "name", "mode")
class AsyncIndirectBufferedIOBase(AsyncIndirectBase):
"""The indirect asyncio executor version of io.BufferedWriter and BufferedIOBase."""
@delegate_to_executor("peek")
class AsyncIndirectBufferedReader(AsyncIndirectBufferedIOBase):
"""The indirect asyncio executor version of io.BufferedReader and Random."""
@delegate_to_executor(
"close",
"flush",
"isatty",
"read",
"readall",
"readinto",
"readline",
"readlines",
"seek",
"seekable",
"tell",
"truncate",
"writable",
"write",
"writelines",
)
@proxy_method_directly("fileno", "readable")
@proxy_property_directly("closed", "name", "mode")
class AsyncIndirectFileIO(AsyncIndirectBase):
"""The indirect asyncio executor version of io.FileIO."""

View File

@ -0,0 +1,64 @@
from ..base import AsyncBase, AsyncIndirectBase
from .utils import delegate_to_executor, proxy_method_directly, proxy_property_directly
@delegate_to_executor(
"close",
"flush",
"isatty",
"read",
"readable",
"readline",
"readlines",
"seek",
"seekable",
"tell",
"truncate",
"write",
"writable",
"writelines",
)
@proxy_method_directly("detach", "fileno", "readable")
@proxy_property_directly(
"buffer",
"closed",
"encoding",
"errors",
"line_buffering",
"newlines",
"name",
"mode",
)
class AsyncTextIOWrapper(AsyncBase):
"""The asyncio executor version of io.TextIOWrapper."""
@delegate_to_executor(
"close",
"flush",
"isatty",
"read",
"readable",
"readline",
"readlines",
"seek",
"seekable",
"tell",
"truncate",
"write",
"writable",
"writelines",
)
@proxy_method_directly("detach", "fileno", "readable")
@proxy_property_directly(
"buffer",
"closed",
"encoding",
"errors",
"line_buffering",
"newlines",
"name",
"mode",
)
class AsyncTextIndirectIOWrapper(AsyncIndirectBase):
"""The indirect asyncio executor version of io.TextIOWrapper."""

View File

@ -0,0 +1,72 @@
import functools
def delegate_to_executor(*attrs):
def cls_builder(cls):
for attr_name in attrs:
setattr(cls, attr_name, _make_delegate_method(attr_name))
return cls
return cls_builder
def proxy_method_directly(*attrs):
def cls_builder(cls):
for attr_name in attrs:
setattr(cls, attr_name, _make_proxy_method(attr_name))
return cls
return cls_builder
def proxy_property_directly(*attrs):
def cls_builder(cls):
for attr_name in attrs:
setattr(cls, attr_name, _make_proxy_property(attr_name))
return cls
return cls_builder
def cond_delegate_to_executor(*attrs):
def cls_builder(cls):
for attr_name in attrs:
setattr(cls, attr_name, _make_cond_delegate_method(attr_name))
return cls
return cls_builder
def _make_delegate_method(attr_name):
async def method(self, *args, **kwargs):
cb = functools.partial(getattr(self._file, attr_name), *args, **kwargs)
return await self._loop.run_in_executor(self._executor, cb)
return method
def _make_proxy_method(attr_name):
def method(self, *args, **kwargs):
return getattr(self._file, attr_name)(*args, **kwargs)
return method
def _make_proxy_property(attr_name):
def proxy_property(self):
return getattr(self._file, attr_name)
return property(proxy_property)
def _make_cond_delegate_method(attr_name):
"""For spooled temp files, delegate only if rolled to file object"""
async def method(self, *args, **kwargs):
if self._file._rolled:
cb = functools.partial(getattr(self._file, attr_name), *args, **kwargs)
return await self._loop.run_in_executor(self._executor, cb)
else:
return getattr(self._file, attr_name)(*args, **kwargs)
return method

View File

@ -0,0 +1 @@
pip

View File

@ -0,0 +1,279 @@
A. HISTORY OF THE SOFTWARE
==========================
Python was created in the early 1990s by Guido van Rossum at Stichting
Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands
as a successor of a language called ABC. Guido remains Python's
principal author, although it includes many contributions from others.
In 1995, Guido continued his work on Python at the Corporation for
National Research Initiatives (CNRI, see https://www.cnri.reston.va.us)
in Reston, Virginia where he released several versions of the
software.
In May 2000, Guido and the Python core development team moved to
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
year, the PythonLabs team moved to Digital Creations, which became
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
https://www.python.org/psf/) was formed, a non-profit organization
created specifically to own Python-related Intellectual Property.
Zope Corporation was a sponsoring member of the PSF.
All Python releases are Open Source (see https://opensource.org for
the Open Source Definition). Historically, most, but not all, Python
releases have also been GPL-compatible; the table below summarizes
the various releases.
Release Derived Year Owner GPL-
from compatible? (1)
0.9.0 thru 1.2 1991-1995 CWI yes
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
1.6 1.5.2 2000 CNRI no
2.0 1.6 2000 BeOpen.com no
1.6.1 1.6 2001 CNRI yes (2)
2.1 2.0+1.6.1 2001 PSF no
2.0.1 2.0+1.6.1 2001 PSF yes
2.1.1 2.1+2.0.1 2001 PSF yes
2.1.2 2.1.1 2002 PSF yes
2.1.3 2.1.2 2002 PSF yes
2.2 and above 2.1.1 2001-now PSF yes
Footnotes:
(1) GPL-compatible doesn't mean that we're distributing Python under
the GPL. All Python licenses, unlike the GPL, let you distribute
a modified version without making your changes open source. The
GPL-compatible licenses make it possible to combine Python with
other software that is released under the GPL; the others don't.
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
because its license has a choice of law clause. According to
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
is "not incompatible" with the GPL.
Thanks to the many outside volunteers who have worked under Guido's
direction to make these releases possible.
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
===============================================================
Python software and documentation are licensed under the
Python Software Foundation License Version 2.
Starting with Python 3.8.6, examples, recipes, and other code in
the documentation are dual licensed under the PSF License Version 2
and the Zero-Clause BSD license.
Some software incorporated into Python is under different licenses.
The licenses are listed with code falling under that license.
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
--------------------------------------------
1. This LICENSE AGREEMENT is between the Python Software Foundation
("PSF"), and the Individual or Organization ("Licensee") accessing and
otherwise using this software ("Python") in source or binary form and
its associated documentation.
2. Subject to the terms and conditions of this License Agreement, PSF hereby
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
analyze, test, perform and/or display publicly, prepare derivative works,
distribute, and otherwise use Python alone or in any derivative version,
provided, however, that PSF's License Agreement and PSF's notice of copyright,
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation;
All Rights Reserved" are retained in Python alone or in any derivative version
prepared by Licensee.
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python.
4. PSF is making Python available to Licensee on an "AS IS"
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. Nothing in this License Agreement shall be deemed to create any
relationship of agency, partnership, or joint venture between PSF and
Licensee. This License Agreement does not grant permission to use PSF
trademarks or trade name in a trademark sense to endorse or promote
products or services of Licensee, or any third party.
8. By copying, installing or otherwise using Python, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
-------------------------------------------
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
Individual or Organization ("Licensee") accessing and otherwise using
this software in source or binary form and its associated
documentation ("the Software").
2. Subject to the terms and conditions of this BeOpen Python License
Agreement, BeOpen hereby grants Licensee a non-exclusive,
royalty-free, world-wide license to reproduce, analyze, test, perform
and/or display publicly, prepare derivative works, distribute, and
otherwise use the Software alone or in any derivative version,
provided, however, that the BeOpen Python License is retained in the
Software, alone or in any derivative version prepared by Licensee.
3. BeOpen is making the Software available to Licensee on an "AS IS"
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
5. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
6. This License Agreement shall be governed by and interpreted in all
respects by the law of the State of California, excluding conflict of
law provisions. Nothing in this License Agreement shall be deemed to
create any relationship of agency, partnership, or joint venture
between BeOpen and Licensee. This License Agreement does not grant
permission to use BeOpen trademarks or trade names in a trademark
sense to endorse or promote products or services of Licensee, or any
third party. As an exception, the "BeOpen Python" logos available at
http://www.pythonlabs.com/logos.html may be used according to the
permissions granted on that web page.
7. By copying, installing or otherwise using the software, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
---------------------------------------
1. This LICENSE AGREEMENT is between the Corporation for National
Research Initiatives, having an office at 1895 Preston White Drive,
Reston, VA 20191 ("CNRI"), and the Individual or Organization
("Licensee") accessing and otherwise using Python 1.6.1 software in
source or binary form and its associated documentation.
2. Subject to the terms and conditions of this License Agreement, CNRI
hereby grants Licensee a nonexclusive, royalty-free, world-wide
license to reproduce, analyze, test, perform and/or display publicly,
prepare derivative works, distribute, and otherwise use Python 1.6.1
alone or in any derivative version, provided, however, that CNRI's
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
1995-2001 Corporation for National Research Initiatives; All Rights
Reserved" are retained in Python 1.6.1 alone or in any derivative
version prepared by Licensee. Alternately, in lieu of CNRI's License
Agreement, Licensee may substitute the following text (omitting the
quotes): "Python 1.6.1 is made available subject to the terms and
conditions in CNRI's License Agreement. This Agreement together with
Python 1.6.1 may be located on the internet using the following
unique, persistent identifier (known as a handle): 1895.22/1013. This
Agreement may also be obtained from a proxy server on the internet
using the following URL: http://hdl.handle.net/1895.22/1013".
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python 1.6.1 or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python 1.6.1.
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. This License Agreement shall be governed by the federal
intellectual property law of the United States, including without
limitation the federal copyright law, and, to the extent such
U.S. federal law does not apply, by the law of the Commonwealth of
Virginia, excluding Virginia's conflict of law provisions.
Notwithstanding the foregoing, with regard to derivative works based
on Python 1.6.1 that incorporate non-separable material that was
previously distributed under the GNU General Public License (GPL), the
law of the Commonwealth of Virginia shall govern this License
Agreement only as to issues arising under or with respect to
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
License Agreement shall be deemed to create any relationship of
agency, partnership, or joint venture between CNRI and Licensee. This
License Agreement does not grant permission to use CNRI trademarks or
trade name in a trademark sense to endorse or promote products or
services of Licensee, or any third party.
8. By clicking on the "ACCEPT" button where indicated, or by copying,
installing or otherwise using Python 1.6.1, Licensee agrees to be
bound by the terms and conditions of this License Agreement.
ACCEPT
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
--------------------------------------------------
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
The Netherlands. All rights reserved.
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted,
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation, and that the name of Stichting Mathematisch
Centrum or CWI not be used in advertising or publicity pertaining to
distribution of the software without specific, written prior
permission.
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION
----------------------------------------------------------------------
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

View File

@ -0,0 +1,123 @@
Metadata-Version: 2.3
Name: aiohappyeyeballs
Version: 2.6.1
Summary: Happy Eyeballs for asyncio
License: PSF-2.0
Author: J. Nick Koston
Author-email: nick@koston.org
Requires-Python: >=3.9
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: English
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Libraries
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: License :: OSI Approved :: Python Software Foundation License
Project-URL: Bug Tracker, https://github.com/aio-libs/aiohappyeyeballs/issues
Project-URL: Changelog, https://github.com/aio-libs/aiohappyeyeballs/blob/main/CHANGELOG.md
Project-URL: Documentation, https://aiohappyeyeballs.readthedocs.io
Project-URL: Repository, https://github.com/aio-libs/aiohappyeyeballs
Description-Content-Type: text/markdown
# aiohappyeyeballs
<p align="center">
<a href="https://github.com/aio-libs/aiohappyeyeballs/actions/workflows/ci.yml?query=branch%3Amain">
<img src="https://img.shields.io/github/actions/workflow/status/aio-libs/aiohappyeyeballs/ci-cd.yml?branch=main&label=CI&logo=github&style=flat-square" alt="CI Status" >
</a>
<a href="https://aiohappyeyeballs.readthedocs.io">
<img src="https://img.shields.io/readthedocs/aiohappyeyeballs.svg?logo=read-the-docs&logoColor=fff&style=flat-square" alt="Documentation Status">
</a>
<a href="https://codecov.io/gh/aio-libs/aiohappyeyeballs">
<img src="https://img.shields.io/codecov/c/github/aio-libs/aiohappyeyeballs.svg?logo=codecov&logoColor=fff&style=flat-square" alt="Test coverage percentage">
</a>
</p>
<p align="center">
<a href="https://python-poetry.org/">
<img src="https://img.shields.io/badge/packaging-poetry-299bd7?style=flat-square&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAASCAYAAABrXO8xAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAJJSURBVHgBfZLPa1NBEMe/s7tNXoxW1KJQKaUHkXhQvHgW6UHQQ09CBS/6V3hKc/AP8CqCrUcpmop3Cx48eDB4yEECjVQrlZb80CRN8t6OM/teagVxYZi38+Yz853dJbzoMV3MM8cJUcLMSUKIE8AzQ2PieZzFxEJOHMOgMQQ+dUgSAckNXhapU/NMhDSWLs1B24A8sO1xrN4NECkcAC9ASkiIJc6k5TRiUDPhnyMMdhKc+Zx19l6SgyeW76BEONY9exVQMzKExGKwwPsCzza7KGSSWRWEQhyEaDXp6ZHEr416ygbiKYOd7TEWvvcQIeusHYMJGhTwF9y7sGnSwaWyFAiyoxzqW0PM/RjghPxF2pWReAowTEXnDh0xgcLs8l2YQmOrj3N7ByiqEoH0cARs4u78WgAVkoEDIDoOi3AkcLOHU60RIg5wC4ZuTC7FaHKQm8Hq1fQuSOBvX/sodmNJSB5geaF5CPIkUeecdMxieoRO5jz9bheL6/tXjrwCyX/UYBUcjCaWHljx1xiX6z9xEjkYAzbGVnB8pvLmyXm9ep+W8CmsSHQQY77Zx1zboxAV0w7ybMhQmfqdmmw3nEp1I0Z+FGO6M8LZdoyZnuzzBdjISicKRnpxzI9fPb+0oYXsNdyi+d3h9bm9MWYHFtPeIZfLwzmFDKy1ai3p+PDls1Llz4yyFpferxjnyjJDSEy9CaCx5m2cJPerq6Xm34eTrZt3PqxYO1XOwDYZrFlH1fWnpU38Y9HRze3lj0vOujZcXKuuXm3jP+s3KbZVra7y2EAAAAAASUVORK5CYII=" alt="Poetry">
</a>
<a href="https://github.com/astral-sh/ruff">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
</a>
<a href="https://github.com/pre-commit/pre-commit">
<img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=flat-square" alt="pre-commit">
</a>
</p>
<p align="center">
<a href="https://pypi.org/project/aiohappyeyeballs/">
<img src="https://img.shields.io/pypi/v/aiohappyeyeballs.svg?logo=python&logoColor=fff&style=flat-square" alt="PyPI Version">
</a>
<img src="https://img.shields.io/pypi/pyversions/aiohappyeyeballs.svg?style=flat-square&logo=python&amp;logoColor=fff" alt="Supported Python versions">
<img src="https://img.shields.io/pypi/l/aiohappyeyeballs.svg?style=flat-square" alt="License">
</p>
---
**Documentation**: <a href="https://aiohappyeyeballs.readthedocs.io" target="_blank">https://aiohappyeyeballs.readthedocs.io </a>
**Source Code**: <a href="https://github.com/aio-libs/aiohappyeyeballs" target="_blank">https://github.com/aio-libs/aiohappyeyeballs </a>
---
[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs)
([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html))
## Use case
This library exists to allow connecting with
[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs)
([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html))
when you
already have a list of addrinfo and not a DNS name.
The stdlib version of `loop.create_connection()`
will only work when you pass in an unresolved name which
is not a good fit when using DNS caching or resolving
names via another method such as `zeroconf`.
## Installation
Install this via pip (or your favourite package manager):
`pip install aiohappyeyeballs`
## License
[aiohappyeyeballs is licensed under the same terms as cpython itself.](https://github.com/python/cpython/blob/main/LICENSE)
## Example usage
```python
addr_infos = await loop.getaddrinfo("example.org", 80)
socket = await start_connection(addr_infos)
socket = await start_connection(addr_infos, local_addr_infos=local_addr_infos, happy_eyeballs_delay=0.2)
transport, protocol = await loop.create_connection(
MyProtocol, sock=socket, ...)
# Remove the first address for each family from addr_info
pop_addr_infos_interleave(addr_info, 1)
# Remove all matching address from addr_info
remove_addr_infos(addr_info, "dead::beef::")
# Convert a local_addr to local_addr_infos
local_addr_infos = addr_to_addr_infos(("127.0.0.1",0))
```
## Credits
This package contains code from cpython and is licensed under the same terms as cpython itself.
This package was created with
[Copier](https://copier.readthedocs.io/) and the
[browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
project template.

View File

@ -0,0 +1,16 @@
aiohappyeyeballs-2.6.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
aiohappyeyeballs-2.6.1.dist-info/LICENSE,sha256=Oy-B_iHRgcSZxZolbI4ZaEVdZonSaaqFNzv7avQdo78,13936
aiohappyeyeballs-2.6.1.dist-info/METADATA,sha256=NSXlhJwAfi380eEjAo7BQ4P_TVal9xi0qkyZWibMsVM,5915
aiohappyeyeballs-2.6.1.dist-info/RECORD,,
aiohappyeyeballs-2.6.1.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
aiohappyeyeballs/__init__.py,sha256=x7kktHEtaD9quBcWDJPuLeKyjuVAI-Jj14S9B_5hcTs,361
aiohappyeyeballs/__pycache__/__init__.cpython-312.pyc,,
aiohappyeyeballs/__pycache__/_staggered.cpython-312.pyc,,
aiohappyeyeballs/__pycache__/impl.cpython-312.pyc,,
aiohappyeyeballs/__pycache__/types.cpython-312.pyc,,
aiohappyeyeballs/__pycache__/utils.cpython-312.pyc,,
aiohappyeyeballs/_staggered.py,sha256=edfVowFx-P-ywJjIEF3MdPtEMVODujV6CeMYr65otac,6900
aiohappyeyeballs/impl.py,sha256=Dlcm2mTJ28ucrGnxkb_fo9CZzLAkOOBizOt7dreBbXE,9681
aiohappyeyeballs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
aiohappyeyeballs/types.py,sha256=YZJIAnyoV4Dz0WFtlaf_OyE4EW7Xus1z7aIfNI6tDDQ,425
aiohappyeyeballs/utils.py,sha256=on9GxIR0LhEfZu8P6Twi9hepX9zDanuZM20MWsb3xlQ,3028

View File

@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: poetry-core 2.1.1
Root-Is-Purelib: true
Tag: py3-none-any

View File

@ -0,0 +1,14 @@
__version__ = "2.6.1"
from .impl import start_connection
from .types import AddrInfoType, SocketFactoryType
from .utils import addr_to_addr_infos, pop_addr_infos_interleave, remove_addr_infos
__all__ = (
"AddrInfoType",
"SocketFactoryType",
"addr_to_addr_infos",
"pop_addr_infos_interleave",
"remove_addr_infos",
"start_connection",
)

Some files were not shown because too many files have changed in this diff Show More