Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin
This commit is contained in:
73
ali_oss.py
Normal file
73
ali_oss.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import alibabacloud_oss_v2 as oss
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from config import region, endpoint, bucket, file_url_pre, bucket_path
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(file_content: bytes, key: str):
|
||||||
|
credentials_provider = oss.credentials.EnvironmentVariableCredentialsProvider()
|
||||||
|
cfg = oss.config.load_default()
|
||||||
|
cfg.credentials_provider = credentials_provider
|
||||||
|
cfg.region = region
|
||||||
|
cfg.endpoint = endpoint
|
||||||
|
cfg.retry_max_attempts = 3
|
||||||
|
client = oss.Client(cfg)
|
||||||
|
|
||||||
|
result = client.put_object(
|
||||||
|
oss.PutObjectRequest(
|
||||||
|
bucket=bucket, # 存储空间名称
|
||||||
|
key=key, # 对象名称
|
||||||
|
body=file_content # 读取文件内容
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# print(result)
|
||||||
|
return file_url_pre + key
|
||||||
|
|
||||||
|
|
||||||
|
def upload_data_url(data_url: str, prefix: str = "history", key_hint: str = "") -> str:
|
||||||
|
"""
|
||||||
|
将 base64 data URL 上传到 OSS,返回图片链接
|
||||||
|
data_url: data:image/png;base64,xxxx 或 data:image/jpeg;base64,xxxx
|
||||||
|
prefix: OSS key 前缀
|
||||||
|
key_hint: 可选后缀避免重名,如 "_0", "_1"
|
||||||
|
"""
|
||||||
|
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
|
||||||
|
if not match:
|
||||||
|
raise ValueError('无效的 data URL 格式')
|
||||||
|
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
|
||||||
|
file_content = base64.b64decode(match.group(2))
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
key = f"{bucket_path}{prefix}/{ts}{key_hint}.{ext}"
|
||||||
|
return upload_file(file_content, key)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_data_urls(data_urls: list, prefix: str = "history") -> list:
|
||||||
|
"""批量上传 base64 图片到 OSS,返回图片链接列表"""
|
||||||
|
urls = []
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
for i, data_url in enumerate(data_urls or []):
|
||||||
|
if not data_url or not isinstance(data_url, str):
|
||||||
|
continue
|
||||||
|
if not data_url.startswith("http"):
|
||||||
|
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
|
||||||
|
file_content = base64.b64decode(match.group(2))
|
||||||
|
else:
|
||||||
|
file_content = requests.get(data_url).content
|
||||||
|
ext = "png"
|
||||||
|
key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}"
|
||||||
|
urls.append(upload_file(file_content, key))
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
# 脚本入口,当文件被直接运行时调用main函数
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with open("测试图片数据/IMG_2685.JPG", "rb") as f:
|
||||||
|
file_content = f.read()
|
||||||
|
upload_file(file_content,key=bucket_path+"test.png")
|
||||||
45
app.py
Normal file
45
app.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
卖相AI - Flask 后端
|
||||||
|
按功能拆分为蓝图:认证(auth)、主页面(main)、管理员(admin)、图片(image)、品牌(brand)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
from app_common import init_db, BASE_DIR
|
||||||
|
from blueprints.auth import auth_bp
|
||||||
|
from blueprints.main import main_bp
|
||||||
|
from blueprints.admin import admin_bp
|
||||||
|
from blueprints.image import image_bp
|
||||||
|
from blueprints.brand import brand_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__, template_folder=BASE_DIR, static_folder=BASE_DIR)
|
||||||
|
CORS(app)
|
||||||
|
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
|
||||||
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
|
||||||
|
|
||||||
|
# 注册蓝图(不设 url_prefix,保持原有 URL 路径不变,前端无需改动)
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(main_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(image_bp)
|
||||||
|
app.register_blueprint(brand_bp)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def run_app(host='127.0.0.1', port=5123):
|
||||||
|
init_db()
|
||||||
|
app.run(host=host, port=port, threaded=True, use_reloader=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run_app()
|
||||||
73
app/ali_oss.py
Normal file
73
app/ali_oss.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import alibabacloud_oss_v2 as oss
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from config import region, endpoint, bucket, file_url_pre, bucket_path
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(file_content: bytes, key: str):
|
||||||
|
credentials_provider = oss.credentials.EnvironmentVariableCredentialsProvider()
|
||||||
|
cfg = oss.config.load_default()
|
||||||
|
cfg.credentials_provider = credentials_provider
|
||||||
|
cfg.region = region
|
||||||
|
cfg.endpoint = endpoint
|
||||||
|
cfg.retry_max_attempts = 3
|
||||||
|
client = oss.Client(cfg)
|
||||||
|
|
||||||
|
result = client.put_object(
|
||||||
|
oss.PutObjectRequest(
|
||||||
|
bucket=bucket, # 存储空间名称
|
||||||
|
key=key, # 对象名称
|
||||||
|
body=file_content # 读取文件内容
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# print(result)
|
||||||
|
return file_url_pre + key
|
||||||
|
|
||||||
|
|
||||||
|
def upload_data_url(data_url: str, prefix: str = "history", key_hint: str = "") -> str:
|
||||||
|
"""
|
||||||
|
将 base64 data URL 上传到 OSS,返回图片链接
|
||||||
|
data_url: data:image/png;base64,xxxx 或 data:image/jpeg;base64,xxxx
|
||||||
|
prefix: OSS key 前缀
|
||||||
|
key_hint: 可选后缀避免重名,如 "_0", "_1"
|
||||||
|
"""
|
||||||
|
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
|
||||||
|
if not match:
|
||||||
|
raise ValueError('无效的 data URL 格式')
|
||||||
|
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
|
||||||
|
file_content = base64.b64decode(match.group(2))
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
key = f"{bucket_path}{prefix}/{ts}{key_hint}.{ext}"
|
||||||
|
return upload_file(file_content, key)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_data_urls(data_urls: list, prefix: str = "history") -> list:
|
||||||
|
"""批量上传 base64 图片到 OSS,返回图片链接列表"""
|
||||||
|
urls = []
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
for i, data_url in enumerate(data_urls or []):
|
||||||
|
if not data_url or not isinstance(data_url, str):
|
||||||
|
continue
|
||||||
|
if not data_url.startswith("http"):
|
||||||
|
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
|
||||||
|
file_content = base64.b64decode(match.group(2))
|
||||||
|
else:
|
||||||
|
file_content = requests.get(data_url).content
|
||||||
|
ext = "png"
|
||||||
|
key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}"
|
||||||
|
urls.append(upload_file(file_content, key))
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
# 脚本入口,当文件被直接运行时调用main函数
|
||||||
|
if __name__ == "__main__":
|
||||||
|
with open("测试图片数据/IMG_2685.JPG", "rb") as f:
|
||||||
|
file_content = f.read()
|
||||||
|
upload_file(file_content,key=bucket_path+"test.png")
|
||||||
45
app/app.py
Normal file
45
app/app.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
卖相AI - Flask 后端
|
||||||
|
按功能拆分为蓝图:认证(auth)、主页面(main)、管理员(admin)、图片(image)、品牌(brand)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
from app_common import init_db, BASE_DIR
|
||||||
|
from blueprints.auth import auth_bp
|
||||||
|
from blueprints.main import main_bp
|
||||||
|
from blueprints.admin import admin_bp
|
||||||
|
from blueprints.image import image_bp
|
||||||
|
from blueprints.brand import brand_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__, template_folder=BASE_DIR, static_folder=BASE_DIR)
|
||||||
|
CORS(app)
|
||||||
|
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
|
||||||
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
|
||||||
|
|
||||||
|
# 注册蓝图(不设 url_prefix,保持原有 URL 路径不变,前端无需改动)
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(main_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
app.register_blueprint(image_bp)
|
||||||
|
app.register_blueprint(brand_bp)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def run_app(host='127.0.0.1', port=5123):
|
||||||
|
init_db()
|
||||||
|
app.run(host=host, port=port, threaded=True, use_reloader=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run_app()
|
||||||
261
app/app_common.py
Normal file
261
app/app_common.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""
|
||||||
|
公共模块:数据库连接、初始化、会话校验、装饰器、模板渲染
|
||||||
|
供各蓝图复用
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string
|
||||||
|
|
||||||
|
from config import mysql_host, mysql_user, mysql_password, mysql_database
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
STATIC_DIR = os.path.join(BASE_DIR, 'static')
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
return pymysql.connect(
|
||||||
|
host=mysql_host,
|
||||||
|
user=mysql_user,
|
||||||
|
password=mysql_password,
|
||||||
|
database=mysql_database,
|
||||||
|
charset='utf8mb4',
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_html(template_name: str, **context):
|
||||||
|
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
|
||||||
|
path = os.path.join(BASE_DIR, "web_source", template_name)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return render_template(template_name, **context)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
try:
|
||||||
|
from html_crypto import decrypt
|
||||||
|
content = decrypt(raw).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
content = raw.decode("utf-8", errors="replace")
|
||||||
|
return render_template_string(content, **context)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_session_user_valid():
|
||||||
|
"""校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False"""
|
||||||
|
uid = session.get('user_id')
|
||||||
|
if not uid:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id FROM users WHERE id = %s", (uid,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
session.clear()
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
session.clear()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_admin_role():
|
||||||
|
"""获取当前登录用户的管理角色:super_admin / admin / None(非管理员)"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
|
||||||
|
(session['user_id'],)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or not row.get('is_admin'):
|
||||||
|
return None, None
|
||||||
|
return row.get('role') or ('super_admin' if row.get('created_by_id') is None else 'admin'), row
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not session.get('user_id') or not _is_session_user_valid():
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not session.get('user_id'):
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or not row.get('is_admin'):
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||||
|
return redirect(url_for('main.home'))
|
||||||
|
except Exception as e:
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
return redirect(url_for('main.home'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""初始化数据库表,若不存在则创建"""
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=mysql_host,
|
||||||
|
user=mysql_user,
|
||||||
|
password=mysql_password,
|
||||||
|
charset='utf8mb4'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(f"CREATE DATABASE IF NOT EXISTS `{mysql_database}` DEFAULT CHARSET utf8mb4")
|
||||||
|
cur.execute(f"USE `{mysql_database}`")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(256) NOT NULL,
|
||||||
|
is_admin TINYINT(1) DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS image_history (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
panel_type VARCHAR(64) DEFAULT '',
|
||||||
|
original_urls JSON,
|
||||||
|
params JSON,
|
||||||
|
result_urls JSON,
|
||||||
|
long_image_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
INDEX idx_user_created (user_id, created_at DESC)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE image_history ADD COLUMN long_image_url VARCHAR(1024) DEFAULT NULL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE users ADD COLUMN machine VARCHAR(64) DEFAULT NULL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'normal'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE users ADD COLUMN created_by_id INT NULL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS brand_crawl_tasks (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
file_paths JSON NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行',
|
||||||
|
result_paths JSON NULL,
|
||||||
|
error_message TEXT NULL,
|
||||||
|
progress_current INT DEFAULT 0,
|
||||||
|
progress_total INT DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_status (user_id, status)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_current INT DEFAULT 0")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_total INT DEFAULT 0")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN `desc` VARCHAR(500) NULL COMMENT '上传文件名描述'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN strategy VARCHAR(20) DEFAULT 'Terms' COMMENT '品牌匹配方式: Terms/Simple'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS columns (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL COMMENT '栏目名',
|
||||||
|
column_key VARCHAR(64) NOT NULL COMMENT '栏目标识',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_column_key (column_key)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_column_permission (
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
column_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, column_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
cur.execute("UPDATE users SET role = 'normal' WHERE (role IS NULL OR role = '') AND (is_admin = 0 OR is_admin IS NULL)")
|
||||||
|
cur.execute("SELECT MIN(id) AS mid FROM users WHERE is_admin = 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row.get('mid'):
|
||||||
|
mid = row['mid']
|
||||||
|
cur.execute("UPDATE users SET role = 'super_admin' WHERE id = %s", (mid,))
|
||||||
|
cur.execute("UPDATE users SET role = 'admin' WHERE is_admin = 1 AND id != %s", (mid,))
|
||||||
|
cur.execute("UPDATE users SET created_by_id = %s WHERE role = 'admin' AND (created_by_id IS NULL)", (mid,))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
_create_initial_admin()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_initial_admin():
|
||||||
|
"""若没有任何管理员,则创建默认超级管理员(首次启动时,仅一个)"""
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1")
|
||||||
|
if cur.fetchone():
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
admin_user = os.environ.get('ADMIN_USER', 'admin')
|
||||||
|
admin_pwd = os.environ.get('ADMIN_PASSWORD', 'admin123')
|
||||||
|
pwd_hash = generate_password_hash(admin_pwd, method='pbkdf2:sha256')
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, is_admin, role) VALUES (%s, %s, 1, 'super_admin')",
|
||||||
|
(admin_user, pwd_hash)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
46
app/config.py
Normal file
46
app/config.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
base_url = "https://api.coze.cn/v1"
|
||||||
|
coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgqZ5"
|
||||||
|
workflow_id = "7608812635877900322"
|
||||||
|
STITCH_WORKFLOW_ID = "7608813873483300907"
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
_base_url = os.getenv("base_url","http://159.75.121.33:15124")
|
||||||
|
|
||||||
|
# MySQL 配置
|
||||||
|
mysql_host = os.getenv("mysql_host")
|
||||||
|
mysql_user = os.getenv("mysql_user")
|
||||||
|
mysql_password = os.getenv("mysql_password","WTFrb5y6hNLz6hNy")
|
||||||
|
mysql_database = os.getenv("mysql_database","aiimage")
|
||||||
|
proxy_url = os.getenv("proxy_url")
|
||||||
|
proxy_mode = int(os.getenv("proxy_mode",1))
|
||||||
|
|
||||||
|
client_name=os.getenv("client_name") + ".exe"
|
||||||
|
|
||||||
|
cache_path = "./user_data"
|
||||||
|
|
||||||
|
region = "cn-hangzhou"
|
||||||
|
endpoint = "oss-cn-hangzhou.aliyuncs.com"
|
||||||
|
bucket = "nanri-ai-images"
|
||||||
|
accessKeyId = "LTAI5tNpyvzMNz9f2dHarsm8"
|
||||||
|
accessKeySecret = "bQSZnFH455i8tzyOgeahJmUzwmhynz"
|
||||||
|
bucket_path = "nanri-image/"
|
||||||
|
|
||||||
|
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
||||||
|
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
||||||
|
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||||
|
|
||||||
|
|
||||||
|
debug = True
|
||||||
|
version = "1.0.3"
|
||||||
|
APP_UPDATE_URL = f"{_base_url}/api/version/latest" # 检测更新接口地址,返回 { "file_url": "...", "version": "x.x.x" }
|
||||||
|
os.environ['APP_VERSION'] = version
|
||||||
|
os.environ['APP_UPDATE_URL'] = APP_UPDATE_URL
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
92
app/coze.py
Normal file
92
app/coze.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
from config import base_url, coze_token
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(file_path,_coze_token =coze_token):
|
||||||
|
url = f"{base_url}/files/upload"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {_coze_token}"
|
||||||
|
}
|
||||||
|
print(file_path)
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {
|
||||||
|
'file': f
|
||||||
|
}
|
||||||
|
# with open(f"{time.time()".replace(".","_")+".png","wb") as file:
|
||||||
|
# file.write(f.read())
|
||||||
|
# allow_redirects=True 模拟 curl 的 --location,默认即为 True
|
||||||
|
response = requests.post(url, headers=headers, files=files, allow_redirects=True)
|
||||||
|
data = response.json()
|
||||||
|
print("上传图片->>",data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def workflow_run(workflow_id, parameters,_coze_token = coze_token,is_async=True):
|
||||||
|
url = f"{base_url}/workflow/run"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {_coze_token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 请求体(JSON 格式)
|
||||||
|
payload = {
|
||||||
|
"parameters": parameters,
|
||||||
|
# "parameters": {
|
||||||
|
# "name": "包包",
|
||||||
|
# "ratio": "3:4",
|
||||||
|
# "menu": 3,
|
||||||
|
# "resolution": "2K",
|
||||||
|
# "count": 1,
|
||||||
|
# "desc": "1、一个中国美女手上挎着这个包,2、站在商场内",
|
||||||
|
# "language": "中文",
|
||||||
|
# "style": "极简高级"
|
||||||
|
# },
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"is_async" : is_async,
|
||||||
|
}
|
||||||
|
# 发送 POST 请求(使用 json 参数自动序列化并设置 Content-Type)
|
||||||
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
print("图片生成参数->>",payload)
|
||||||
|
print("图片生成->>",data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def query_result(workflow_id, execute_id,_coze_token=coze_token):
|
||||||
|
url = f"{base_url}/workflows/{workflow_id}/run_histories/{execute_id}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {_coze_token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
data = response.json()
|
||||||
|
print(f"【{execute_id}】结果查询->>",data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
from config import workflow_id
|
||||||
|
|
||||||
|
parameters = {
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"file_id": "7612667079497613350",
|
||||||
|
|
||||||
|
},{
|
||||||
|
"file_id": "7612667042990768174",
|
||||||
|
},{
|
||||||
|
"file_id": "7612667111332577332",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
# resp = workflow_run("7607680760402100258",parameters,_coze_token="sat_ZVNLR9Om54A3iMlJWAasHF9kbtDZnR3BjwjAxRwe8x7igEW446y5ROyVlW1UlpVX")
|
||||||
|
# print(resp)
|
||||||
|
|
||||||
|
# resp = query_result("7607680760402100258","7612668958797447187",_coze_token="sat_ZVNLR9Om54A3iMlJWAasHF9kbtDZnR3BjwjAxRwe8x7igEW446y5ROyVlW1UlpVX")
|
||||||
|
# print(resp)
|
||||||
|
resp = upload_file("D:\私单交付\maixiang_AI\测试图片数据\IMG_2686.JPG",_coze_token="sat_ZVNLR9Om54A3iMlJWAasHF9kbtDZnR3BjwjAxRwe8x7igEW446y5ROyVlW1UlpVX")
|
||||||
|
print(resp)
|
||||||
261
app/generate_api.py
Normal file
261
app/generate_api.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""
|
||||||
|
生成图片 API - 供 pywebview 前端调用
|
||||||
|
流程:上传图片 -> workflow_run -> 轮询 query_result -> 解析返回图片 URL
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from coze import upload_file, workflow_run, query_result
|
||||||
|
from config import workflow_id
|
||||||
|
from ali_oss import upload_data_urls as oss_upload_data_urls
|
||||||
|
|
||||||
|
|
||||||
|
def _data_url_to_temp_file(data_url: str, allow_video: bool = False) -> str:
|
||||||
|
"""将 base64 data URL 转为临时文件路径"""
|
||||||
|
# data:image/png;base64,xxxx 或 data:video/mp4;base64,xxxx
|
||||||
|
if allow_video:
|
||||||
|
match = re.match(r'data:(?:image|video)/(\w+);base64,(.+)', data_url)
|
||||||
|
else:
|
||||||
|
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
|
||||||
|
if not match:
|
||||||
|
raise ValueError('无效的 data URL 格式')
|
||||||
|
mime = match.group(1).lower()
|
||||||
|
ext_map = {'png': 'png', 'webp': 'png', 'jpeg': 'jpg', 'jpg': 'jpg', 'mp4': 'mp4', 'webm': 'webm'}
|
||||||
|
ext = ext_map.get(mime, 'jpg')
|
||||||
|
data = base64.b64decode(match.group(2))
|
||||||
|
fd, path = tempfile.mkstemp(suffix=f'.{ext}')
|
||||||
|
try:
|
||||||
|
os.write(fd, data)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_video(video_data_url: str) -> str:
|
||||||
|
"""上传视频,返回 file_id"""
|
||||||
|
if not video_data_url or not isinstance(video_data_url, str):
|
||||||
|
raise ValueError('无效的视频数据')
|
||||||
|
path = _data_url_to_temp_file(video_data_url, allow_video=True)
|
||||||
|
try:
|
||||||
|
resp = upload_file(path)
|
||||||
|
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
|
||||||
|
return resp['data']['id']
|
||||||
|
raise RuntimeError(f'视频上传失败: {resp.get("msg", resp)}')
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_images(image_data_urls: list) -> list:
|
||||||
|
"""上传多张图片,返回 file_id 列表"""
|
||||||
|
file_ids = []
|
||||||
|
temp_paths = []
|
||||||
|
try:
|
||||||
|
for data_url in (image_data_urls or []):
|
||||||
|
if not data_url or not isinstance(data_url, str):
|
||||||
|
continue
|
||||||
|
if data_url.startswith("http"):
|
||||||
|
fd, path = tempfile.mkstemp(suffix=f'.png')
|
||||||
|
data_content = requests.get(data_url).content
|
||||||
|
os.write(fd, data_content)
|
||||||
|
else:
|
||||||
|
path = _data_url_to_temp_file(data_url)
|
||||||
|
temp_paths.append(path)
|
||||||
|
resp = upload_file(path)
|
||||||
|
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
|
||||||
|
file_ids.append(resp['data']['id'])
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f'上传失败: {resp.get("msg", resp)}')
|
||||||
|
return file_ids
|
||||||
|
finally:
|
||||||
|
for p in temp_paths:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_output(output_str: str) -> list:
|
||||||
|
"""解析 query_result 中的 output,提取图片 URL 列表"""
|
||||||
|
try:
|
||||||
|
outer = json.loads(output_str)
|
||||||
|
inner_str = outer.get('Output', '{}')
|
||||||
|
inner = json.loads(inner_str)
|
||||||
|
data_str = inner.get('data', '[]')
|
||||||
|
data = json.loads(data_str)
|
||||||
|
urls = data.get("images")
|
||||||
|
long_image_url = data.get("merged_image_url")
|
||||||
|
return urls if isinstance(urls, list) else [],long_image_url
|
||||||
|
except Exception:
|
||||||
|
return [],None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_output_text(output_str: str) -> list:
|
||||||
|
"""解析 query_result 中的 output,提取图片 URL 列表"""
|
||||||
|
try:
|
||||||
|
outer = json.loads(output_str)
|
||||||
|
inner_str = outer.get('Output', '{}')
|
||||||
|
inner = json.loads(inner_str)
|
||||||
|
data_str = inner.get('data', '')
|
||||||
|
if isinstance(data_str,str):
|
||||||
|
data = json.loads(data_str).get("reverse_prompt")
|
||||||
|
return [data]
|
||||||
|
pattern = r'【?图像 \d+】.*?(?=【?图像 \d+】|$)'
|
||||||
|
segments = re.findall(pattern, data_str, re.DOTALL)
|
||||||
|
return segments
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_until_done(wf_id: str, execute_id: str, interval: float =10, timeout: int = 600*2, is_text: bool = False) -> list:
|
||||||
|
"""轮询直到成功或超时,返回图片 URL 列表"""
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
resp = query_result(wf_id, execute_id)
|
||||||
|
if resp.get('code') != 0:
|
||||||
|
raise RuntimeError(f'查询失败: {resp.get("msg", resp)}')
|
||||||
|
items = resp.get('data') or []
|
||||||
|
if not items:
|
||||||
|
time.sleep(interval)
|
||||||
|
continue
|
||||||
|
item = items[0]
|
||||||
|
status = item.get('execute_status', '')
|
||||||
|
if status == 'Success':
|
||||||
|
output = item.get('output', '{}')
|
||||||
|
if is_text:
|
||||||
|
return _parse_output_text(output)
|
||||||
|
return _parse_output(output)
|
||||||
|
if status and status not in ('Running', 'Pending', ''):
|
||||||
|
raise RuntimeError(f'执行失败: {status}')
|
||||||
|
time.sleep(interval)
|
||||||
|
raise RuntimeError('生成超时')
|
||||||
|
|
||||||
|
|
||||||
|
def _res_to_2k(res: str) -> str:
|
||||||
|
"""统一分辨率为 2K/4K"""
|
||||||
|
r = (res or '2k').strip().upper()
|
||||||
|
return '4K' if r == '4K' else '2K'
|
||||||
|
|
||||||
|
|
||||||
|
def generate(params: dict) -> dict:
|
||||||
|
"""
|
||||||
|
生成图片
|
||||||
|
params: {
|
||||||
|
menu: int, # 1=图片反推 2=图片编辑 3=随机海报 4=克隆海报 5=服饰穿搭
|
||||||
|
prompt: str, # 用户自定义指令 (menu=1 必填)
|
||||||
|
ref_images: list, # base64 data URLs - 参考图
|
||||||
|
video: str, # base64 data URL - 视频 (menu=1 可选,仅支持1个)
|
||||||
|
model_images: list, # base64 data URLs - 多模特图 (menu!=1,最多5张)
|
||||||
|
name: str, desc: str, ratio: str, resolution: str, count: int,
|
||||||
|
language: str, style: str, batch_prompt: list, brand_name: str,
|
||||||
|
Ingredients: str, activity: str, mode: str, texts: list,
|
||||||
|
proc_images: list, layout_image: str,
|
||||||
|
}
|
||||||
|
返回: { success: bool, urls: list, prompts: list, error: str }
|
||||||
|
"""
|
||||||
|
original_urls = []
|
||||||
|
api_key = (params.get('api_key') or '').strip()
|
||||||
|
try:
|
||||||
|
menu = int(params.get('menu', 2))
|
||||||
|
ref_data = params.get('ref_images') or []
|
||||||
|
proc_data = params.get('proc_images') or []
|
||||||
|
layout_data = params.get('layout_image')
|
||||||
|
video_data = params.get('video')
|
||||||
|
model_data = params.get('model_images') or []
|
||||||
|
|
||||||
|
# menu 1: 反推词,简化参数
|
||||||
|
if menu == 1:
|
||||||
|
base_params = {'menu': 1, 'prompt': str(params.get('prompt', '')).strip() or ''}
|
||||||
|
|
||||||
|
ref_ids = _upload_images(ref_data) if ref_data else []
|
||||||
|
if ref_ids:
|
||||||
|
base_params['ref_images'] = [{'file_id': fid} for fid in ref_ids]
|
||||||
|
if video_data:
|
||||||
|
try:
|
||||||
|
vid = _upload_video(video_data)
|
||||||
|
base_params['video'] = {'file_id': vid}
|
||||||
|
except Exception as ve:
|
||||||
|
return {'success': False, 'urls': [], 'prompts': [], 'error': f'视频上传失败: {ve}'}
|
||||||
|
base_params = {k: v for k, v in base_params.items() if v is not None and v != ''}
|
||||||
|
base_params["api_key"] = api_key
|
||||||
|
resp = workflow_run(workflow_id, base_params)
|
||||||
|
if resp.get('code') != 0:
|
||||||
|
return {'success': False, 'urls': [], 'prompts': [], 'error': resp.get('msg', str(resp))}
|
||||||
|
execute_id = resp.get('execute_id')
|
||||||
|
if not execute_id:
|
||||||
|
return {'success': False, 'urls': [], 'prompts': [], 'error': '未返回 execute_id'}
|
||||||
|
prompts = _poll_until_done(workflow_id, str(execute_id), is_text=True)
|
||||||
|
# menu 1 返回提示词列表,统一转为字符串
|
||||||
|
# prompts = [str(x) for x in result_list] if result_list else []
|
||||||
|
# prompts = [prompts]
|
||||||
|
return {'success': True, 'urls': [], 'prompts': prompts, 'error': ''}
|
||||||
|
|
||||||
|
# menu != 1: 原有逻辑
|
||||||
|
all_ref = list(ref_data)
|
||||||
|
if layout_data:
|
||||||
|
all_ref = [layout_data] + list(ref_data)
|
||||||
|
all_originals = list(all_ref) + list(proc_data) + list(model_data)
|
||||||
|
|
||||||
|
if all_originals:
|
||||||
|
try:
|
||||||
|
original_urls = oss_upload_data_urls(all_originals, prefix="originals")
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
pass
|
||||||
|
|
||||||
|
ref_ids = _upload_images(all_ref) if all_ref else []
|
||||||
|
proc_ids = _upload_images(proc_data) if proc_data else []
|
||||||
|
model_ids = _upload_images(model_data) if model_data else []
|
||||||
|
|
||||||
|
res = _res_to_2k(params.get('resolution', '2K'))
|
||||||
|
|
||||||
|
base_params = {
|
||||||
|
'name': str(params.get('name', '')).strip(),
|
||||||
|
'ratio': str(params.get('ratio', '')).strip(),
|
||||||
|
'menu': menu,
|
||||||
|
'resolution': res,
|
||||||
|
'count': int(params.get('count', 1)),
|
||||||
|
'desc': str(params.get('desc', '')).strip(),
|
||||||
|
'language': str(params.get('language', '中文')).strip() or '中文',
|
||||||
|
'style': str(params.get('style', '')).strip(),
|
||||||
|
'prompt': str(params.get('prompt', '')).strip(),
|
||||||
|
'batch_prompt': params.get('batch_prompt') or [],
|
||||||
|
'brand_name': str(params.get('brand_name', '')).strip(),
|
||||||
|
'Ingredients': str(params.get('Ingredients', '')).strip(),
|
||||||
|
'activity': str(params.get('activity', '')).strip(),
|
||||||
|
'mode': str(params.get('mode', '1')).strip() or '1',
|
||||||
|
'texts': params.get('text') or [],
|
||||||
|
"api_key" : api_key
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref_ids:
|
||||||
|
base_params['ref_images'] = [{'file_id': fid} for fid in ref_ids]
|
||||||
|
if proc_ids:
|
||||||
|
base_params['proc_images'] = [{'file_id': fid} for fid in proc_ids]
|
||||||
|
if model_ids:
|
||||||
|
base_params['model_images'] = [{'file_id': fid} for fid in model_ids]
|
||||||
|
base_params = {k: v for k, v in base_params.items() if v is not None and v != ''}
|
||||||
|
|
||||||
|
resp = workflow_run(workflow_id, base_params)
|
||||||
|
if resp.get('code') != 0:
|
||||||
|
return {'success': False, 'urls': [], 'error': resp.get('msg', str(resp))}
|
||||||
|
execute_id = resp.get('execute_id')
|
||||||
|
if not execute_id:
|
||||||
|
return {'success': False, 'urls': [], 'error': '未返回 execute_id'}
|
||||||
|
|
||||||
|
urls,long_image_url = _poll_until_done(workflow_id, str(execute_id))
|
||||||
|
return {'success': True, 'urls': urls, 'original_urls': original_urls, 'error': '',"long_image_url":long_image_url}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'urls': [], 'prompts': [], 'original_urls': [], 'error': str(e),"long_image_url":""}
|
||||||
|
|
||||||
|
|
||||||
68
app/html_crypto.py
Normal file
68
app/html_crypto.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
HTML 模板加密/解密模块
|
||||||
|
使用 AES (Fernet) 对 HTML 资源进行加密存储,运行时解密后渲染
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet_key() -> bytes:
|
||||||
|
"""从环境变量 HTML_ENCRYPT_KEY 派生 Fernet 密钥,若未设置则使用默认开发密钥"""
|
||||||
|
raw = os.environ.get(
|
||||||
|
"HTML_ENCRYPT_KEY",
|
||||||
|
"maixiang_html_encrypt_default_key_change_in_production",
|
||||||
|
)
|
||||||
|
# 使用 PBKDF2 派生 32 字节密钥,再转为 Fernet 所需的 base64url
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=b"maixiang_html_salt",
|
||||||
|
iterations=100000,
|
||||||
|
)
|
||||||
|
key_bytes = kdf.derive(raw.encode("utf-8"))
|
||||||
|
return base64.urlsafe_b64encode(key_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(plain_data: bytes) -> bytes:
|
||||||
|
"""加密原始数据,返回密文(bytes)"""
|
||||||
|
key = _get_fernet_key()
|
||||||
|
f = Fernet(key)
|
||||||
|
return f.encrypt(plain_data)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(encrypted_data: bytes) -> bytes:
|
||||||
|
"""解密数据,返回明文(bytes)"""
|
||||||
|
key = _get_fernet_key()
|
||||||
|
f = Fernet(key)
|
||||||
|
return f.decrypt(encrypted_data)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_file(path: str) -> None:
|
||||||
|
"""就地加密文件:读取 UTF-8 内容,加密后写回同一路径"""
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
plain = f.read()
|
||||||
|
encrypted = encrypt(plain)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(encrypted)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_file(path: str) -> None:
|
||||||
|
"""就地解密文件:读取密文,解密后以 UTF-8 写回同一路径"""
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
encrypted = f.read()
|
||||||
|
plain = decrypt(encrypted)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(plain)
|
||||||
|
|
||||||
|
|
||||||
|
def is_encrypted(data: bytes) -> bool:
|
||||||
|
"""简单启发式:Fernet 密文以 b'gAAAAA' 开头(base64 编码后)"""
|
||||||
|
try:
|
||||||
|
return data[:7] == b"gAAAAAB"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
344
app/main.py
Normal file
344
app/main.py
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
"""
|
||||||
|
基于 pywebview 实现的跨平台 UI,需先登录方可使用
|
||||||
|
"""
|
||||||
|
from config import cache_path,debug,version
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
os.makedirs(cache_path,exist_ok=True)
|
||||||
|
if not debug:
|
||||||
|
today = datetime.datetime.now().strftime("%Y_%m_%d")
|
||||||
|
sys.stdout = open(os.path.join(cache_path,f'{today}.log'), 'a',buffering=1)
|
||||||
|
sys.stderr = sys.stdout
|
||||||
|
|
||||||
|
import webview
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
with open("version.txt","w",encoding="utf-8") as file:
|
||||||
|
file.write(json.dumps({"version":version}))
|
||||||
|
|
||||||
|
|
||||||
|
# 获取当前脚本所在目录
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
APP_URL = "http://127.0.0.1:5123"
|
||||||
|
PORT = 5123
|
||||||
|
|
||||||
|
|
||||||
|
def generate_images(params):
|
||||||
|
"""供前端调用的生成图片接口"""
|
||||||
|
from generate_api import generate
|
||||||
|
return generate(params)
|
||||||
|
|
||||||
|
|
||||||
|
class WindowAPI:
|
||||||
|
"""暴露给 JavaScript 的窗口控制 API"""
|
||||||
|
|
||||||
|
def __init__(self, window):
|
||||||
|
self._window = window
|
||||||
|
self._is_maximized = False
|
||||||
|
window.events.maximized += lambda _: setattr(self, '_is_maximized', True)
|
||||||
|
window.events.restored += lambda _: setattr(self, '_is_maximized', False)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
sys.exit(1)
|
||||||
|
self._window.destroy()
|
||||||
|
|
||||||
|
def minimize(self):
|
||||||
|
self._window.minimize()
|
||||||
|
|
||||||
|
def maximize(self):
|
||||||
|
self._window.maximize()
|
||||||
|
|
||||||
|
def toggle_maximize(self):
|
||||||
|
if self._is_maximized:
|
||||||
|
self._window.restore()
|
||||||
|
else:
|
||||||
|
self._window.maximize()
|
||||||
|
|
||||||
|
def save_image(self, url_or_data, filename='image.png'):
|
||||||
|
"""弹窗选择保存位置并下载图片。url_or_data 可为 http(s) 链接或 data: base64"""
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
result = self._window.create_file_dialog(
|
||||||
|
webview.SAVE_DIALOG,
|
||||||
|
save_filename=filename,
|
||||||
|
file_types=('图片文件 (*.png;*.jpg;*.jpeg)', '所有文件 (*.*)')
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return {'success': False, 'error': '用户取消'}
|
||||||
|
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||||
|
try:
|
||||||
|
if url_or_data.startswith('data:'):
|
||||||
|
match = re.match(r'data:image/\w+;base64,(.+)', url_or_data)
|
||||||
|
if not match:
|
||||||
|
print(url_or_data)
|
||||||
|
print('无效的 data URL')
|
||||||
|
return {'success': False, 'error': '无效的 data URL'}
|
||||||
|
data = base64.b64decode(match.group(1))
|
||||||
|
else:
|
||||||
|
import requests
|
||||||
|
resp = requests.get(url_or_data, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.content
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
return {'success': True, 'path': path}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def save_image_to_folder(self, url_or_data, dir_path, filename='image.png'):
|
||||||
|
"""将图片保存到指定文件夹。url_or_data 可为 http(s) 链接或 data: base64"""
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
path = os.path.join(dir_path, filename)
|
||||||
|
try:
|
||||||
|
if url_or_data.startswith('data:'):
|
||||||
|
match = re.match(r'data:image/\w+;base64,(.+)', url_or_data)
|
||||||
|
if not match:
|
||||||
|
return {'success': False, 'error': '无效的 data URL'}
|
||||||
|
data = base64.b64decode(match.group(1))
|
||||||
|
else:
|
||||||
|
import requests
|
||||||
|
resp = requests.get(url_or_data, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.content
|
||||||
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
return {'success': True, 'path': path}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def select_folder(self):
|
||||||
|
# 此方法会在 JavaScript 中被调用,返回选择的文件夹路径
|
||||||
|
result = webview.windows[0].create_file_dialog(
|
||||||
|
webview.FOLDER_DIALOG, # 指定为文件夹对话框
|
||||||
|
allow_multiple=False, # 是否允许多选
|
||||||
|
directory='' # 初始目录
|
||||||
|
)
|
||||||
|
# create_file_dialog 返回的是列表(多选时)或 None
|
||||||
|
return result[0] if result else ''
|
||||||
|
|
||||||
|
def select_brand_xlsx_files(self):
|
||||||
|
"""品牌爬虫:选择 .xlsx 文件,允许多选,返回路径列表"""
|
||||||
|
result = webview.windows[0].create_file_dialog(
|
||||||
|
webview.OPEN_DIALOG,
|
||||||
|
allow_multiple=True,
|
||||||
|
file_types=('Excel 文件 (*.xlsx)', '所有文件 (*.*)')
|
||||||
|
)
|
||||||
|
print("选择的excel 文件",result)
|
||||||
|
if not result:
|
||||||
|
return []
|
||||||
|
return result if isinstance(result, list) or isinstance(result, tuple) else [result]
|
||||||
|
|
||||||
|
def select_brand_folder(self):
|
||||||
|
"""品牌爬虫:选择文件夹,返回文件夹路径(前端再请求后端展开该目录下所有 xlsx)"""
|
||||||
|
result = webview.windows[0].create_file_dialog(
|
||||||
|
webview.FOLDER_DIALOG,
|
||||||
|
allow_multiple=False,
|
||||||
|
directory=''
|
||||||
|
)
|
||||||
|
return result[0] if result else ''
|
||||||
|
|
||||||
|
def save_file_from_url(self, url, default_filename='download.zip'):
|
||||||
|
"""弹窗选择保存位置,从 url 下载文件并保存。用于品牌任务结果 zip 等。"""
|
||||||
|
if not url or not url.strip():
|
||||||
|
return {'success': False, 'error': '下载地址为空'}
|
||||||
|
result = self._window.create_file_dialog(
|
||||||
|
webview.SAVE_DIALOG,
|
||||||
|
save_filename=default_filename,
|
||||||
|
file_types=('ZIP 压缩包 (*.zip)', '所有文件 (*.*)')
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return {'success': False, 'error': '用户取消'}
|
||||||
|
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
resp = requests.get(url.strip(), timeout=120, stream=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
for chunk in resp.iter_content(chunk_size=65536):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
return {'success': True, 'path': path}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def save_template_xlsx(self):
|
||||||
|
"""弹窗选择保存位置,将品牌文档格式模板 xlsx 保存到用户选择的位置。"""
|
||||||
|
import shutil
|
||||||
|
template_name = '品牌文档格式_模板.xlsx'
|
||||||
|
template_path = os.path.join(BASE_DIR, 'static', template_name)
|
||||||
|
if not os.path.isfile(template_path):
|
||||||
|
return {'success': False, 'error': '模板文件不存在'}
|
||||||
|
result = self._window.create_file_dialog(
|
||||||
|
webview.SAVE_DIALOG,
|
||||||
|
save_filename=template_name,
|
||||||
|
file_types=('Excel 文件 (*.xlsx)', '所有文件 (*.*)')
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return {'success': False, 'error': '用户取消'}
|
||||||
|
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||||
|
try:
|
||||||
|
shutil.copy2(template_path, path)
|
||||||
|
return {'success': True, 'path': path}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def save_template_zip(self):
|
||||||
|
"""弹窗选择保存位置,将品牌文档格式模板 xlsx 保存到用户选择的位置。"""
|
||||||
|
import shutil
|
||||||
|
template_name = '模板2-以文件夹方式上传.zip'
|
||||||
|
template_path = os.path.join(BASE_DIR, 'static', template_name)
|
||||||
|
if not os.path.isfile(template_path):
|
||||||
|
return {'success': False, 'error': '模板文件不存在'}
|
||||||
|
result = self._window.create_file_dialog(
|
||||||
|
webview.SAVE_DIALOG,
|
||||||
|
save_filename=template_name,
|
||||||
|
file_types=('ZIP 文件 (*.zip)', '所有文件 (*.*)')
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return {'success': False, 'error': '用户取消'}
|
||||||
|
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||||
|
try:
|
||||||
|
shutil.copy2(template_path, path)
|
||||||
|
return {'success': True, 'path': path}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _select_folder_win32():
|
||||||
|
"""Windows: 用 ctypes 调用系统文件夹选择对话框,打包成 exe 后也能正常弹窗(不依赖 tkinter)"""
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
BIF_RETURNONLYFSDIRS = 0x00000001
|
||||||
|
BIF_NEWDIALOGSTYLE = 0x00000040
|
||||||
|
MAX_PATH = 260
|
||||||
|
shell32 = ctypes.windll.shell32 # type: ignore[attr-defined]
|
||||||
|
# BROWSEINFOW
|
||||||
|
class BROWSEINFOW(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("hwndOwner", wintypes.HWND),
|
||||||
|
("pidlRoot", ctypes.c_void_p),
|
||||||
|
("pszDisplayName", ctypes.c_wchar_p),
|
||||||
|
("lpszTitle", ctypes.c_wchar_p),
|
||||||
|
("ulFlags", wintypes.UINT),
|
||||||
|
("lpfn", ctypes.c_void_p),
|
||||||
|
("lParam", wintypes.LPARAM),
|
||||||
|
("iImage", ctypes.c_int),
|
||||||
|
]
|
||||||
|
display_name_buf = ctypes.create_unicode_buffer(MAX_PATH)
|
||||||
|
bi = BROWSEINFOW()
|
||||||
|
bi.hwndOwner = 0
|
||||||
|
bi.pidlRoot = 0
|
||||||
|
bi.pszDisplayName = ctypes.cast(display_name_buf, ctypes.c_wchar_p)
|
||||||
|
bi.lpszTitle = "选择保存图片的文件夹"
|
||||||
|
bi.ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE
|
||||||
|
bi.lpfn = 0
|
||||||
|
bi.lParam = 0
|
||||||
|
bi.iImage = 0
|
||||||
|
pidl = shell32.SHBrowseForFolderW(ctypes.byref(bi))
|
||||||
|
if not pidl:
|
||||||
|
return ''
|
||||||
|
path_buf = ctypes.create_unicode_buffer(MAX_PATH)
|
||||||
|
if not shell32.SHGetPathFromIDListW(pidl, path_buf):
|
||||||
|
return ''
|
||||||
|
ole32 = ctypes.windll.ole32 # type: ignore[attr-defined]
|
||||||
|
ole32.CoTaskMemFree(pidl)
|
||||||
|
return path_buf.value or ''
|
||||||
|
except Exception as e:
|
||||||
|
print("Windows 文件夹选择失败:", e)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
# def _select_folder_tk():
|
||||||
|
# """使用 tkinter 选择文件夹(在非 Windows 或作为后备)"""
|
||||||
|
# try:
|
||||||
|
#
|
||||||
|
# root = tkinter.Tk()
|
||||||
|
# root.withdraw()
|
||||||
|
# root.attributes('-topmost', True)
|
||||||
|
# path = filedialog.askdirectory(title='选择保存图片的文件夹')
|
||||||
|
# try:
|
||||||
|
# root.destroy()
|
||||||
|
# except Exception:
|
||||||
|
# pass
|
||||||
|
# return path if path else ''
|
||||||
|
# except Exception as e:
|
||||||
|
# print("tkinter 选择文件夹失败:", e)
|
||||||
|
# return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _select_folder_tk():
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog
|
||||||
|
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
root.attributes('-topmost', True)
|
||||||
|
|
||||||
|
# 使用 after 确保 askdirectory 在主循环启动后执行
|
||||||
|
def ask():
|
||||||
|
nonlocal path
|
||||||
|
path = filedialog.askdirectory(title='选择保存图片的文件夹')
|
||||||
|
root.quit() # 关闭主循环
|
||||||
|
|
||||||
|
path = ''
|
||||||
|
root.after(100, ask)
|
||||||
|
root.mainloop() # 启动主循环,直到 root.quit() 被调用
|
||||||
|
try:
|
||||||
|
root.destroy()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def select_folder():
|
||||||
|
"""弹窗选择文件夹,返回路径(空字符串表示取消)。Windows 打包 exe 时优先用系统 API 避免 tkinter 不弹窗。"""
|
||||||
|
# if sys.platform == 'win32':
|
||||||
|
# path = _select_folder_win32()
|
||||||
|
# if path:
|
||||||
|
# return path
|
||||||
|
return _select_folder_tk()
|
||||||
|
|
||||||
|
|
||||||
|
def start_flask():
|
||||||
|
from app import run_app
|
||||||
|
run_app(host='127.0.0.1', port=PORT)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(cache_path):
|
||||||
|
os.makedirs(cache_path,exist_ok=True)
|
||||||
|
t = threading.Thread(target=start_flask, daemon=True)
|
||||||
|
t.start()
|
||||||
|
# 等待 Flask 启动
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
window = webview.create_window(
|
||||||
|
title="南日AI",
|
||||||
|
url=APP_URL,
|
||||||
|
width=1400,
|
||||||
|
height=900,
|
||||||
|
resizable=True,
|
||||||
|
min_size=(1200, 700),
|
||||||
|
background_color="#1a1a1a",
|
||||||
|
frameless=False,
|
||||||
|
easy_drag=True
|
||||||
|
)
|
||||||
|
api = WindowAPI(window)
|
||||||
|
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder, api.save_file_from_url, api.save_template_xlsx,api.save_template_zip)
|
||||||
|
webview.start(
|
||||||
|
debug=True,
|
||||||
|
storage_path=cache_path,
|
||||||
|
private_mode=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
261
app_common.py
Normal file
261
app_common.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""
|
||||||
|
公共模块:数据库连接、初始化、会话校验、装饰器、模板渲染
|
||||||
|
供各蓝图复用
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string
|
||||||
|
|
||||||
|
from config import mysql_host, mysql_user, mysql_password, mysql_database
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
STATIC_DIR = os.path.join(BASE_DIR, 'static')
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
return pymysql.connect(
|
||||||
|
host=mysql_host,
|
||||||
|
user=mysql_user,
|
||||||
|
password=mysql_password,
|
||||||
|
database=mysql_database,
|
||||||
|
charset='utf8mb4',
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_html(template_name: str, **context):
|
||||||
|
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
|
||||||
|
path = os.path.join(BASE_DIR, "web_source", template_name)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return render_template(template_name, **context)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
try:
|
||||||
|
from html_crypto import decrypt
|
||||||
|
content = decrypt(raw).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
content = raw.decode("utf-8", errors="replace")
|
||||||
|
return render_template_string(content, **context)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_session_user_valid():
|
||||||
|
"""校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False"""
|
||||||
|
uid = session.get('user_id')
|
||||||
|
if not uid:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id FROM users WHERE id = %s", (uid,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
session.clear()
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
session.clear()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_admin_role():
|
||||||
|
"""获取当前登录用户的管理角色:super_admin / admin / None(非管理员)"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
|
||||||
|
(session['user_id'],)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or not row.get('is_admin'):
|
||||||
|
return None, None
|
||||||
|
return row.get('role') or ('super_admin' if row.get('created_by_id') is None else 'admin'), row
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not session.get('user_id') or not _is_session_user_valid():
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not session.get('user_id'):
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or not row.get('is_admin'):
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||||
|
return redirect(url_for('main.home'))
|
||||||
|
except Exception as e:
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
return redirect(url_for('main.home'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""初始化数据库表,若不存在则创建"""
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=mysql_host,
|
||||||
|
user=mysql_user,
|
||||||
|
password=mysql_password,
|
||||||
|
charset='utf8mb4'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(f"CREATE DATABASE IF NOT EXISTS `{mysql_database}` DEFAULT CHARSET utf8mb4")
|
||||||
|
cur.execute(f"USE `{mysql_database}`")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(256) NOT NULL,
|
||||||
|
is_admin TINYINT(1) DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS image_history (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
panel_type VARCHAR(64) DEFAULT '',
|
||||||
|
original_urls JSON,
|
||||||
|
params JSON,
|
||||||
|
result_urls JSON,
|
||||||
|
long_image_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
INDEX idx_user_created (user_id, created_at DESC)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE image_history ADD COLUMN long_image_url VARCHAR(1024) DEFAULT NULL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE users ADD COLUMN machine VARCHAR(64) DEFAULT NULL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'normal'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE users ADD COLUMN created_by_id INT NULL")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS brand_crawl_tasks (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
file_paths JSON NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行',
|
||||||
|
result_paths JSON NULL,
|
||||||
|
error_message TEXT NULL,
|
||||||
|
progress_current INT DEFAULT 0,
|
||||||
|
progress_total INT DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_status (user_id, status)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_current INT DEFAULT 0")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_total INT DEFAULT 0")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN `desc` VARCHAR(500) NULL COMMENT '上传文件名描述'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN strategy VARCHAR(20) DEFAULT 'Terms' COMMENT '品牌匹配方式: Terms/Simple'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行'")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS columns (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL COMMENT '栏目名',
|
||||||
|
column_key VARCHAR(64) NOT NULL COMMENT '栏目标识',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_column_key (column_key)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_column_permission (
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
column_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, column_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
try:
|
||||||
|
cur.execute("UPDATE users SET role = 'normal' WHERE (role IS NULL OR role = '') AND (is_admin = 0 OR is_admin IS NULL)")
|
||||||
|
cur.execute("SELECT MIN(id) AS mid FROM users WHERE is_admin = 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row.get('mid'):
|
||||||
|
mid = row['mid']
|
||||||
|
cur.execute("UPDATE users SET role = 'super_admin' WHERE id = %s", (mid,))
|
||||||
|
cur.execute("UPDATE users SET role = 'admin' WHERE is_admin = 1 AND id != %s", (mid,))
|
||||||
|
cur.execute("UPDATE users SET created_by_id = %s WHERE role = 'admin' AND (created_by_id IS NULL)", (mid,))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
_create_initial_admin()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_initial_admin():
|
||||||
|
"""若没有任何管理员,则创建默认超级管理员(首次启动时,仅一个)"""
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1")
|
||||||
|
if cur.fetchone():
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
admin_user = os.environ.get('ADMIN_USER', 'admin')
|
||||||
|
admin_pwd = os.environ.get('ADMIN_PASSWORD', 'admin123')
|
||||||
|
pwd_hash = generate_password_hash(admin_pwd, method='pbkdf2:sha256')
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, is_admin, role) VALUES (%s, %s, 1, 'super_admin')",
|
||||||
|
(admin_user, pwd_hash)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
1
blueprints/__init__.py
Normal file
1
blueprints/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# 蓝图包
|
||||||
BIN
blueprints/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
blueprints/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
blueprints/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/admin.cpython-311.pyc
Normal file
BIN
blueprints/__pycache__/admin.cpython-311.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/admin.cpython-39.pyc
Normal file
BIN
blueprints/__pycache__/admin.cpython-39.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/auth.cpython-311.pyc
Normal file
BIN
blueprints/__pycache__/auth.cpython-311.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/auth.cpython-39.pyc
Normal file
BIN
blueprints/__pycache__/auth.cpython-39.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/brand.cpython-311.pyc
Normal file
BIN
blueprints/__pycache__/brand.cpython-311.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/brand.cpython-39.pyc
Normal file
BIN
blueprints/__pycache__/brand.cpython-39.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/image.cpython-311.pyc
Normal file
BIN
blueprints/__pycache__/image.cpython-311.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/image.cpython-39.pyc
Normal file
BIN
blueprints/__pycache__/image.cpython-39.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/main.cpython-311.pyc
Normal file
BIN
blueprints/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
blueprints/__pycache__/main.cpython-39.pyc
Normal file
BIN
blueprints/__pycache__/main.cpython-39.pyc
Normal file
Binary file not shown.
362
blueprints/admin.py
Normal file
362
blueprints/admin.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
"""
|
||||||
|
管理员蓝图:用户管理(列表/创建/更新/删除)、生成历史、管理页
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pymysql
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
admin_bp = Blueprint('admin', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(val, default=None):
|
||||||
|
if val is None:
|
||||||
|
return default if default is not None else []
|
||||||
|
if isinstance(val, (list, dict)):
|
||||||
|
return val
|
||||||
|
try:
|
||||||
|
return json.loads(val)
|
||||||
|
except Exception:
|
||||||
|
return default if default is not None else []
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/admin')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def admin_page():
|
||||||
|
return _render_html('admin.html')
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/api/admin/users')
|
||||||
|
@admin_required
|
||||||
|
def admin_list_users():
|
||||||
|
"""分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选"""
|
||||||
|
role, current_row = _get_current_admin_role()
|
||||||
|
if not role:
|
||||||
|
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||||
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
|
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
|
||||||
|
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
|
||||||
|
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
|
||||||
|
if role != 'super_admin':
|
||||||
|
created_by_id = None
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if role == 'super_admin':
|
||||||
|
where_parts = ["1=1"]
|
||||||
|
params = []
|
||||||
|
if search_username:
|
||||||
|
where_parts.append("u.username LIKE %s")
|
||||||
|
params.append("%" + search_username + "%")
|
||||||
|
if created_by_id is not None:
|
||||||
|
where_parts.append("u.created_by_id = %s")
|
||||||
|
params.append(created_by_id)
|
||||||
|
where_sql = " AND ".join(where_parts)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
|
||||||
|
creator.username AS creator_username
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN users creator ON creator.id = u.created_by_id
|
||||||
|
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
|
||||||
|
tuple(params) + (page_size, offset),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params))
|
||||||
|
total = cur.fetchone()['total']
|
||||||
|
cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id")
|
||||||
|
admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()]
|
||||||
|
else:
|
||||||
|
admin_id = current_row['id']
|
||||||
|
where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"]
|
||||||
|
params = [admin_id, admin_id]
|
||||||
|
if search_username:
|
||||||
|
where_parts.append("u.username LIKE %s")
|
||||||
|
params.append("%" + search_username + "%")
|
||||||
|
where_sql = " AND ".join(where_parts)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
|
||||||
|
creator.username AS creator_username
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN users creator ON creator.id = u.created_by_id
|
||||||
|
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
|
||||||
|
tuple(params) + (page_size, offset),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) as total FROM users u WHERE " + where_sql,
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
total = cur.fetchone()['total']
|
||||||
|
admins = []
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
'id': r['id'],
|
||||||
|
'username': r['username'],
|
||||||
|
'is_admin': bool(r.get('is_admin')),
|
||||||
|
'role': r.get('role') or 'normal',
|
||||||
|
'created_by_id': r.get('created_by_id'),
|
||||||
|
'creator_username': r.get('creator_username') or '',
|
||||||
|
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
conn.close()
|
||||||
|
payload = {
|
||||||
|
'success': True,
|
||||||
|
'items': items,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'current_user_role': role,
|
||||||
|
'admins': admins,
|
||||||
|
}
|
||||||
|
return jsonify(payload)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/api/admin/user', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def admin_create_user():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
username = (data.get('username') or '').strip()
|
||||||
|
password = data.get('password') or ''
|
||||||
|
role, current_row = _get_current_admin_role()
|
||||||
|
if not role:
|
||||||
|
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||||
|
want_role = (data.get('role') or 'normal').strip() or 'normal'
|
||||||
|
if want_role not in ('admin', 'normal'):
|
||||||
|
want_role = 'normal'
|
||||||
|
if role == 'admin' and want_role == 'admin':
|
||||||
|
return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'})
|
||||||
|
if want_role == 'admin':
|
||||||
|
want_created_by = current_row['id']
|
||||||
|
elif role == 'super_admin':
|
||||||
|
want_created_by = data.get('created_by_id')
|
||||||
|
else:
|
||||||
|
want_created_by = current_row['id']
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({'success': False, 'error': '用户名和密码不能为空'})
|
||||||
|
if len(username) < 2:
|
||||||
|
return jsonify({'success': False, 'error': '用户名至少2个字符'})
|
||||||
|
if len(password) < 6:
|
||||||
|
return jsonify({'success': False, 'error': '密码至少6个字符'})
|
||||||
|
if want_role == 'normal' and role == 'super_admin' and want_created_by is None:
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1")
|
||||||
|
r = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
want_created_by = r['id'] if r else current_row['id']
|
||||||
|
except Exception:
|
||||||
|
want_created_by = current_row['id']
|
||||||
|
if want_role == 'normal' and want_created_by is None:
|
||||||
|
want_created_by = current_row['id']
|
||||||
|
is_admin = 1 if want_role in ('super_admin', 'admin') else 0
|
||||||
|
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
(username, pwd_hash, is_admin, want_role, want_created_by),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'success': True, 'msg': '用户创建成功'})
|
||||||
|
except pymysql.IntegrityError:
|
||||||
|
return jsonify({'success': False, 'error': '用户名已存在'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/api/admin/user/<int:uid>', methods=['PUT'])
|
||||||
|
@admin_required
|
||||||
|
def admin_update_user(uid):
|
||||||
|
data = request.get_json() or {}
|
||||||
|
password = data.get('password')
|
||||||
|
want_role = (data.get('role') or '').strip() or data.get('role')
|
||||||
|
role, current_row = _get_current_admin_role()
|
||||||
|
if not role:
|
||||||
|
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||||
|
if want_role is None and not password:
|
||||||
|
return jsonify({'success': False, 'error': '请提供要修改的内容'})
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
|
||||||
|
target = cur.fetchone()
|
||||||
|
if not target:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'success': False, 'error': '用户不存在'})
|
||||||
|
if role == 'admin':
|
||||||
|
if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403
|
||||||
|
want_role = None
|
||||||
|
else:
|
||||||
|
if target.get('role') == 'super_admin':
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'success': False, 'error': '不能修改超级管理员'})
|
||||||
|
if want_role == 'super_admin':
|
||||||
|
return jsonify({'success': False, 'error': '不能将用户设为超级管理员'})
|
||||||
|
if want_role not in ('admin', 'normal', None, ''):
|
||||||
|
want_role = None
|
||||||
|
if password:
|
||||||
|
if len(password) < 6:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'success': False, 'error': '密码至少6个字符'})
|
||||||
|
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
|
||||||
|
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid))
|
||||||
|
if want_role is not None and want_role != '':
|
||||||
|
is_admin = 1 if want_role == 'admin' else 0
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
|
||||||
|
(is_admin, want_role, uid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'success': True, 'msg': '更新成功'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/api/admin/user/<int:uid>', methods=['DELETE'])
|
||||||
|
@admin_required
|
||||||
|
def admin_delete_user(uid):
|
||||||
|
from flask import session
|
||||||
|
if session.get('user_id') == uid:
|
||||||
|
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
|
||||||
|
role, current_row = _get_current_admin_role()
|
||||||
|
if not role:
|
||||||
|
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
|
||||||
|
target = cur.fetchone()
|
||||||
|
if not target:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'success': False, 'error': '用户不存在'})
|
||||||
|
if target.get('role') == 'super_admin':
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'success': False, 'error': '不能删除超级管理员'})
|
||||||
|
if role == 'admin':
|
||||||
|
if target.get('role') != 'normal' or target.get('created_by_id') != current_row['id']:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'success': False, 'error': '只能删除自己创建的普通用户'}), 403
|
||||||
|
cur.execute("DELETE FROM users WHERE id = %s", (uid,))
|
||||||
|
affected = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
if affected == 0:
|
||||||
|
return jsonify({'success': False, 'error': '用户不存在'})
|
||||||
|
return jsonify({'success': True, 'msg': '删除成功'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/api/admin/user/<int:uid>/column-permissions')
|
||||||
|
@login_required
|
||||||
|
def admin_user_column_permissions(uid):
|
||||||
|
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
|
||||||
|
current_uid = session.get('user_id')
|
||||||
|
if current_uid != uid:
|
||||||
|
role, _ = _get_current_admin_role()
|
||||||
|
if not role:
|
||||||
|
return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
|
||||||
|
user_row = cur.fetchone()
|
||||||
|
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, column_key, created_at FROM columns ORDER BY id
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
else:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT c.id, c.name, c.column_key, c.created_at
|
||||||
|
FROM columns c
|
||||||
|
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
|
||||||
|
WHERE ucp.user_id = %s
|
||||||
|
ORDER BY c.id
|
||||||
|
""", (uid,))
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
'id': r['id'],
|
||||||
|
'name': r['name'],
|
||||||
|
'column_key': r['column_key'],
|
||||||
|
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return jsonify({'success': True, 'items': items})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/api/admin/history')
|
||||||
|
@admin_required
|
||||||
|
def admin_history():
|
||||||
|
"""管理员分页获取所有生成记录,支持按用户和时间筛选"""
|
||||||
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
|
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
user_id = request.args.get('user_id', type=int)
|
||||||
|
time_start = (request.args.get('time_start') or '').strip()
|
||||||
|
time_end = (request.args.get('time_end') or '').strip()
|
||||||
|
conditions, params = [], []
|
||||||
|
if user_id:
|
||||||
|
conditions.append("h.user_id = %s")
|
||||||
|
params.append(user_id)
|
||||||
|
if time_start:
|
||||||
|
conditions.append("h.created_at >= %s")
|
||||||
|
params.append(time_start)
|
||||||
|
if time_end:
|
||||||
|
conditions.append("h.created_at <= %s")
|
||||||
|
params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end)
|
||||||
|
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||||
|
params_count = params[:]
|
||||||
|
params.extend([page_size, offset])
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls,
|
||||||
|
h.long_image_url, u.username
|
||||||
|
FROM image_history h
|
||||||
|
LEFT JOIN users u ON h.user_id = u.id
|
||||||
|
WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count)
|
||||||
|
total = cur.fetchone()['total']
|
||||||
|
conn.close()
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
items.append({
|
||||||
|
'id': r['id'],
|
||||||
|
'user_id': r['user_id'],
|
||||||
|
'username': r.get('username') or '-',
|
||||||
|
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
|
||||||
|
'panel_type': r['panel_type'] or '',
|
||||||
|
'original_urls': _parse_json(r['original_urls'], []),
|
||||||
|
'params': _parse_json(r['params'], {}),
|
||||||
|
'result_urls': _parse_json(r['result_urls'], []),
|
||||||
|
'long_image_url': (r.get('long_image_url') or '').strip() or None,
|
||||||
|
})
|
||||||
|
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)})
|
||||||
107
blueprints/auth.py
Normal file
107
blueprints/auth.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
认证蓝图:登录、登出、登录状态校验
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, redirect, url_for, session, jsonify
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
|
||||||
|
from app_common import (
|
||||||
|
get_db,
|
||||||
|
_render_html,
|
||||||
|
_is_session_user_valid,
|
||||||
|
login_required,
|
||||||
|
BASE_DIR,
|
||||||
|
)
|
||||||
|
from tool.devices import DeviceIDGenerator
|
||||||
|
|
||||||
|
auth_bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if session.get('user_id') and _is_session_user_valid():
|
||||||
|
return redirect(url_for('main.home'))
|
||||||
|
if request.method == 'POST':
|
||||||
|
data = request.get_json() if request.is_json else request.form
|
||||||
|
username = (data.get('username') or '').strip()
|
||||||
|
password = data.get('password') or ''
|
||||||
|
if not username or not password:
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': '请输入用户名和密码'})
|
||||||
|
return _render_html('login.html', error='请输入用户名和密码')
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s",
|
||||||
|
(username,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and check_password_hash(row['password_hash'], password):
|
||||||
|
current_machine = DeviceIDGenerator().get_device_id()
|
||||||
|
stored_machine = (row.get('machine') or '').strip()
|
||||||
|
if not stored_machine:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id']))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
session.permanent = True
|
||||||
|
session['user_id'] = row['id']
|
||||||
|
session['username'] = username
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({'success': True, 'redirect': url_for('main.home')})
|
||||||
|
return redirect(url_for('main.home'))
|
||||||
|
print("验证设备",stored_machine)
|
||||||
|
print("当前设备",current_machine)
|
||||||
|
if stored_machine != current_machine and row.get("is_admin") != 1:
|
||||||
|
conn.close()
|
||||||
|
err_msg = '当前设备与首次登录设备不一致,请在原设备上登录'
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': err_msg})
|
||||||
|
return _render_html('login.html', error=err_msg)
|
||||||
|
conn.close()
|
||||||
|
session.permanent = True
|
||||||
|
session['user_id'] = row['id']
|
||||||
|
session['username'] = username
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({'success': True, 'redirect': url_for('main.home')})
|
||||||
|
return redirect(url_for('main.home'))
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': str(e)})
|
||||||
|
return _render_html('login.html', error='登录失败,请稍后重试')
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': '用户名或密码错误'})
|
||||||
|
return _render_html('login.html', error='用户名或密码错误')
|
||||||
|
return _render_html('login.html')
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/api/auth/check')
|
||||||
|
@login_required
|
||||||
|
def api_auth_check():
|
||||||
|
"""校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致"""
|
||||||
|
if not session.get('user_id'):
|
||||||
|
return jsonify({'logged_in': False})
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'logged_in': False})
|
||||||
|
stored_machine = (row.get('machine') or '').strip()
|
||||||
|
if stored_machine:
|
||||||
|
current_machine = DeviceIDGenerator().get_device_id()
|
||||||
|
if stored_machine != current_machine and row.get("is_admin") != 1:
|
||||||
|
session.clear()
|
||||||
|
return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'})
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'logged_in': False})
|
||||||
|
return jsonify({'logged_in': True, 'redirect': url_for('main.home')})
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/logout')
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
855
blueprints/brand.py
Normal file
855
blueprints/brand.py
Normal file
@@ -0,0 +1,855 @@
|
|||||||
|
"""
|
||||||
|
品牌爬虫蓝图:展开文件夹、运行任务、任务列表/详情、下载结果
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import datetime
|
||||||
|
import traceback
|
||||||
|
import zipfile
|
||||||
|
import io
|
||||||
|
import time
|
||||||
|
import tempfile
|
||||||
|
import requests
|
||||||
|
from queue import Queue, Empty
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, session, send_file, redirect, Response
|
||||||
|
|
||||||
|
from app_common import get_db, login_required, BASE_DIR
|
||||||
|
from config import bucket_path
|
||||||
|
from brand_spider.main import single_file_handle, TaskCancelledError
|
||||||
|
|
||||||
|
brand_bp = Blueprint('brand', __name__)
|
||||||
|
BRAND_OUTPUT_DIR = os.path.join(BASE_DIR, 'brand_output')
|
||||||
|
OSS_PREFIX = "brand_results"
|
||||||
|
|
||||||
|
# 任务完成时推送给前端的 SSE 队列:task_id -> [Queue, ...]
|
||||||
|
_task_event_queues = {}
|
||||||
|
_task_event_lock = threading.Lock()
|
||||||
|
|
||||||
|
# 任务行级进度(当前处理文件的行数/总行数),仅存内存,不落库:
|
||||||
|
# task_id -> {
|
||||||
|
# 'file_index': int, # 当前处理第几个文件
|
||||||
|
# 'file_total': int, # 总文件数
|
||||||
|
# 'file_path': str, # 当前文件路径
|
||||||
|
# 'current_line': int, # 当前行号(或当前品牌序号)
|
||||||
|
# 'total_lines': int, # 总行数(或总品牌数)
|
||||||
|
# }
|
||||||
|
_task_line_progress = {}
|
||||||
|
_task_line_progress_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _push_task_event(task_id, event):
|
||||||
|
"""向订阅了该任务的所有 SSE 连接推送事件,并移除该任务的队列列表。
|
||||||
|
同时清理内存中的行级进度,不通过数据库中转行级进度。"""
|
||||||
|
with _task_event_lock:
|
||||||
|
queues = _task_event_queues.pop(task_id, [])
|
||||||
|
for q in queues:
|
||||||
|
try:
|
||||||
|
q.put_nowait(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 任务进入终态时,顺便清理行级进度缓存
|
||||||
|
with _task_line_progress_lock:
|
||||||
|
_task_line_progress.pop(task_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_folder_xlsx(folder_path):
|
||||||
|
"""返回文件夹下所有 .xlsx 文件的绝对路径列表"""
|
||||||
|
if not folder_path or not os.path.isdir(folder_path):
|
||||||
|
return []
|
||||||
|
paths = []
|
||||||
|
for name in os.listdir(folder_path):
|
||||||
|
if name.endswith('.xlsx') or name.endswith('.XLSX'):
|
||||||
|
paths.append(os.path.normpath(os.path.join(folder_path, name)))
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_local_xlsx_to_oss(local_path, user_id):
|
||||||
|
"""将本地 xlsx 文件上传到 OSS,返回 (url, None) 或 (None, error_message)。"""
|
||||||
|
if not local_path or not os.path.isfile(local_path):
|
||||||
|
return None, "文件不存在"
|
||||||
|
try:
|
||||||
|
from ali_oss import upload_file as oss_upload_file
|
||||||
|
except ImportError:
|
||||||
|
return None, "OSS 模块未配置"
|
||||||
|
base = os.path.basename(local_path)
|
||||||
|
safe_base = "".join(c if c.isalnum() or c in '-_.' else '_' for c in base)
|
||||||
|
if not safe_base.endswith('.xlsx'):
|
||||||
|
safe_base = safe_base + '.xlsx'
|
||||||
|
key = f"{bucket_path}brand_input/{int(time.time() * 1000)}/{user_id}_{safe_base}"
|
||||||
|
try:
|
||||||
|
with open(local_path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
url = oss_upload_file(content, key)
|
||||||
|
return url, None
|
||||||
|
except Exception as e:
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_brand_files(file_paths, task_id=None, check_cancelled=None, strategy="Terms"):
|
||||||
|
"""对多个 xlsx 执行 single_file_handle(线程池多线程),返回 (result_paths, error_message)。
|
||||||
|
check_cancelled: 可调用对象,返回 True 时中止并返回 (result_paths, 'cancelled')。
|
||||||
|
task_id 存在时会在每处理完一个文件后更新 progress_current。
|
||||||
|
strategy: 品牌匹配方式,默认 Terms,可选 Simple。"""
|
||||||
|
os.makedirs(BRAND_OUTPUT_DIR, exist_ok=True)
|
||||||
|
subdir = str(task_id) if task_id else ('immediate_' + datetime.datetime.now().strftime('%Y%m%d_%H%M%S'))
|
||||||
|
out_dir = os.path.join(BRAND_OUTPUT_DIR, subdir)
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 先筛出有效路径并生成 (fp, out_path) 列表
|
||||||
|
tasks = []
|
||||||
|
for fp in file_paths:
|
||||||
|
if not fp or not isinstance(fp, str) or not os.path.isfile(fp):
|
||||||
|
continue
|
||||||
|
base = os.path.splitext(os.path.basename(fp))[0]
|
||||||
|
safe_base = "".join(c if c.isalnum() or c in '-_' else '_' for c in base)
|
||||||
|
out_path = os.path.join(out_dir, safe_base + '_result.xlsx')
|
||||||
|
tasks.append((fp, out_path))
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
result_paths = []
|
||||||
|
result_lock = threading.Lock()
|
||||||
|
current = [0] # 用列表以便在闭包中修改
|
||||||
|
|
||||||
|
def process_one(fp, out_path, file_index, files_total):
|
||||||
|
# 行级进度回调:由 single_file_handle 在循环中调用,直接写入内存字典,不落库
|
||||||
|
def progress_callback(current_line, total_lines, extra=None):
|
||||||
|
if not task_id:
|
||||||
|
return
|
||||||
|
info = {
|
||||||
|
'file_index': file_index,
|
||||||
|
'file_total': files_total,
|
||||||
|
'file_path': fp,
|
||||||
|
'current_line': int(current_line or 0),
|
||||||
|
'total_lines': int(total_lines or 0),
|
||||||
|
}
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
info.update(extra)
|
||||||
|
with _task_line_progress_lock:
|
||||||
|
_task_line_progress[task_id] = info
|
||||||
|
|
||||||
|
single_file_handle(fp, out_path, strategy, check_cancelled=check_cancelled, progress_callback=progress_callback)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
max_workers = min(8, len(tasks))
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_to_out = {
|
||||||
|
executor.submit(process_one, fp, out_path, idx + 1, len(tasks)): out_path
|
||||||
|
for idx, (fp, out_path) in enumerate(tasks)
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
for future in as_completed(future_to_out):
|
||||||
|
if check_cancelled and check_cancelled():
|
||||||
|
for f in future_to_out:
|
||||||
|
f.cancel()
|
||||||
|
return result_paths, 'cancelled'
|
||||||
|
try:
|
||||||
|
out_path = future.result()
|
||||||
|
with result_lock:
|
||||||
|
result_paths.append(out_path)
|
||||||
|
current[0] += 1
|
||||||
|
if task_id:
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE brand_crawl_tasks SET progress_current = %s WHERE id = %s",
|
||||||
|
(current[0], task_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
print(traceback.format_exc())
|
||||||
|
except TaskCancelledError:
|
||||||
|
for f in future_to_out:
|
||||||
|
f.cancel()
|
||||||
|
return result_paths, 'cancelled'
|
||||||
|
except Exception as e:
|
||||||
|
print(traceback.format_exc())
|
||||||
|
print(e)
|
||||||
|
for f in future_to_out:
|
||||||
|
f.cancel()
|
||||||
|
return result_paths, str(e)
|
||||||
|
except Exception as e:
|
||||||
|
print(traceback.format_exc())
|
||||||
|
return result_paths, str(e)
|
||||||
|
return result_paths, None
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_results_to_oss(source_paths, local_result_paths, task_id):
|
||||||
|
"""
|
||||||
|
将源文件和结果文件上传到 OSS,并打包成 zip(分两个文件夹:源文件、结果文件)上传。
|
||||||
|
返回 (result_data, error_message),result_data 为 {"urls": [url1, ...], "zip_url": "..."},出错时为 (None, err)。
|
||||||
|
"""
|
||||||
|
if not local_result_paths or not task_id:
|
||||||
|
return None, "无结果文件"
|
||||||
|
try:
|
||||||
|
from ali_oss import upload_file as oss_upload_file
|
||||||
|
except ImportError:
|
||||||
|
return None, "OSS 模块未配置"
|
||||||
|
urls = []
|
||||||
|
subdir = f"{OSS_PREFIX}/{task_id}"
|
||||||
|
for fp in local_result_paths:
|
||||||
|
if not fp or not os.path.isfile(fp):
|
||||||
|
continue
|
||||||
|
name = os.path.basename(fp)
|
||||||
|
key = f"{bucket_path}{subdir}/{name}"
|
||||||
|
try:
|
||||||
|
with open(fp, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
url = oss_upload_file(content, key)
|
||||||
|
urls.append(url)
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"上传结果文件失败: {e}"
|
||||||
|
if not urls:
|
||||||
|
return None, "没有可上传的结果文件"
|
||||||
|
# 打包成 zip:源文件 -> 源文件/,结果文件 -> 结果文件/
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for fp in (source_paths or []):
|
||||||
|
if fp and os.path.isfile(fp):
|
||||||
|
zf.write(fp, os.path.join("源文件", os.path.basename(fp)))
|
||||||
|
for fp in local_result_paths:
|
||||||
|
if fp and os.path.isfile(fp):
|
||||||
|
zf.write(fp, os.path.join("结果文件", os.path.basename(fp)))
|
||||||
|
buf.seek(0)
|
||||||
|
zip_key = f"{bucket_path}{subdir}/result.zip"
|
||||||
|
try:
|
||||||
|
zip_url = oss_upload_file(buf.getvalue(), zip_key)
|
||||||
|
except Exception as e:
|
||||||
|
return {"urls": urls, "zip_url": None}, None # 单文件链接已保存,zip 失败不报错
|
||||||
|
return {"urls": urls, "zip_url": zip_url}, None
|
||||||
|
|
||||||
|
|
||||||
|
def _background_brand_task(task_id):
|
||||||
|
"""后台执行单个品牌爬虫任务并更新数据库"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, user_id, file_paths, strategy, `desc` FROM brand_crawl_tasks WHERE id = %s AND status = 'pending'",
|
||||||
|
(task_id,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
file_paths = row.get('file_paths')
|
||||||
|
if isinstance(file_paths, str):
|
||||||
|
file_paths = json.loads(file_paths) if file_paths else []
|
||||||
|
# 优先使用表里存的 strategy 字段,兼容旧数据则从 desc 解析
|
||||||
|
strategy = (row.get('strategy') or '').strip() or None
|
||||||
|
if not strategy:
|
||||||
|
desc_val = (row.get('desc') or '').strip()
|
||||||
|
strategy = "Simple" if desc_val.startswith('[Simple]') else "Terms"
|
||||||
|
if strategy not in ('Terms', 'Simple'):
|
||||||
|
strategy = 'Terms'
|
||||||
|
if not file_paths:
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
||||||
|
('没有有效文件路径', task_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_push_task_event(task_id, {'status': 'failed', 'error_message': '没有有效文件路径'})
|
||||||
|
return
|
||||||
|
# 将 file_paths 解析为本地路径:URL 下载到 BRAND_OUTPUT_DIR 对应任务文件夹下(兼容旧数据中的本地路径)
|
||||||
|
os.makedirs(BRAND_OUTPUT_DIR, exist_ok=True)
|
||||||
|
task_subdir = str(task_id) if task_id else ('immediate_' + datetime.datetime.now().strftime('%Y%m%d_%H%M%S'))
|
||||||
|
task_dir = os.path.join(BRAND_OUTPUT_DIR, task_subdir)
|
||||||
|
os.makedirs(task_dir, exist_ok=True)
|
||||||
|
|
||||||
|
local_paths = []
|
||||||
|
downloaded_files_to_cleanup_on_fail = []
|
||||||
|
for idx, p in enumerate(file_paths):
|
||||||
|
if not p or not isinstance(p, str):
|
||||||
|
continue
|
||||||
|
p = p.strip()
|
||||||
|
if _is_url(p):
|
||||||
|
try:
|
||||||
|
parsed = urlparse(p)
|
||||||
|
url_name = os.path.basename(parsed.path or '')
|
||||||
|
if not url_name:
|
||||||
|
url_name = f"input_{idx + 1}.xlsx"
|
||||||
|
safe_name = "".join(c if c.isalnum() or c in '-_.' else '_' for c in url_name)
|
||||||
|
if not safe_name.lower().endswith('.xlsx'):
|
||||||
|
safe_name = safe_name + '.xlsx'
|
||||||
|
|
||||||
|
base_no_ext, ext = os.path.splitext(safe_name)
|
||||||
|
dest = os.path.join(task_dir, safe_name)
|
||||||
|
n = 1
|
||||||
|
while os.path.exists(dest):
|
||||||
|
dest = os.path.join(task_dir, f"{base_no_ext}_{n}{ext}")
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
r = requests.get(p, timeout=120, stream=True)
|
||||||
|
r.raise_for_status()
|
||||||
|
with open(dest, "wb") as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=1024 * 1024):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
local_paths.append(dest)
|
||||||
|
downloaded_files_to_cleanup_on_fail.append(dest)
|
||||||
|
except Exception as e:
|
||||||
|
for tf in downloaded_files_to_cleanup_on_fail:
|
||||||
|
try:
|
||||||
|
os.unlink(tf)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
||||||
|
(f'下载文件失败: {str(e)}', task_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_push_task_event(task_id, {'status': 'failed', 'error_message': f'下载文件失败: {str(e)}'})
|
||||||
|
return
|
||||||
|
elif os.path.isfile(p):
|
||||||
|
local_paths.append(p)
|
||||||
|
if not local_paths:
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
||||||
|
('没有有效的本地或可下载文件', task_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_push_task_event(task_id, {'status': 'failed', 'error_message': '没有有效的本地或可下载文件'})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# 原子地将 pending 改为 running,避免用户先点取消时仍被覆盖为 running
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE brand_crawl_tasks SET status = 'running', progress_total = %s, progress_current = 0 WHERE id = %s AND status = 'pending'",
|
||||||
|
(len(local_paths), task_id)
|
||||||
|
)
|
||||||
|
n = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
if n == 0:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT status FROM brand_crawl_tasks WHERE id = %s", (task_id,))
|
||||||
|
r = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if r and (r.get('status') or '') == 'cancelled':
|
||||||
|
_push_task_event(task_id, {'status': 'cancelled'})
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def check_cancelled():
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT status FROM brand_crawl_tasks WHERE id = %s", (task_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row and (row.get('status') or '') == 'cancelled'
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
result_paths, err = _run_brand_files(local_paths, task_id=task_id, check_cancelled=check_cancelled, strategy=strategy)
|
||||||
|
if err:
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
st = 'cancelled' if err == 'cancelled' else 'failed'
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE brand_crawl_tasks SET status = %s, error_message = %s WHERE id = %s",
|
||||||
|
(st, err if st == 'failed' else None, task_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
print(e)
|
||||||
|
pass
|
||||||
|
_push_task_event(task_id, {'status': st, 'error_message': err if st == 'failed' else None})
|
||||||
|
return
|
||||||
|
result_data, upload_err = _upload_results_to_oss(local_paths, result_paths, task_id)
|
||||||
|
if upload_err:
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
||||||
|
(upload_err, task_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_push_task_event(task_id, {'status': 'failed', 'error_message': upload_err})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE brand_crawl_tasks SET status = 'success', result_paths = %s WHERE id = %s",
|
||||||
|
(json.dumps(result_data), task_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_push_task_event(task_id, {'status': 'success'})
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE brand_crawl_tasks SET status = 'failed', error_message = %s WHERE id = %s",
|
||||||
|
(str(e), task_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_push_task_event(task_id, {'status': 'failed', 'error_message': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json(val, default=None):
|
||||||
|
if val is None:
|
||||||
|
return default if default is not None else []
|
||||||
|
if isinstance(val, (list, dict)):
|
||||||
|
return val
|
||||||
|
try:
|
||||||
|
return json.loads(val)
|
||||||
|
except Exception:
|
||||||
|
return default if default is not None else []
|
||||||
|
|
||||||
|
|
||||||
|
def _is_url(s):
|
||||||
|
if not isinstance(s, str) or not s.strip():
|
||||||
|
return False
|
||||||
|
return s.strip().startswith('http://') or s.strip().startswith('https://')
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_result_paths(raw):
|
||||||
|
"""
|
||||||
|
将 result_paths 规范化为 (url_list, zip_url)。
|
||||||
|
支持旧格式 list 或新格式 dict {"urls": [...], "zip_url": "..."}。
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return [], None
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
urls = raw.get('urls') or []
|
||||||
|
if not isinstance(urls, list):
|
||||||
|
urls = []
|
||||||
|
zip_url = raw.get('zip_url')
|
||||||
|
if zip_url and not isinstance(zip_url, str):
|
||||||
|
zip_url = None
|
||||||
|
return urls, zip_url
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return raw, None
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
|
||||||
|
@brand_bp.route('/api/brand/expand-folder', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_brand_expand_folder():
|
||||||
|
"""展开文件夹,返回其下所有 .xlsx 文件路径列表"""
|
||||||
|
try:
|
||||||
|
data = request.get_json() or {}
|
||||||
|
folder = (data.get('folder') or '').strip()
|
||||||
|
if not folder:
|
||||||
|
return jsonify({'success': False, 'error': '请提供 folder 路径'}), 400
|
||||||
|
paths = _expand_folder_xlsx(folder)
|
||||||
|
return jsonify({'success': True, 'paths': paths})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@brand_bp.route('/api/brand/run', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_brand_run():
|
||||||
|
"""立即运行:创建任务并后台执行,立即返回 task_id,前端可轮询进度与取消"""
|
||||||
|
try:
|
||||||
|
data = request.get_json() or {}
|
||||||
|
paths = data.get('paths') or []
|
||||||
|
# task_type: 1=立即执行,2=添加任务;此接口默认 1
|
||||||
|
try:
|
||||||
|
task_type = int(data.get('task_type') or 1)
|
||||||
|
except Exception:
|
||||||
|
task_type = 1
|
||||||
|
if task_type not in (1, 2):
|
||||||
|
task_type = 1
|
||||||
|
strategy = (data.get('strategy') or 'Terms').strip()
|
||||||
|
if strategy not in ('Terms', 'Simple'):
|
||||||
|
strategy = 'Terms'
|
||||||
|
if not isinstance(paths, list):
|
||||||
|
paths = []
|
||||||
|
paths = [p.strip() for p in paths if p and isinstance(p, str) and os.path.isfile(p.strip())]
|
||||||
|
if not paths:
|
||||||
|
return jsonify({'success': False, 'error': '没有有效的 xlsx 文件路径'}), 400
|
||||||
|
# 先上传到 OSS,获取链接再入库
|
||||||
|
user_id = session['user_id']
|
||||||
|
urls = []
|
||||||
|
for p in paths:
|
||||||
|
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||||
|
if err:
|
||||||
|
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||||
|
urls.append(url)
|
||||||
|
desc_core = ', '.join(os.path.basename(p) for p in paths)[:500] if paths else ''
|
||||||
|
desc = f'[{strategy}] ' + (desc_core or '')
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO brand_crawl_tasks (user_id, file_paths, `desc`, strategy, status, task_type) VALUES (%s, %s, %s, %s, 'pending', %s)",
|
||||||
|
(user_id, json.dumps(urls), desc or None, strategy, task_type)
|
||||||
|
)
|
||||||
|
task_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
threading.Thread(target=_background_brand_task, args=(task_id,), daemon=True).start()
|
||||||
|
return jsonify({'success': True, 'task_id': task_id})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@brand_bp.route('/api/brand/tasks', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def api_brand_tasks():
|
||||||
|
"""GET: 获取当前用户的任务列表;POST: 添加任务(后台执行)"""
|
||||||
|
if request.method == 'GET':
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, file_paths, `desc`, strategy, status, result_paths, error_message,
|
||||||
|
COALESCE(progress_current, 0) AS progress_current,
|
||||||
|
COALESCE(progress_total, 0) AS progress_total,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM brand_crawl_tasks WHERE user_id = %s ORDER BY id DESC LIMIT 100""",
|
||||||
|
(session['user_id'],)
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
items.append({
|
||||||
|
'id': r['id'],
|
||||||
|
'file_paths': _parse_json(r['file_paths'], []),
|
||||||
|
'desc': (r.get('desc') or '').strip() or None,
|
||||||
|
'strategy': (r.get('strategy') or 'Terms').strip() or 'Terms',
|
||||||
|
'status': r.get('status') or 'pending',
|
||||||
|
'result_paths': _parse_json(r['result_paths']),
|
||||||
|
'error_message': (r.get('error_message') or '').strip() or None,
|
||||||
|
'progress_current': int(r.get('progress_current') or 0),
|
||||||
|
'progress_total': int(r.get('progress_total') or 0),
|
||||||
|
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
|
||||||
|
'updated_at': r['updated_at'].strftime('%Y-%m-%d %H:%M') if r.get('updated_at') else '',
|
||||||
|
})
|
||||||
|
return jsonify({'success': True, 'items': items})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
# POST
|
||||||
|
try:
|
||||||
|
data = request.get_json() or {}
|
||||||
|
paths = data.get('paths') or []
|
||||||
|
# task_type: 1=立即执行,2=添加任务;此接口默认 2
|
||||||
|
try:
|
||||||
|
task_type = int(data.get('task_type') or 2)
|
||||||
|
except Exception:
|
||||||
|
task_type = 2
|
||||||
|
if task_type not in (1, 2):
|
||||||
|
task_type = 2
|
||||||
|
strategy = (data.get('strategy') or 'Terms').strip()
|
||||||
|
if strategy not in ('Terms', 'Simple'):
|
||||||
|
strategy = 'Terms'
|
||||||
|
if not isinstance(paths, list):
|
||||||
|
paths = []
|
||||||
|
paths = [p.strip() for p in paths if p and isinstance(p, str)]
|
||||||
|
if not paths:
|
||||||
|
return jsonify({'success': False, 'error': '请提供至少一个文件路径或链接'}), 400
|
||||||
|
user_id = session['user_id']
|
||||||
|
urls = []
|
||||||
|
for p in paths:
|
||||||
|
if _is_url(p):
|
||||||
|
urls.append(p)
|
||||||
|
elif os.path.isfile(p):
|
||||||
|
url, err = _upload_local_xlsx_to_oss(p, user_id)
|
||||||
|
if err:
|
||||||
|
return jsonify({'success': False, 'error': f'上传文件失败: {os.path.basename(p)} - {err}'}), 500
|
||||||
|
urls.append(url)
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': f'无效路径或链接: {p[:80]}'}), 400
|
||||||
|
desc_core = ', '.join(os.path.basename(urlparse(u).path or u) for u in urls)[:500] if urls else ''
|
||||||
|
desc = f'[{strategy}] ' + (desc_core or '')
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO brand_crawl_tasks (user_id, file_paths, `desc`, strategy, status, task_type) VALUES (%s, %s, %s, %s, 'pending', %s)",
|
||||||
|
(user_id, json.dumps(urls), desc or None, strategy, task_type)
|
||||||
|
)
|
||||||
|
task_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
# threading.Thread(target=_background_brand_task, args=(task_id,), daemon=True).start()
|
||||||
|
return jsonify({'success': True, 'task_id': task_id})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@brand_bp.route('/api/brand/tasks/<int:task_id>')
|
||||||
|
@login_required
|
||||||
|
def api_brand_task_detail(task_id):
|
||||||
|
"""获取单个任务详情(用于轮询状态)"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, file_paths, `desc`, strategy, status, result_paths, error_message,
|
||||||
|
COALESCE(progress_current, 0) AS progress_current,
|
||||||
|
COALESCE(progress_total, 0) AS progress_total,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM brand_crawl_tasks WHERE id = %s AND user_id = %s""",
|
||||||
|
(task_id, session['user_id'])
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'success': False, 'error': '任务不存在'}), 404
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'task': {
|
||||||
|
'id': row['id'],
|
||||||
|
'file_paths': _parse_json(row['file_paths'], []),
|
||||||
|
'desc': (row.get('desc') or '').strip() or None,
|
||||||
|
'strategy': (row.get('strategy') or 'Terms').strip() or 'Terms',
|
||||||
|
'status': row.get('status') or 'pending',
|
||||||
|
'result_paths': _parse_json(row['result_paths']),
|
||||||
|
'error_message': (row.get('error_message') or '').strip() or None,
|
||||||
|
'progress_current': int(row.get('progress_current') or 0),
|
||||||
|
'progress_total': int(row.get('progress_total') or 0),
|
||||||
|
'created_at': row['created_at'].strftime('%Y-%m-%d %H:%M') if row.get('created_at') else '',
|
||||||
|
'updated_at': row['updated_at'].strftime('%Y-%m-%d %H:%M') if row.get('updated_at') else '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@brand_bp.route('/api/brand/tasks/<int:task_id>/line-progress')
|
||||||
|
@login_required
|
||||||
|
def api_brand_task_line_progress(task_id):
|
||||||
|
"""
|
||||||
|
获取任务的“当前文件行级进度”(当前行数/总行数)。
|
||||||
|
该信息仅存放在内存字典 _task_line_progress 中,不写入数据库。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _task_line_progress_lock:
|
||||||
|
info = _task_line_progress.get(task_id)
|
||||||
|
if not info:
|
||||||
|
return jsonify({'success': True, 'has_progress': False})
|
||||||
|
# 为了避免暴露完整路径,只返回文件名
|
||||||
|
file_name = os.path.basename(info.get('file_path') or '') if info.get('file_path') else ''
|
||||||
|
resp = {
|
||||||
|
'file_index': int(info.get('file_index') or 0),
|
||||||
|
'file_total': int(info.get('file_total') or 0),
|
||||||
|
'file_name': file_name,
|
||||||
|
'current_line': int(info.get('current_line') or 0),
|
||||||
|
'total_lines': int(info.get('total_lines') or 0),
|
||||||
|
}
|
||||||
|
return jsonify({'success': True, 'has_progress': True, 'info': resp})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@brand_bp.route('/api/brand/tasks/<int:task_id>/events')
|
||||||
|
@login_required
|
||||||
|
def api_brand_task_events(task_id):
|
||||||
|
"""SSE:任务完成时后端主动推送事件,前端监听后隐藏进度条与取消按钮"""
|
||||||
|
def _task_status_event():
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT status, error_message FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||||
|
(task_id, session['user_id'])
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
st = (row.get('status') or '').lower()
|
||||||
|
if st in ('success', 'failed', 'cancelled'):
|
||||||
|
return {'status': st, 'error_message': (row.get('error_message') or '').strip() or None}
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 若任务已处于终态,直接返回一条事件后结束
|
||||||
|
ev = _task_status_event()
|
||||||
|
if ev is not None:
|
||||||
|
def _one_shot():
|
||||||
|
yield "data: " + json.dumps(ev, ensure_ascii=False) + "\n\n"
|
||||||
|
return Response(
|
||||||
|
_one_shot(),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 否则注册队列,等待后台任务完成时推送
|
||||||
|
q = Queue()
|
||||||
|
with _task_event_lock:
|
||||||
|
_task_event_queues.setdefault(task_id, []).append(q)
|
||||||
|
|
||||||
|
def _stream():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = q.get(timeout=20)
|
||||||
|
yield "data: " + json.dumps(event, ensure_ascii=False) + "\n\n"
|
||||||
|
return
|
||||||
|
except Empty:
|
||||||
|
yield ": keepalive\n\n"
|
||||||
|
finally:
|
||||||
|
with _task_event_lock:
|
||||||
|
lst = _task_event_queues.get(task_id, [])
|
||||||
|
if q in lst:
|
||||||
|
lst.remove(q)
|
||||||
|
if not lst:
|
||||||
|
_task_event_queues.pop(task_id, None)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
_stream(),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@brand_bp.route('/api/brand/tasks/<int:task_id>/cancel', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_brand_task_cancel(task_id):
|
||||||
|
"""取消正在执行或等待中的任务(pending/running 均可取消)"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE brand_crawl_tasks SET status = 'cancelled' WHERE id = %s AND user_id = %s AND status IN ('pending', 'running')",
|
||||||
|
(task_id, session['user_id'])
|
||||||
|
)
|
||||||
|
n = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
if n == 0:
|
||||||
|
return jsonify({'success': False, 'error': '任务不存在或无法取消'}), 400
|
||||||
|
_push_task_event(task_id, {'status': 'cancelled'})
|
||||||
|
return jsonify({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@brand_bp.route('/api/brand/tasks/<int:task_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
def api_brand_task_delete(task_id):
|
||||||
|
"""删除任务(仅限非 running 状态)"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM brand_crawl_tasks WHERE id = %s AND user_id = %s AND status != 'running'",
|
||||||
|
(task_id, session['user_id'])
|
||||||
|
)
|
||||||
|
n = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
if n == 0:
|
||||||
|
return jsonify({'success': False, 'error': '任务不存在或正在执行中无法删除'}), 400
|
||||||
|
return jsonify({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@brand_bp.route('/api/brand/download/<int:task_id>')
|
||||||
|
@login_required
|
||||||
|
def api_brand_download(task_id):
|
||||||
|
"""下载任务结果:优先使用 OSS zip_url 重定向;否则本地/单链接/多链接按原逻辑处理"""
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT result_paths FROM brand_crawl_tasks WHERE id = %s AND user_id = %s",
|
||||||
|
(task_id, session['user_id'])
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row or not row.get('result_paths'):
|
||||||
|
return jsonify({'success': False, 'error': '无结果可下载'}), 404
|
||||||
|
raw = row['result_paths']
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = _parse_json(raw)
|
||||||
|
url_list, zip_url = _normalize_result_paths(raw)
|
||||||
|
# 新格式:有 zip_url 直接重定向到 OSS
|
||||||
|
if zip_url and _is_url(zip_url):
|
||||||
|
return redirect(zip_url, code=302)
|
||||||
|
|
||||||
|
# 兼容旧格式:result_paths 可能为 list(本地路径或 url)
|
||||||
|
if not url_list and isinstance(raw, list):
|
||||||
|
url_list = raw
|
||||||
|
local_paths = [p for p in url_list if isinstance(p, str) and not _is_url(p) and os.path.isfile(p)]
|
||||||
|
url_paths = [p.strip() for p in url_list if isinstance(p, str) and _is_url(p)]
|
||||||
|
|
||||||
|
if len(url_paths) == 1 and not local_paths:
|
||||||
|
return redirect(url_paths[0], code=302)
|
||||||
|
|
||||||
|
if len(local_paths) == 1 and not url_paths:
|
||||||
|
return send_file(
|
||||||
|
local_paths[0],
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=os.path.basename(local_paths[0])
|
||||||
|
)
|
||||||
|
|
||||||
|
if local_paths or url_paths:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for p in local_paths:
|
||||||
|
zf.write(p, os.path.basename(p))
|
||||||
|
for url in url_paths:
|
||||||
|
try:
|
||||||
|
import requests as req
|
||||||
|
r = req.get(url, timeout=30, stream=True)
|
||||||
|
r.raise_for_status()
|
||||||
|
name = os.path.basename(urlparse(url).path) or ('file_%s' % (url_paths.index(url)))
|
||||||
|
if not name or name == 'file_%s' % url_paths.index(url):
|
||||||
|
name = 'download_%s' % url_paths.index(url)
|
||||||
|
zf.writestr(name, r.content)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
buf.seek(0)
|
||||||
|
return send_file(
|
||||||
|
buf,
|
||||||
|
mimetype='application/zip',
|
||||||
|
as_attachment=True,
|
||||||
|
download_name='brand_task_%s.zip' % task_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({'success': False, 'error': '结果文件不存在或链接不可用'}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
411
blueprints/image.py
Normal file
411
blueprints/image.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
"""
|
||||||
|
图片生成蓝图:生成、历史、下载、拼接、版本
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
import requests
|
||||||
|
from urllib.parse import urlparse, quote
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, Response, session, send_file
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app_common import get_db, login_required, BASE_DIR
|
||||||
|
from config import STITCH_WORKFLOW_ID,client_name
|
||||||
|
|
||||||
|
image_bp = Blueprint('image', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_image_from_url_or_data(url_or_data):
|
||||||
|
"""从 http(s) URL 或 data URL 加载为 PIL Image,失败返回 None"""
|
||||||
|
if not url_or_data or not isinstance(url_or_data, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if url_or_data.startswith('data:'):
|
||||||
|
m = re.match(r'data:image/[^;]+;base64,(.+)', url_or_data, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
raw = base64.b64decode(m.group(1).strip())
|
||||||
|
img = Image.open(io.BytesIO(raw))
|
||||||
|
elif url_or_data.startswith(('http://', 'https://')):
|
||||||
|
resp = requests.get(url_or_data, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
img = Image.open(io.BytesIO(resp.content))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if img.mode != 'RGB':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
return img
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _stitch_and_upload_long_image(urls):
|
||||||
|
"""将多张图片 URL 先上传获取 file_id,再调用 workflow_run 拼接长图,返回 data.merged_image_url;失败返回 None。"""
|
||||||
|
if not urls or not isinstance(urls, (list, tuple)):
|
||||||
|
return None
|
||||||
|
from coze import upload_file as coze_upload_file, workflow_run
|
||||||
|
file_ids = []
|
||||||
|
temp_paths = []
|
||||||
|
try:
|
||||||
|
for u in urls:
|
||||||
|
img = _load_image_from_url_or_data(u)
|
||||||
|
if img is None:
|
||||||
|
continue
|
||||||
|
fd, path = tempfile.mkstemp(suffix='.png')
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
img.save(path)
|
||||||
|
temp_paths.append(path)
|
||||||
|
resp = coze_upload_file(path)
|
||||||
|
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
|
||||||
|
file_ids.append(resp['data']['id'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not file_ids:
|
||||||
|
return None
|
||||||
|
parameters = {"images": [{"file_id": fid} for fid in file_ids]}
|
||||||
|
resp = workflow_run(STITCH_WORKFLOW_ID, parameters, is_async=False)
|
||||||
|
if resp.get('code') != 0:
|
||||||
|
return None
|
||||||
|
data = resp.get('data') or {}
|
||||||
|
data = json.loads(data)
|
||||||
|
merged_image_url = data.get('merged_image_url')
|
||||||
|
if merged_image_url:
|
||||||
|
return merged_image_url
|
||||||
|
output_str = data.get('output') or ''
|
||||||
|
if output_str:
|
||||||
|
try:
|
||||||
|
outer = json.loads(output_str)
|
||||||
|
inner_str = outer.get('Output', '{}')
|
||||||
|
inner = json.loads(inner_str)
|
||||||
|
data_str = inner.get('data', '[]')
|
||||||
|
inner_data = json.loads(data_str)
|
||||||
|
merged_image_url = inner_data.get('merged_image_url')
|
||||||
|
return merged_image_url
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
for p in temp_paths:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_params_for_history(params):
|
||||||
|
"""移除 base64 大字段及敏感字段,仅保留可存储的请求参数"""
|
||||||
|
exclude = ('ref_images', 'proc_images', 'layout_image')
|
||||||
|
out = {}
|
||||||
|
for k, v in (params or {}).items():
|
||||||
|
if k == 'api_key':
|
||||||
|
continue
|
||||||
|
if k in exclude:
|
||||||
|
if isinstance(v, list):
|
||||||
|
out[f'{k}_count'] = len(v)
|
||||||
|
else:
|
||||||
|
out[f'{k}_count'] = 1 if v else 0
|
||||||
|
elif isinstance(v, (str, int, float, bool, type(None))):
|
||||||
|
out[k] = v
|
||||||
|
elif isinstance(v, list) and not v:
|
||||||
|
out[k] = []
|
||||||
|
elif isinstance(v, list) and isinstance(v[0], (str, int, float, bool)):
|
||||||
|
out[k] = v
|
||||||
|
else:
|
||||||
|
out[k] = str(v)[:200] if v else None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@image_bp.route('/api/generate', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_generate():
|
||||||
|
"""生成图片:调用 generate_api,上传原图到 OSS,保存历史记录"""
|
||||||
|
try:
|
||||||
|
params = request.get_json() or {}
|
||||||
|
from generate_api import generate
|
||||||
|
result = generate(params)
|
||||||
|
if result.get('success') and result.get('urls'):
|
||||||
|
long_image_url = result.get("long_image_url")
|
||||||
|
result["long_image_url"] = long_image_url
|
||||||
|
import json as _json
|
||||||
|
history_id = None
|
||||||
|
try:
|
||||||
|
hid = params.get('history_id')
|
||||||
|
if hid is not None:
|
||||||
|
try:
|
||||||
|
hid = int(hid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
hid = None
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if hid is not None and hid > 0:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s",
|
||||||
|
(hid, session['user_id']),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
existing_urls = []
|
||||||
|
if row and row.get('result_urls'):
|
||||||
|
try:
|
||||||
|
existing_urls = _json.loads(row['result_urls'])
|
||||||
|
except Exception:
|
||||||
|
existing_urls = []
|
||||||
|
new_urls = result.get('urls') or []
|
||||||
|
new_url = new_urls[0] if new_urls else None
|
||||||
|
idx = params.get('history_index', 0)
|
||||||
|
try:
|
||||||
|
idx = int(idx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
idx = 0
|
||||||
|
if new_url:
|
||||||
|
if not isinstance(existing_urls, list):
|
||||||
|
existing_urls = []
|
||||||
|
while len(existing_urls) <= idx:
|
||||||
|
existing_urls.append(existing_urls[-1] if existing_urls else new_url)
|
||||||
|
existing_urls[idx] = new_url
|
||||||
|
merged_result_urls = existing_urls or new_urls
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE image_history SET panel_type=%s, original_urls=%s, params=%s, result_urls=%s
|
||||||
|
WHERE id=%s AND user_id=%s""",
|
||||||
|
(
|
||||||
|
params.get('panel_type', ''),
|
||||||
|
_json.dumps(result.get('original_urls') or []),
|
||||||
|
_json.dumps(_sanitize_params_for_history(params)),
|
||||||
|
_json.dumps(merged_result_urls),
|
||||||
|
hid,
|
||||||
|
session['user_id'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
history_id = hid
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||||
|
(
|
||||||
|
session['user_id'],
|
||||||
|
params.get('panel_type', ''),
|
||||||
|
_json.dumps(result.get('original_urls') or []),
|
||||||
|
_json.dumps(_sanitize_params_for_history(params)),
|
||||||
|
_json.dumps(result.get('urls') or []),
|
||||||
|
long_image_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
history_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if history_id is not None:
|
||||||
|
result['history_id'] = history_id
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'success': False, 'urls': [], 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@image_bp.route('/api/version')
|
||||||
|
def api_version():
|
||||||
|
"""检测更新:请求 APP_UPDATE_URL 获取最新版本信息,与当前版本比较"""
|
||||||
|
current_version = (os.environ.get('APP_VERSION', '1.0.0') or '1.0.0').strip()
|
||||||
|
update_url = (os.environ.get('APP_UPDATE_URL', '') or '').strip()
|
||||||
|
result = {
|
||||||
|
'version': current_version,
|
||||||
|
'desc': '',
|
||||||
|
'url': '',
|
||||||
|
'has_update': False,
|
||||||
|
'latest_version': current_version,
|
||||||
|
'file_url': '',
|
||||||
|
}
|
||||||
|
if not update_url:
|
||||||
|
return jsonify(result)
|
||||||
|
try:
|
||||||
|
resp = requests.get(update_url, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json() or {}
|
||||||
|
latest_version = (data.get('version') or '').strip()
|
||||||
|
file_url = (data.get('file_url') or '').strip()
|
||||||
|
result['latest_version'] = latest_version
|
||||||
|
result['file_url'] = file_url
|
||||||
|
result['url'] = file_url
|
||||||
|
# 版本不一致则视为有更新
|
||||||
|
if latest_version and latest_version != current_version:
|
||||||
|
result['has_update'] = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_update_and_exit(zip_path, target_dir):
|
||||||
|
"""在后台延迟后启动 update.exe(脱离当前进程),然后退出当前程序"""
|
||||||
|
def _do():
|
||||||
|
import time
|
||||||
|
time.sleep(1.5) # 确保 HTTP 响应已发送
|
||||||
|
# exe_dir = target_dir
|
||||||
|
# exe_dir = "D:\\pack\\nanri\\update_exe\\main.dist"
|
||||||
|
exe_dir = os.path.join(BASE_DIR,"update")
|
||||||
|
update_exe = os.path.join(exe_dir, 'update.exe')
|
||||||
|
if not os.path.isfile(update_exe):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
creationflags = 0
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
subprocess.Popen(
|
||||||
|
[update_exe, '--zip', zip_path, '--target', target_dir, '--process',client_name],
|
||||||
|
cwd=exe_dir,
|
||||||
|
creationflags=creationflags,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
os._exit(0)
|
||||||
|
t = threading.Thread(target=_do, daemon=False)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
|
||||||
|
@image_bp.route('/api/update/do', methods=['POST'])
|
||||||
|
def api_update_do():
|
||||||
|
"""执行更新:下载 zip 到 tmp,启动 update.exe 后退出程序"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
file_url = (data.get('file_url') or '').strip()
|
||||||
|
if not file_url:
|
||||||
|
return jsonify({'success': False, 'error': '缺少 file_url'}), 400
|
||||||
|
parsed = urlparse(file_url)
|
||||||
|
if parsed.scheme not in ('http', 'https'):
|
||||||
|
return jsonify({'success': False, 'error': '无效的下载地址'}), 400
|
||||||
|
tmp_dir = os.path.join(BASE_DIR, 'tmp')
|
||||||
|
try:
|
||||||
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': f'创建 tmp 目录失败: {e}'}), 500
|
||||||
|
# 使用 URL 中的文件名或默认版本名
|
||||||
|
filename = os.path.basename(parsed.path) or 'update.zip'
|
||||||
|
zip_path = os.path.join(tmp_dir, filename)
|
||||||
|
try:
|
||||||
|
resp = requests.get(file_url, timeout=300, stream=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(zip_path, 'wb') as f:
|
||||||
|
for chunk in resp.iter_content(chunk_size=65536):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return jsonify({'success': False, 'error': f'下载失败: {e}'}), 502
|
||||||
|
_run_update_and_exit(zip_path, BASE_DIR)
|
||||||
|
return jsonify({'success': True, 'message': '更新已启动,程序即将退出'})
|
||||||
|
|
||||||
|
|
||||||
|
@image_bp.route('/api/download')
|
||||||
|
@login_required
|
||||||
|
def api_download():
|
||||||
|
"""代理下载图片,解决跨域 fetch 无法下载的问题"""
|
||||||
|
url = request.args.get('url', '').strip()
|
||||||
|
filename = request.args.get('filename', 'image.png')
|
||||||
|
if not url:
|
||||||
|
return jsonify({'success': False, 'error': '缺少 url 参数'}), 400
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ('http', 'https'):
|
||||||
|
return jsonify({'success': False, 'error': '仅支持 http/https 链接'}), 400
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=30, stream=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
content_type = resp.headers.get('Content-Type', 'image/png')
|
||||||
|
encoded = quote(filename, safe='')
|
||||||
|
disposition = f"attachment; filename*=UTF-8''{encoded}"
|
||||||
|
return Response(
|
||||||
|
resp.iter_content(chunk_size=8192),
|
||||||
|
mimetype=content_type,
|
||||||
|
headers={'Content-Disposition': disposition}
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@image_bp.route('/api/stitch/save', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_stitch_save():
|
||||||
|
"""手动拼接:接收图片 URL 列表(支持 http 或 data URL),拼接并上传,返回长图 URL"""
|
||||||
|
try:
|
||||||
|
data = request.get_json() or {}
|
||||||
|
urls = data.get('urls')
|
||||||
|
if not urls or not isinstance(urls, list):
|
||||||
|
return jsonify({'success': False, 'error': '请提供 urls 数组'}), 400
|
||||||
|
urls = [u for u in urls if u and isinstance(u, str)]
|
||||||
|
if not urls:
|
||||||
|
return jsonify({'success': False, 'error': '没有有效的图片'}), 400
|
||||||
|
long_image_url = _stitch_and_upload_long_image(urls)
|
||||||
|
if not long_image_url:
|
||||||
|
return jsonify({'success': False, 'error': '拼接或上传失败'}), 500
|
||||||
|
return jsonify({'success': True, 'long_image_url': long_image_url})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@image_bp.route('/api/history')
|
||||||
|
@login_required
|
||||||
|
def api_history():
|
||||||
|
"""分页获取当前用户的历史图库,支持按 panel_type 栏目筛选"""
|
||||||
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
|
page_size = min(50, max(10, int(request.args.get('page_size', 20))))
|
||||||
|
panel_type = (request.args.get('panel_type') or '').strip()
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
where_user = "user_id = %s"
|
||||||
|
params_where = [session['user_id']]
|
||||||
|
if panel_type:
|
||||||
|
where_user += " AND panel_type = %s"
|
||||||
|
params_where.append(panel_type)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, created_at, panel_type, original_urls, params, result_urls, long_image_url
|
||||||
|
FROM image_history WHERE """ + where_user + """ ORDER BY created_at DESC LIMIT %s OFFSET %s""",
|
||||||
|
params_where + [page_size, offset],
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.execute("SELECT COUNT(*) as total FROM image_history WHERE " + where_user, params_where)
|
||||||
|
total = cur.fetchone()['total']
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _parse_json(val, default=None):
|
||||||
|
if val is None:
|
||||||
|
return default if default is not None else []
|
||||||
|
if isinstance(val, (list, dict)):
|
||||||
|
return val
|
||||||
|
try:
|
||||||
|
return json.loads(val)
|
||||||
|
except Exception:
|
||||||
|
return default if default is not None else []
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
items.append({
|
||||||
|
'id': r['id'],
|
||||||
|
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
|
||||||
|
'panel_type': r['panel_type'] or '',
|
||||||
|
'original_urls': _parse_json(r['original_urls'], []),
|
||||||
|
'params': _parse_json(r['params'], {}),
|
||||||
|
'result_urls': _parse_json(r['result_urls'], []),
|
||||||
|
'long_image_url': (r.get('long_image_url') or '').strip() or None,
|
||||||
|
})
|
||||||
|
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
69
blueprints/main.py
Normal file
69
blueprints/main.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from flask import Blueprint, send_file
|
||||||
|
|
||||||
|
from app_common import (
|
||||||
|
get_db,
|
||||||
|
_render_html,
|
||||||
|
_is_session_user_valid,
|
||||||
|
login_required,
|
||||||
|
admin_required,
|
||||||
|
STATIC_DIR,
|
||||||
|
BASE_DIR,
|
||||||
|
)
|
||||||
|
from flask import redirect, url_for, session
|
||||||
|
|
||||||
|
from config import base_url,version
|
||||||
|
|
||||||
|
main_bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/')
|
||||||
|
def index():
|
||||||
|
if session.get('user_id') and _is_session_user_valid():
|
||||||
|
return redirect(url_for('main.home'))
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/home')
|
||||||
|
@login_required
|
||||||
|
def home():
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version)
|
||||||
|
except Exception:
|
||||||
|
return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/image')
|
||||||
|
@login_required
|
||||||
|
def wb():
|
||||||
|
return _render_html('index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/brand')
|
||||||
|
@login_required
|
||||||
|
def brand_page():
|
||||||
|
return _render_html('brand.html')
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/static/<path:filename>')
|
||||||
|
def serve_static(filename):
|
||||||
|
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||||
|
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
|
||||||
|
static_abs = os.path.abspath(STATIC_DIR)
|
||||||
|
file_abs = os.path.abspath(filepath)
|
||||||
|
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
|
||||||
|
return '', 404
|
||||||
|
return send_file(file_abs, as_attachment=False)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/logo.jpg', methods=['GET'])
|
||||||
|
def get_logo_image():
|
||||||
|
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')
|
||||||
BIN
brand_spider/__pycache__/main.cpython-39.pyc
Normal file
BIN
brand_spider/__pycache__/main.cpython-39.pyc
Normal file
Binary file not shown.
BIN
brand_spider/__pycache__/web_dec.cpython-39.pyc
Normal file
BIN
brand_spider/__pycache__/web_dec.cpython-39.pyc
Normal file
Binary file not shown.
692
brand_spider/main.py
Normal file
692
brand_spider/main.py
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
import random
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
import openpyxl
|
||||||
|
import unicodedata
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
from brand_spider.web_dec import decrypt_via_service, get_guid
|
||||||
|
|
||||||
|
try:
|
||||||
|
from config import proxy_url as CONFIG_PROXY_URL, proxy_mode as CONFIG_PROXY_MODE
|
||||||
|
except ImportError:
|
||||||
|
CONFIG_PROXY_URL = None
|
||||||
|
CONFIG_PROXY_MODE = 1
|
||||||
|
|
||||||
|
# Forbidden 后 3 分钟内统一使用代理:记录代理生效截止时间与当前代理
|
||||||
|
_FORBIDDEN_PROXY_UNTIL = 0.0
|
||||||
|
_FORBIDDEN_PROXY_DICT = None
|
||||||
|
_FORBIDDEN_PROXY_MINUTES = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
special_char_pattern = re.compile(r'[^\w\s]')
|
||||||
|
|
||||||
|
def remove_accents(input_str):
|
||||||
|
# 将字符分解为基础字符和重音符号
|
||||||
|
nksel = unicodedata.normalize('NFKD', input_str)
|
||||||
|
# 过滤掉非间距重音符号,并重新编码
|
||||||
|
return "".join([c for c in nksel if not unicodedata.combining(c)])
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text(text):
|
||||||
|
text = remove_accents(text)
|
||||||
|
return special_char_pattern.sub(' ', text).replace(" ","")
|
||||||
|
|
||||||
|
|
||||||
|
def create_code_generator():
|
||||||
|
counter = random.randint(0, 0xFFFFFF)
|
||||||
|
def get_code():
|
||||||
|
nonlocal counter
|
||||||
|
counter = (counter + 1) % 0xFFFFFF
|
||||||
|
low_16_bits = counter & 0xFFFF
|
||||||
|
return f"{low_16_bits:04x}"
|
||||||
|
return get_code()
|
||||||
|
|
||||||
|
|
||||||
|
def search(hashsearch, data, proxies=None):
|
||||||
|
global _FORBIDDEN_PROXY_UNTIL, _FORBIDDEN_PROXY_DICT
|
||||||
|
|
||||||
|
url = "https://api.branddb.wipo.int/search"
|
||||||
|
headers = {
|
||||||
|
"accept": "application/json, text/plain, */*",
|
||||||
|
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"hashsearch": hashsearch,
|
||||||
|
"origin": "https://branddb.wipo.int",
|
||||||
|
"pragma": "no-cache",
|
||||||
|
"priority": "u=1, i",
|
||||||
|
"referer": "https://branddb.wipo.int/",
|
||||||
|
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 若 3 分钟内曾出现 Forbidden,则直接使用当时保存的代理
|
||||||
|
now = time.time()
|
||||||
|
if now < _FORBIDDEN_PROXY_UNTIL and _FORBIDDEN_PROXY_DICT:
|
||||||
|
proxies = _FORBIDDEN_PROXY_DICT
|
||||||
|
print("处于 Forbidden 代理窗口内,直接使用代理",proxies)
|
||||||
|
|
||||||
|
# 发送 POST 请求
|
||||||
|
response = requests.post(url, headers=headers, json=data, proxies=proxies)
|
||||||
|
enc_text = response.text
|
||||||
|
# print("原始结果", enc_text)
|
||||||
|
|
||||||
|
# 若返回 Forbidden,则从 config 的 proxy_url 获取代理 IP 后重试,并开启 3 分钟代理窗口
|
||||||
|
if enc_text.strip() == '{"message":"Forbidden"}' and CONFIG_PROXY_URL:
|
||||||
|
try:
|
||||||
|
proxy_resp = requests.get(CONFIG_PROXY_URL, timeout=10)
|
||||||
|
print("代理请求结果->:", proxy_resp.text)
|
||||||
|
# 模式 2:账号密码代理,接口返回 JSON
|
||||||
|
if CONFIG_PROXY_MODE == 2:
|
||||||
|
resp_json = proxy_resp.json()
|
||||||
|
proxy_list = resp_json.get("data", {}).get("list") or []
|
||||||
|
first_item = proxy_list[0] if proxy_list else None
|
||||||
|
if first_item:
|
||||||
|
ip = first_item.get("ip")
|
||||||
|
port = first_item.get("port")
|
||||||
|
account = first_item.get("account")
|
||||||
|
password = first_item.get("password")
|
||||||
|
if ip and port and account and password:
|
||||||
|
auth_proxy = f"{account}:{password}@{ip}:{port}"
|
||||||
|
print("获取到账号密码代理:", auth_proxy)
|
||||||
|
proxies = {
|
||||||
|
"http": f"http://{auth_proxy}",
|
||||||
|
"https": f"http://{auth_proxy}",
|
||||||
|
}
|
||||||
|
# 默认模式 1:普通 IP:port 文本
|
||||||
|
if CONFIG_PROXY_MODE != 2:
|
||||||
|
proxy_ip = (proxy_resp.text or "").strip()
|
||||||
|
if proxy_ip:
|
||||||
|
proxies = {
|
||||||
|
"http": f"http://{proxy_ip}",
|
||||||
|
"https": f"http://{proxy_ip}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxies:
|
||||||
|
response = requests.post(url, headers=headers, json=data, proxies=proxies)
|
||||||
|
enc_text = response.text
|
||||||
|
print("代理重试结果", enc_text)
|
||||||
|
# 记录 3 分钟内都使用该代理
|
||||||
|
_FORBIDDEN_PROXY_UNTIL = now + _FORBIDDEN_PROXY_MINUTES * 60
|
||||||
|
_FORBIDDEN_PROXY_DICT = proxies
|
||||||
|
except Exception as e:
|
||||||
|
print("获取代理或重试失败:", e)
|
||||||
|
|
||||||
|
return enc_text
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCancelledError(Exception):
|
||||||
|
"""用户取消任务时抛出,供上层捕获并中止"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def single_file_handle(file_path, output_path, strategy="Terms", check_cancelled=None, progress_callback=None):
|
||||||
|
status_info = {
|
||||||
|
"Ended": "已结束",
|
||||||
|
"Expired": "已过期的",
|
||||||
|
"Pending": "待决",
|
||||||
|
"Registered": "已注册",
|
||||||
|
"RegisteredMadrid": "国际注册有效",
|
||||||
|
"Unknown": "未知"
|
||||||
|
}
|
||||||
|
country_info = {
|
||||||
|
"AB": "ARABPAT",
|
||||||
|
"AD": "安道尔",
|
||||||
|
"AE": "UAE",
|
||||||
|
"AF": "阿富汗",
|
||||||
|
"AFR": "非洲",
|
||||||
|
"AG": "安提瓜和巴布达",
|
||||||
|
"AI": "安圭拉",
|
||||||
|
"AL": "阿尔巴尼亚",
|
||||||
|
"AM": "亚美尼亚",
|
||||||
|
"AN": "荷属安的列斯",
|
||||||
|
"ANT": "南极洲",
|
||||||
|
"AO": "安哥拉",
|
||||||
|
"AP": "非洲地区知识产权组织 ",
|
||||||
|
"AQ": "南极洲",
|
||||||
|
"AR": "阿根廷",
|
||||||
|
"AS": "美属萨摩亚",
|
||||||
|
"ASI": "亚洲",
|
||||||
|
"AT": "奥地利",
|
||||||
|
"AU": "澳大利亚",
|
||||||
|
"AW": "阿鲁巴",
|
||||||
|
"AX": "奥兰群岛",
|
||||||
|
"AZ": "阿塞拜疆",
|
||||||
|
"BA": "波斯尼亚和黑塞哥维纳",
|
||||||
|
"BB": "巴巴多斯",
|
||||||
|
"BD": "孟加拉国",
|
||||||
|
"BE": "比利时",
|
||||||
|
"BF": "布基纳法索",
|
||||||
|
"BG": "保加利亚",
|
||||||
|
"BH": "巴林",
|
||||||
|
"BI": "布隆迪",
|
||||||
|
"BJ": "贝宁",
|
||||||
|
"BL": "圣巴泰勒米",
|
||||||
|
"BM": "百慕大",
|
||||||
|
"BN": "文莱达鲁萨兰国",
|
||||||
|
"BO": "多民族玻利维亚国",
|
||||||
|
"BQ": "博纳尔、圣俄斯塔休斯和萨巴",
|
||||||
|
"BR": "巴西",
|
||||||
|
"BS": "巴哈马",
|
||||||
|
"BT": "不丹",
|
||||||
|
"BV": "布韦岛",
|
||||||
|
"BW": "博茨瓦纳",
|
||||||
|
"BX": "比荷卢知识产权局",
|
||||||
|
"BY": "白俄罗斯",
|
||||||
|
"BZ": "伯利兹",
|
||||||
|
"CA": "加拿大",
|
||||||
|
"CC": "科科斯群岛(基灵群岛)",
|
||||||
|
"CD": "刚果民主共和国",
|
||||||
|
"CF": "中非共和国",
|
||||||
|
"CG": "刚果",
|
||||||
|
"CH": "瑞士",
|
||||||
|
"CI": "科特迪瓦",
|
||||||
|
"CK": "库克群岛",
|
||||||
|
"CL": "智利",
|
||||||
|
"CM": "喀麦隆",
|
||||||
|
"CN": "中国",
|
||||||
|
"CO": "哥伦比亚",
|
||||||
|
"CR": "哥斯达黎加",
|
||||||
|
"CS": "捷克斯洛伐克",
|
||||||
|
"CU": "古巴",
|
||||||
|
"CV": "佛得角",
|
||||||
|
"CW": "库拉索",
|
||||||
|
"CX": "圣诞岛",
|
||||||
|
"CY": "塞浦路斯",
|
||||||
|
"CZ": "捷克共和国",
|
||||||
|
"DD": "德意志民主共和国",
|
||||||
|
"DE": "德国",
|
||||||
|
"DJ": "吉布提",
|
||||||
|
"DK": "丹麦",
|
||||||
|
"DM": "多米尼克",
|
||||||
|
"DO": "多米尼加",
|
||||||
|
"DT": "西德",
|
||||||
|
"DZ": "阿尔及利亚",
|
||||||
|
"EA": "欧亚专利组织",
|
||||||
|
"EC": "厄瓜多尔",
|
||||||
|
"EE": "爱沙尼亚",
|
||||||
|
"EG": "埃及",
|
||||||
|
"EH": "西撒哈拉",
|
||||||
|
"EM": "欧洲联盟",
|
||||||
|
"EP": "欧洲专利局",
|
||||||
|
"ER": "厄立特里亚",
|
||||||
|
"ES": "西班牙",
|
||||||
|
"ET": "埃塞俄比亚",
|
||||||
|
"EUR": "欧洲",
|
||||||
|
"FI": "芬兰",
|
||||||
|
"FJ": "斐济",
|
||||||
|
"FK": "福克兰群岛(马尔维纳斯群岛)",
|
||||||
|
"FM": "密克罗尼西亚联邦",
|
||||||
|
"FO": "法罗群岛",
|
||||||
|
"FR": "法国",
|
||||||
|
"GA": "加蓬",
|
||||||
|
"GB": "英国",
|
||||||
|
"GC": "海湾阿拉伯国家合作委员会专利局",
|
||||||
|
"GD": "格林纳达",
|
||||||
|
"GE": "格鲁吉亚",
|
||||||
|
"GF": "法属圭亚那",
|
||||||
|
"GG": "格恩西岛",
|
||||||
|
"GH": "加纳",
|
||||||
|
"GI": "直布罗陀",
|
||||||
|
"GL": "格陵兰",
|
||||||
|
"GM": "冈比亚",
|
||||||
|
"GN": "几内亚",
|
||||||
|
"GP": "瓜德罗普",
|
||||||
|
"GQ": "赤道几内亚",
|
||||||
|
"GR": "希腊",
|
||||||
|
"GS": "南乔治亚和南桑威奇群岛",
|
||||||
|
"GT": "危地马拉",
|
||||||
|
"GU": "关岛",
|
||||||
|
"GW": "几内亚比绍",
|
||||||
|
"GY": "圭亚那",
|
||||||
|
"HK": "香港",
|
||||||
|
"HM": "赫德岛和麦克唐纳群岛",
|
||||||
|
"HN": "洪都拉斯",
|
||||||
|
"HR": "克罗地亚",
|
||||||
|
"HT": "海地",
|
||||||
|
"HU": "匈牙利",
|
||||||
|
"IB": "世界知识产权组织国际局",
|
||||||
|
"ID": "印度尼西亚",
|
||||||
|
"IE": "爱尔兰",
|
||||||
|
"IL": "以色列",
|
||||||
|
"IM": "马恩岛",
|
||||||
|
"IN": "印度",
|
||||||
|
"INN": "世界卫生组织",
|
||||||
|
"IO": "英属印度洋领地",
|
||||||
|
"IQ": "伊拉克",
|
||||||
|
"IR": "伊朗伊斯兰共和国",
|
||||||
|
"IS": "冰岛",
|
||||||
|
"IT": "意大利",
|
||||||
|
"JE": "泽西岛",
|
||||||
|
"JM": "牙买加",
|
||||||
|
"JO": "约旦",
|
||||||
|
"JP": "日本",
|
||||||
|
"KE": "肯尼亚",
|
||||||
|
"KG": "吉尔吉斯斯坦",
|
||||||
|
"KH": "柬埔寨",
|
||||||
|
"KI": "基里巴斯",
|
||||||
|
"KM": "科摩罗",
|
||||||
|
"KN": "圣基茨和尼维斯",
|
||||||
|
"KP": "朝鲜民主主义人民共和国",
|
||||||
|
"KR": "大韩民国",
|
||||||
|
"KW": "科威特",
|
||||||
|
"KY": "开曼群岛",
|
||||||
|
"KZ": "哈萨克斯坦",
|
||||||
|
"LA": "老挝人民民主共和国",
|
||||||
|
"LB": "黎巴嫩",
|
||||||
|
"LC": "圣卢西亚",
|
||||||
|
"LI": "列支敦士登",
|
||||||
|
"LISBON": "WIPO",
|
||||||
|
"LK": "斯里兰卡",
|
||||||
|
"LP": "LATIPAT",
|
||||||
|
"LR": "利比里亚",
|
||||||
|
"LS": "莱索托",
|
||||||
|
"LT": "立陶宛",
|
||||||
|
"LU": "卢森堡",
|
||||||
|
"LV": "拉脱维亚",
|
||||||
|
"LY": "利比亚",
|
||||||
|
"MA": "摩洛哥",
|
||||||
|
"MC": "摩纳哥",
|
||||||
|
"MD": "摩尔多瓦共和国",
|
||||||
|
"ME": "黑山",
|
||||||
|
"MF": "圣马丁(法国部分)",
|
||||||
|
"MG": "马达加斯加",
|
||||||
|
"MH": "马绍尔群岛",
|
||||||
|
"MK": "北马其顿共和国",
|
||||||
|
"ML": "马里",
|
||||||
|
"MM": "缅甸",
|
||||||
|
"MN": "蒙古",
|
||||||
|
"MO": "澳门",
|
||||||
|
"MP": "北马里亚纳群岛",
|
||||||
|
"MQ": "马提尼克",
|
||||||
|
"MR": "毛里塔尼亚",
|
||||||
|
"MS": "蒙特塞拉特",
|
||||||
|
"MT": "马耳他",
|
||||||
|
"MU": "毛里求斯",
|
||||||
|
"MV": "马尔代夫",
|
||||||
|
"MW": "马拉维",
|
||||||
|
"MX": "墨西哥",
|
||||||
|
"MY": "马来西亚",
|
||||||
|
"MZ": "莫桑比克",
|
||||||
|
"NA": "纳米比亚",
|
||||||
|
"NAM": "北美洲",
|
||||||
|
"NC": "新喀里多尼亚",
|
||||||
|
"NE": "尼日尔",
|
||||||
|
"NF": "诺福克岛",
|
||||||
|
"NG": "尼日利亚",
|
||||||
|
"NI": "尼加拉瓜",
|
||||||
|
"NL": "荷兰",
|
||||||
|
"NO": "挪威",
|
||||||
|
"NP": "尼泊尔",
|
||||||
|
"NR": "瑙鲁",
|
||||||
|
"NU": "纽埃",
|
||||||
|
"NZ": "新西兰",
|
||||||
|
"OA": "非洲知识产权组织",
|
||||||
|
"OCE": "大洋洲",
|
||||||
|
"OM": "阿曼",
|
||||||
|
"PA": "巴拿马",
|
||||||
|
"PE": "秘鲁",
|
||||||
|
"PF": "法属波利尼西亚",
|
||||||
|
"PG": "巴布亚新几内亚",
|
||||||
|
"PH": "菲律宾",
|
||||||
|
"PK": "巴基斯坦",
|
||||||
|
"PL": "波兰",
|
||||||
|
"PM": "圣皮埃尔和密克隆",
|
||||||
|
"PN": "皮特凯恩",
|
||||||
|
"PR": "波多黎各",
|
||||||
|
"PS": "巴勒斯坦",
|
||||||
|
"PT": "葡萄牙",
|
||||||
|
"PW": "帕劳",
|
||||||
|
"PY": "巴拉圭",
|
||||||
|
"QA": "卡塔尔",
|
||||||
|
"QO": "没有ST.3代码的组织",
|
||||||
|
"QZ": "欧洲联盟",
|
||||||
|
"RE": "留尼汪",
|
||||||
|
"RO": "罗马尼亚",
|
||||||
|
"RS": "塞尔维亚",
|
||||||
|
"RU": "俄罗斯联邦",
|
||||||
|
"RW": "卢旺达",
|
||||||
|
"SA": "沙特阿拉伯",
|
||||||
|
"SAM": "南美洲",
|
||||||
|
"SB": "所罗门群岛",
|
||||||
|
"SC": "塞舌尔",
|
||||||
|
"SD": "苏丹",
|
||||||
|
"SE": "瑞典",
|
||||||
|
"SG": "新加坡",
|
||||||
|
"SH": "圣赫勒拿、阿森松和特里斯坦-达库尼亚",
|
||||||
|
"SI": "斯洛文尼亚",
|
||||||
|
"SIXTER": "WIPO",
|
||||||
|
"SJ": "斯瓦尔巴和扬马延",
|
||||||
|
"SK": "斯洛伐克",
|
||||||
|
"SL": "塞拉里昂",
|
||||||
|
"SM": "圣马力诺",
|
||||||
|
"SN": "塞内加尔",
|
||||||
|
"SO": "索马里",
|
||||||
|
"SR": "苏里南",
|
||||||
|
"SS": "南苏丹",
|
||||||
|
"ST": "圣多美和普林西比",
|
||||||
|
"SU": "苏联",
|
||||||
|
"SV": "萨尔瓦多",
|
||||||
|
"SX": "圣马丁(荷兰部分)",
|
||||||
|
"SY": "阿拉伯叙利亚共和国",
|
||||||
|
"SZ": "斯威士兰",
|
||||||
|
"TC": "特克斯和凯科斯群岛",
|
||||||
|
"TD": "乍得",
|
||||||
|
"TF": "法属南部领地",
|
||||||
|
"TG": "多哥",
|
||||||
|
"TH": "泰国",
|
||||||
|
"TJ": "塔吉克斯坦",
|
||||||
|
"TK": "托克劳",
|
||||||
|
"TL": "东帝汶",
|
||||||
|
"TM": "土库曼斯坦",
|
||||||
|
"TN": "突尼斯",
|
||||||
|
"TO": "汤加",
|
||||||
|
"TR": "土耳其",
|
||||||
|
"TT": "特立尼达和多巴哥",
|
||||||
|
"TV": "图瓦卢",
|
||||||
|
"TW": "台湾(中国的省)",
|
||||||
|
"TZ": "坦桑尼亚联合共和国",
|
||||||
|
"UA": "乌克兰",
|
||||||
|
"UG": "乌干达",
|
||||||
|
"UK": "UK",
|
||||||
|
"UM": "美国本土外小岛屿",
|
||||||
|
"US": "USA",
|
||||||
|
"UY": "乌拉圭",
|
||||||
|
"UZ": "乌兹别克斯坦",
|
||||||
|
"VA": "罗马教廷",
|
||||||
|
"VC": "圣文森特和格林纳丁斯",
|
||||||
|
"VE": "委内瑞拉玻利瓦尔共和国",
|
||||||
|
"VG": "英属维尔京群岛",
|
||||||
|
"VI": "美属维尔京群岛",
|
||||||
|
"VN": "越南",
|
||||||
|
"VU": "瓦努阿图",
|
||||||
|
"WF": "瓦利斯和富图纳",
|
||||||
|
"WHO": "世界卫生组织",
|
||||||
|
"WO": "WIPO",
|
||||||
|
"WS": "萨摩亚",
|
||||||
|
"XK": "科索沃共和国",
|
||||||
|
"XN": "北欧专利局",
|
||||||
|
"XX": "国际",
|
||||||
|
"XXX": "跨国和国际局",
|
||||||
|
"YD": "民主也门",
|
||||||
|
"YE": "也门",
|
||||||
|
"YT": "马约特岛",
|
||||||
|
"YU": "塞尔维亚和黑山",
|
||||||
|
"ZA": "南非",
|
||||||
|
"ZM": "赞比亚",
|
||||||
|
"ZW": "津巴布韦"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_value = [
|
||||||
|
"瑞典",
|
||||||
|
"芬兰",
|
||||||
|
"丹麦",
|
||||||
|
"挪威",
|
||||||
|
"冰岛",
|
||||||
|
"法国",
|
||||||
|
"爱尔兰",
|
||||||
|
"荷兰",
|
||||||
|
"比利时",
|
||||||
|
"卢森堡",
|
||||||
|
"英国",
|
||||||
|
"摩纳哥",
|
||||||
|
"德国",
|
||||||
|
"波兰",
|
||||||
|
"捷克",
|
||||||
|
"斯洛伐克",
|
||||||
|
"匈牙利",
|
||||||
|
"奥地利",
|
||||||
|
"瑞士",
|
||||||
|
"列支敦士登",
|
||||||
|
"爱沙尼亚",
|
||||||
|
"拉脱维亚",
|
||||||
|
"立陶宛",
|
||||||
|
"俄罗斯",
|
||||||
|
"白俄罗斯",
|
||||||
|
"乌克兰",
|
||||||
|
"摩尔多瓦",
|
||||||
|
"西班牙",
|
||||||
|
"葡萄牙",
|
||||||
|
"意大利",
|
||||||
|
"希腊",
|
||||||
|
"斯洛文尼亚",
|
||||||
|
"克罗地亚",
|
||||||
|
"罗马尼亚",
|
||||||
|
"保加利亚",
|
||||||
|
"塞尔维亚",
|
||||||
|
"阿尔巴尼亚",
|
||||||
|
"黑山",
|
||||||
|
"马耳他",
|
||||||
|
"塞浦路斯",
|
||||||
|
"北马其顿",
|
||||||
|
"梵蒂冈",
|
||||||
|
"圣马力诺",
|
||||||
|
"安道尔",
|
||||||
|
"波黑",
|
||||||
|
"欧洲联盟"
|
||||||
|
]
|
||||||
|
check_staus = ["已过期的","已结束"]
|
||||||
|
# 1. 读取 Excel 活动工作表
|
||||||
|
wb = openpyxl.load_workbook(file_path, data_only=True)
|
||||||
|
active_sheet = wb.active
|
||||||
|
active_sheet_name = active_sheet.title
|
||||||
|
print(active_sheet_name)
|
||||||
|
|
||||||
|
# 2. 将数据转换为列表嵌套字典
|
||||||
|
rows = list(active_sheet.iter_rows(values_only=True))
|
||||||
|
if rows:
|
||||||
|
columns = list(rows[0])
|
||||||
|
data_rows = []
|
||||||
|
for row in rows[1:]:
|
||||||
|
row_dict = {}
|
||||||
|
for idx, col in enumerate(columns):
|
||||||
|
row_dict[col] = row[idx] if idx < len(row) else None
|
||||||
|
data_rows.append(row_dict)
|
||||||
|
else:
|
||||||
|
columns = []
|
||||||
|
data_rows = []
|
||||||
|
|
||||||
|
# 3. 确保必要列存在
|
||||||
|
# required_cols = ["状态", "国家", "时间"]
|
||||||
|
# for col in required_cols:
|
||||||
|
# if col not in columns:
|
||||||
|
# columns.append(col)
|
||||||
|
# for row_dict in data_rows:
|
||||||
|
# row_dict[col] = ""
|
||||||
|
|
||||||
|
# 4. 提取唯一品牌列表
|
||||||
|
brand_ls = set()
|
||||||
|
for row in data_rows:
|
||||||
|
brand = row.get("品牌")
|
||||||
|
if brand:
|
||||||
|
brand_ls.add(brand)
|
||||||
|
|
||||||
|
# 5. 初始化不符合品牌的数据列表
|
||||||
|
faild_data = []
|
||||||
|
# 初始化查询失败的品牌的数据列表
|
||||||
|
query_faild_data = []
|
||||||
|
|
||||||
|
# 6. 对每个品牌进行处理
|
||||||
|
brands = list(brand_ls)
|
||||||
|
total_brands = len(brands)
|
||||||
|
for idx, brand in enumerate(brands, start=1):
|
||||||
|
if check_cancelled and check_cancelled():
|
||||||
|
raise TaskCancelledError()
|
||||||
|
|
||||||
|
# 行级进度回调:当前第 idx 条 / total_brands
|
||||||
|
if progress_callback and total_brands > 0:
|
||||||
|
try:
|
||||||
|
progress_callback(idx, total_brands, extra={'current_brand': str(brand)})
|
||||||
|
except Exception:
|
||||||
|
# 回调失败不影响主流程
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Simple 策略下最多翻 3 页(0,30,60),直到命中 554 行判断
|
||||||
|
max_pages = 3 if strategy == "Simple" else 1
|
||||||
|
page = 0
|
||||||
|
matched = False
|
||||||
|
|
||||||
|
while page < max_pages and not matched:
|
||||||
|
try:
|
||||||
|
as_structure_dict = {
|
||||||
|
"_id": create_code_generator(),
|
||||||
|
"boolean": "AND",
|
||||||
|
"bricks": [
|
||||||
|
{
|
||||||
|
"_id": create_code_generator(),
|
||||||
|
"key": "brandName",
|
||||||
|
"value": brand,
|
||||||
|
"strategy": strategy
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
# 构造 asStructure 内部 JSON 对象
|
||||||
|
as_structure_dict = as_structure_dict
|
||||||
|
# print(as_structure_dict)
|
||||||
|
|
||||||
|
# 将内部对象转为 JSON 字符串(作为 asStructure 字段的值)
|
||||||
|
as_structure_str = json.dumps(as_structure_dict, ensure_ascii=False)
|
||||||
|
data = {
|
||||||
|
"sort": "score desc",
|
||||||
|
"rows": "30",
|
||||||
|
"asStructure": as_structure_str,
|
||||||
|
"fg": "_void_"
|
||||||
|
}
|
||||||
|
# 翻页:第二页 start=30,第三页 start=60(仅 Simple 策略)
|
||||||
|
if strategy == "Simple" and page > 0:
|
||||||
|
data["start"] = str(30 * page)
|
||||||
|
|
||||||
|
# print("请求参数", as_structure_dict, "页码:", page)
|
||||||
|
hashsearch = get_guid()
|
||||||
|
enc_text = search(hashsearch, data)
|
||||||
|
print(f"【{hashsearch}】待解密-->", enc_text)
|
||||||
|
dec_text = decrypt_via_service(hashsearch, enc_text)
|
||||||
|
# print(type(dec_text))
|
||||||
|
dec_text = str(dec_text)
|
||||||
|
# print(f"解密之后的结果-->", dec_text)
|
||||||
|
data = json.loads(dec_text)
|
||||||
|
except Exception as e:
|
||||||
|
# 仅第一页请求失败时记录为查询失败品牌
|
||||||
|
if page == 0:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
safe_brand = brand.encode('gbk', errors='replace').decode('gbk')
|
||||||
|
print(f"品牌:{safe_brand},处理失败:{e}")
|
||||||
|
query_faild_data.append({"品牌": brand, "时间": datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")})
|
||||||
|
break
|
||||||
|
|
||||||
|
# print(data)
|
||||||
|
|
||||||
|
if data.get("response", {}).get("numFound", 0) > 0:
|
||||||
|
if strategy == "Simple":
|
||||||
|
new_brand_name = clean_text(brand)
|
||||||
|
_brand = new_brand_name.lower()
|
||||||
|
else:
|
||||||
|
_brand = brand
|
||||||
|
|
||||||
|
docs = data["response"]["docs"]
|
||||||
|
for d in docs:
|
||||||
|
office = d.get("office")
|
||||||
|
status = d.get("status")
|
||||||
|
registrationDate = d.get("registrationDate")
|
||||||
|
status_name = status_info.get(status, status)
|
||||||
|
brand_name = d.get("brandName")
|
||||||
|
if isinstance(brand_name, list):
|
||||||
|
brand_name = "|".join(brand_name)
|
||||||
|
|
||||||
|
if strategy == "Simple":
|
||||||
|
new_brand_name = clean_text(brand_name)
|
||||||
|
new_brand_name = new_brand_name.lower()
|
||||||
|
if _brand != new_brand_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
check_office = d.get("designation")
|
||||||
|
|
||||||
|
check_office.append(office)
|
||||||
|
for i in set(check_office):
|
||||||
|
office_name = country_info.get(i, i)
|
||||||
|
# print(office_name,office_name in check_value and status_name not in check_staus)
|
||||||
|
if office_name in check_value and status_name not in check_staus:
|
||||||
|
# 命中 554 行判断,记录并结束当前品牌后续翻页
|
||||||
|
data_rows = [row for row in data_rows if row.get("品牌") != brand]
|
||||||
|
# print(office_name, office in ["EM","QZ"] and i not in ["EM","QZ"])
|
||||||
|
if office in ["EM","QZ"] and i not in ["EM","QZ"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if strategy == "Simple":
|
||||||
|
faild_data.append({"品牌": brand, "国家": office_name, "状态": status_name, "原因":"|".join(d.get("brandName","")),"时间": datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")})
|
||||||
|
matched = True
|
||||||
|
else:
|
||||||
|
# print("添加",brand,office_name)
|
||||||
|
faild_data.append({"品牌": brand, "国家": office_name, "状态": status_name, "时间": datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")})
|
||||||
|
# break
|
||||||
|
if matched:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 如果 Simple 策略下本页未命中,则翻下一页;其他策略不翻页
|
||||||
|
if not matched:
|
||||||
|
page += 1
|
||||||
|
# else:
|
||||||
|
# # 更新主表数据
|
||||||
|
# for row in data_rows:
|
||||||
|
# if row.get("品牌") == brand:
|
||||||
|
# row["状态"] = status_name
|
||||||
|
# row["国家"] = office_name
|
||||||
|
# row["时间"] = datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S") # 品牌匹配行
|
||||||
|
# else:
|
||||||
|
# # 非匹配行:时间 = 当前国家值(模拟原 mask 逻辑)
|
||||||
|
# row["时间"] = ""
|
||||||
|
|
||||||
|
# 7. 创建输出工作簿
|
||||||
|
out_wb = Workbook()
|
||||||
|
out_wb.remove(out_wb.active) # 删除默认 sheet
|
||||||
|
|
||||||
|
# 主工作表
|
||||||
|
main_ws = out_wb.create_sheet(title=active_sheet_name)
|
||||||
|
main_ws.append(columns)
|
||||||
|
for row_dict in data_rows:
|
||||||
|
main_ws.append([row_dict.get(col, "") for col in columns])
|
||||||
|
|
||||||
|
# 不符合品牌工作表
|
||||||
|
faild_ws = out_wb.create_sheet(title="不符合品牌")
|
||||||
|
if strategy == "Simple":
|
||||||
|
faild_columns = ["品牌", "国家", "状态","原因"]
|
||||||
|
else:
|
||||||
|
faild_columns = ["品牌", "国家", "状态"]
|
||||||
|
faild_ws.append(faild_columns)
|
||||||
|
for row_dict in faild_data:
|
||||||
|
# print("写入",row_dict)
|
||||||
|
faild_ws.append([row_dict.get(col, "") for col in faild_columns])
|
||||||
|
|
||||||
|
# 查询失败品牌工作表
|
||||||
|
query_faild_ws = out_wb.create_sheet(title="查询失败品牌")
|
||||||
|
query_faild_columns = ["品牌", "时间"]
|
||||||
|
query_faild_ws.append(query_faild_columns)
|
||||||
|
for row_dict in query_faild_data:
|
||||||
|
query_faild_ws.append([row_dict.get(col, "") for col in query_faild_columns])
|
||||||
|
|
||||||
|
out_wb.save(output_path)
|
||||||
|
print("生成完成--->", output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
file_path = r"D:\\私单交付\\maixiang_AI\\测试图片数据\\品牌采集测试\\brand_task_73\\源文件\\brand_task_75\\源文件\\3.10.xlsx"
|
||||||
|
res = single_file_handle(file_path,"结果.xlsx")
|
||||||
|
print(res)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
126
brand_spider/web_dec.py
Normal file
126
brand_spider/web_dec.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
通过 pywebview 加载本地 HTML,执行 JS 实现解密与 GUID 生成。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import webview
|
||||||
|
|
||||||
|
# 内嵌 HTML:加载 CryptoJS 并定义 decryptWithHashSearches、guid
|
||||||
|
_DECRYPT_HTML = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
function decryptWithHashSearches(ciphertext, hashSearches) {
|
||||||
|
try {
|
||||||
|
var baseKey = "8?)i_~Nk6qv0IX;2";
|
||||||
|
var keyStr = baseKey + (hashSearches || "");
|
||||||
|
var key = CryptoJS.enc.Utf8.parse(keyStr);
|
||||||
|
|
||||||
|
var decrypted = CryptoJS.AES.decrypt(ciphertext, key, {
|
||||||
|
mode: CryptoJS.mode.ECB
|
||||||
|
});
|
||||||
|
|
||||||
|
return decrypted.toString(CryptoJS.enc.Utf8);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解密失败:', error.message);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function guid() {
|
||||||
|
function _p8(s) {
|
||||||
|
var p = (Math.random().toString(16) + "000000000").substr(2, 8);
|
||||||
|
return s ? "-" + p.substr(0, 4) + "-" + p.substr(4, 4) : p;
|
||||||
|
}
|
||||||
|
return _p8() + _p8(true) + _p8(true) + _p8();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_window = None
|
||||||
|
_window_ready = threading.Event()
|
||||||
|
_init_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_window():
|
||||||
|
"""首次调用时在后台线程启动 pywebview 并等待页面加载完成。"""
|
||||||
|
global _window
|
||||||
|
with _init_lock:
|
||||||
|
if _window is not None:
|
||||||
|
return
|
||||||
|
_window = webview.create_window(
|
||||||
|
"",
|
||||||
|
html=_DECRYPT_HTML,
|
||||||
|
width=1,
|
||||||
|
height=1,
|
||||||
|
hidden=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run():
|
||||||
|
webview.start(debug=False)
|
||||||
|
|
||||||
|
t = threading.Thread(target=run, daemon=True)
|
||||||
|
t.start()
|
||||||
|
_window.events.loaded.wait()
|
||||||
|
_window_ready.set()
|
||||||
|
_window_ready.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_via_service(hash_searches, ciphertext):
|
||||||
|
"""
|
||||||
|
通过 pywebview 执行 JS decryptWithHashSearches 进行 AES-ECB 解密。
|
||||||
|
|
||||||
|
:param hash_searches: 对应 JS 的 hashSearches(密钥后缀,可为空串)
|
||||||
|
:param ciphertext: Base64 密文
|
||||||
|
:return: 解密后的 UTF-8 字符串,失败返回空串
|
||||||
|
"""
|
||||||
|
_ensure_window()
|
||||||
|
# 将参数安全注入 JS(避免注入与引号问题)
|
||||||
|
ciphertext_js = json.dumps(ciphertext)
|
||||||
|
hash_searches_js = json.dumps(hash_searches or "")
|
||||||
|
js = f"(function(){{ return decryptWithHashSearches({ciphertext_js}, {hash_searches_js}); }})();"
|
||||||
|
try:
|
||||||
|
result = _window.evaluate_js(js)
|
||||||
|
return result if result is not None else ""
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"解密失败: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def get_guid():
|
||||||
|
"""通过 pywebview 执行 JS guid() 生成 UUID 格式字符串。"""
|
||||||
|
_ensure_window()
|
||||||
|
try:
|
||||||
|
result = _window.evaluate_js("(function(){ return guid(); })();")
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError("guid() 返回为空")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"获取 GUID 失败: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
# 使用示例
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 参数含义:hash_searches 对应原 key,ciphertext 对应原 data
|
||||||
|
hash_searches = "a6efb809-b714-7efd-4b64-8c650b9030f0"
|
||||||
|
ciphertext = "SEsfpOGa8B+sWk0ncknTNh/HNHbGiEVi/RNKxBvyHmAE5VjHonPi202c6VicC/GKfA8mLsIC5mGEpSaH2DdCaEJKeOTWD9SBHHbgtyS1O60VqjgAaptYe9LivvWKc/BU8sZOqhxPMzGUHDcKUts7d0p+hCc80XCXyM2ZT80smM1twndcDfpGkLDk2kJbzl2bGzIc60sl9MzKWfZA2sE0ztFjQ9wD2uhn5LrwoN8NnpiPLNbviMMGtHh4N6Dc0xtPzkzgfkiuxBfWnN1SeM9XVgujHvAGea/dqUWIJqLo26fZIOEFJ0MYL4c8CGLYIeP/70cT2IqJdf+IPBWsCiP29zSyAiQd3yLNvd8+xBLky7lR4ng3MZizn/vhW/5BSg1FtVglbAmNbKgHOIbtpP197Lv+uahx2IpsSPqpy4j2O2VV25YzBk9JpJ4WGG/SvF3f2ZYKKepEQ+kGmBxAfG/5Z8DTNIwnOpXhyjFpJb+fUdPV6LTCK/yFc8g31bNFAJkLW7y/VjewjFravZ3VfQjNAHCvifnrIxGG22MZ3TLVnlbh6ye6vTnd+v9GzqXu+ISR7vQGL/ZSM/bJIjuVkP2XnNUPf+NFdt75gyDmTylFmmbpg7WHaBjinPcyVjeZ38+quJhT8yEk66BOjBM47mVdfU8JLK3ToghxQ54dKWGUbc9HRzYIAQ0rIBBExcOcPM4J9DpufvajmEygoDaws4CxOQDlFoFLwNBYorJsKzAoDe1Cu8oFtk4x190Vd+leRKq6DQSNJwEmyrVkWyFpiuywSMaixFlSqve0lRs50FclXfV7gkBAAvz/DJp90i9yCJdMaihP5ZCvbhKxFGMowkU+tx5Ptnxd9hq6tUxHmuiwDI1eyY8EyjB+3MbrC3H/GY39Z5Qhkj5tkSBm4oGA/h6YjeunBlfMU/QGP3hyZIYALh/YmBlZxAcglWwcqlISUhcx1L3Dbw6ZQbpAmYHmA421xIlRJkdJuPmGoopcoFEVdIO0Ov3uP2JL5ybvUgCBMnbwcYRK9qRmvn2b8jHKch55YXerHBDCEMHz88rfgNIEL8DIAp+wMuIyJ0VrV0BwAXioLYNvMcggBp06gghq2NykGiXRZ/dz8nffFqPVkDNjzcPSGrml9fpKdO8x/GannMNBsA+oHlO2/d4dyo+Q2dZCarQ5UB/N1p+UsTW90kvITKwCbZfV/YY2NRm7HrXq+wLS1vY4mChY82Ybc3cYQT653Q=="
|
||||||
|
|
||||||
|
try:
|
||||||
|
decrypted_text = decrypt_via_service(hash_searches, ciphertext)
|
||||||
|
print("解密结果:", decrypted_text)
|
||||||
|
except Exception as e:
|
||||||
|
print("错误:", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
g = get_guid()
|
||||||
|
print("GUID:", g)
|
||||||
|
except Exception as e:
|
||||||
|
print("GUID 错误:", e)
|
||||||
46
config.py
Normal file
46
config.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
base_url = "https://api.coze.cn/v1"
|
||||||
|
coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgqZ5"
|
||||||
|
workflow_id = "7608812635877900322"
|
||||||
|
STITCH_WORKFLOW_ID = "7608813873483300907"
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
_base_url = os.getenv("base_url","http://159.75.121.33:15124")
|
||||||
|
|
||||||
|
# MySQL 配置
|
||||||
|
mysql_host = os.getenv("mysql_host")
|
||||||
|
mysql_user = os.getenv("mysql_user")
|
||||||
|
mysql_password = os.getenv("mysql_password","WTFrb5y6hNLz6hNy")
|
||||||
|
mysql_database = os.getenv("mysql_database","aiimage")
|
||||||
|
proxy_url = os.getenv("proxy_url")
|
||||||
|
proxy_mode = int(os.getenv("proxy_mode",1))
|
||||||
|
|
||||||
|
client_name=os.getenv("client_name") + ".exe"
|
||||||
|
|
||||||
|
cache_path = "./user_data"
|
||||||
|
|
||||||
|
region = "cn-hangzhou"
|
||||||
|
endpoint = "oss-cn-hangzhou.aliyuncs.com"
|
||||||
|
bucket = "nanri-ai-images"
|
||||||
|
accessKeyId = "LTAI5tNpyvzMNz9f2dHarsm8"
|
||||||
|
accessKeySecret = "bQSZnFH455i8tzyOgeahJmUzwmhynz"
|
||||||
|
bucket_path = "nanri-image/"
|
||||||
|
|
||||||
|
file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId
|
||||||
|
os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret
|
||||||
|
os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6"
|
||||||
|
|
||||||
|
|
||||||
|
debug = True
|
||||||
|
version = "1.0.3"
|
||||||
|
APP_UPDATE_URL = f"{_base_url}/api/version/latest" # 检测更新接口地址,返回 { "file_url": "...", "version": "x.x.x" }
|
||||||
|
os.environ['APP_VERSION'] = version
|
||||||
|
os.environ['APP_UPDATE_URL'] = APP_UPDATE_URL
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
92
coze.py
Normal file
92
coze.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
from config import base_url, coze_token
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(file_path,_coze_token =coze_token):
|
||||||
|
url = f"{base_url}/files/upload"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {_coze_token}"
|
||||||
|
}
|
||||||
|
print(file_path)
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
files = {
|
||||||
|
'file': f
|
||||||
|
}
|
||||||
|
# with open(f"{time.time()".replace(".","_")+".png","wb") as file:
|
||||||
|
# file.write(f.read())
|
||||||
|
# allow_redirects=True 模拟 curl 的 --location,默认即为 True
|
||||||
|
response = requests.post(url, headers=headers, files=files, allow_redirects=True)
|
||||||
|
data = response.json()
|
||||||
|
print("上传图片->>",data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def workflow_run(workflow_id, parameters,_coze_token = coze_token,is_async=True):
|
||||||
|
url = f"{base_url}/workflow/run"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {_coze_token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 请求体(JSON 格式)
|
||||||
|
payload = {
|
||||||
|
"parameters": parameters,
|
||||||
|
# "parameters": {
|
||||||
|
# "name": "包包",
|
||||||
|
# "ratio": "3:4",
|
||||||
|
# "menu": 3,
|
||||||
|
# "resolution": "2K",
|
||||||
|
# "count": 1,
|
||||||
|
# "desc": "1、一个中国美女手上挎着这个包,2、站在商场内",
|
||||||
|
# "language": "中文",
|
||||||
|
# "style": "极简高级"
|
||||||
|
# },
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"is_async" : is_async,
|
||||||
|
}
|
||||||
|
# 发送 POST 请求(使用 json 参数自动序列化并设置 Content-Type)
|
||||||
|
response = requests.post(url, headers=headers, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
print("图片生成参数->>",payload)
|
||||||
|
print("图片生成->>",data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def query_result(workflow_id, execute_id,_coze_token=coze_token):
|
||||||
|
url = f"{base_url}/workflows/{workflow_id}/run_histories/{execute_id}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {_coze_token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
data = response.json()
|
||||||
|
print(f"【{execute_id}】结果查询->>",data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
from config import workflow_id
|
||||||
|
|
||||||
|
parameters = {
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"file_id": "7612667079497613350",
|
||||||
|
|
||||||
|
},{
|
||||||
|
"file_id": "7612667042990768174",
|
||||||
|
},{
|
||||||
|
"file_id": "7612667111332577332",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
# resp = workflow_run("7607680760402100258",parameters,_coze_token="sat_ZVNLR9Om54A3iMlJWAasHF9kbtDZnR3BjwjAxRwe8x7igEW446y5ROyVlW1UlpVX")
|
||||||
|
# print(resp)
|
||||||
|
|
||||||
|
# resp = query_result("7607680760402100258","7612668958797447187",_coze_token="sat_ZVNLR9Om54A3iMlJWAasHF9kbtDZnR3BjwjAxRwe8x7igEW446y5ROyVlW1UlpVX")
|
||||||
|
# print(resp)
|
||||||
|
resp = upload_file("D:\私单交付\maixiang_AI\测试图片数据\IMG_2686.JPG",_coze_token="sat_ZVNLR9Om54A3iMlJWAasHF9kbtDZnR3BjwjAxRwe8x7igEW446y5ROyVlW1UlpVX")
|
||||||
|
print(resp)
|
||||||
261
generate_api.py
Normal file
261
generate_api.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""
|
||||||
|
生成图片 API - 供 pywebview 前端调用
|
||||||
|
流程:上传图片 -> workflow_run -> 轮询 query_result -> 解析返回图片 URL
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from coze import upload_file, workflow_run, query_result
|
||||||
|
from config import workflow_id
|
||||||
|
from ali_oss import upload_data_urls as oss_upload_data_urls
|
||||||
|
|
||||||
|
|
||||||
|
def _data_url_to_temp_file(data_url: str, allow_video: bool = False) -> str:
|
||||||
|
"""将 base64 data URL 转为临时文件路径"""
|
||||||
|
# data:image/png;base64,xxxx 或 data:video/mp4;base64,xxxx
|
||||||
|
if allow_video:
|
||||||
|
match = re.match(r'data:(?:image|video)/(\w+);base64,(.+)', data_url)
|
||||||
|
else:
|
||||||
|
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
|
||||||
|
if not match:
|
||||||
|
raise ValueError('无效的 data URL 格式')
|
||||||
|
mime = match.group(1).lower()
|
||||||
|
ext_map = {'png': 'png', 'webp': 'png', 'jpeg': 'jpg', 'jpg': 'jpg', 'mp4': 'mp4', 'webm': 'webm'}
|
||||||
|
ext = ext_map.get(mime, 'jpg')
|
||||||
|
data = base64.b64decode(match.group(2))
|
||||||
|
fd, path = tempfile.mkstemp(suffix=f'.{ext}')
|
||||||
|
try:
|
||||||
|
os.write(fd, data)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_video(video_data_url: str) -> str:
|
||||||
|
"""上传视频,返回 file_id"""
|
||||||
|
if not video_data_url or not isinstance(video_data_url, str):
|
||||||
|
raise ValueError('无效的视频数据')
|
||||||
|
path = _data_url_to_temp_file(video_data_url, allow_video=True)
|
||||||
|
try:
|
||||||
|
resp = upload_file(path)
|
||||||
|
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
|
||||||
|
return resp['data']['id']
|
||||||
|
raise RuntimeError(f'视频上传失败: {resp.get("msg", resp)}')
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_images(image_data_urls: list) -> list:
|
||||||
|
"""上传多张图片,返回 file_id 列表"""
|
||||||
|
file_ids = []
|
||||||
|
temp_paths = []
|
||||||
|
try:
|
||||||
|
for data_url in (image_data_urls or []):
|
||||||
|
if not data_url or not isinstance(data_url, str):
|
||||||
|
continue
|
||||||
|
if data_url.startswith("http"):
|
||||||
|
fd, path = tempfile.mkstemp(suffix=f'.png')
|
||||||
|
data_content = requests.get(data_url).content
|
||||||
|
os.write(fd, data_content)
|
||||||
|
else:
|
||||||
|
path = _data_url_to_temp_file(data_url)
|
||||||
|
temp_paths.append(path)
|
||||||
|
resp = upload_file(path)
|
||||||
|
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
|
||||||
|
file_ids.append(resp['data']['id'])
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f'上传失败: {resp.get("msg", resp)}')
|
||||||
|
return file_ids
|
||||||
|
finally:
|
||||||
|
for p in temp_paths:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_output(output_str: str) -> list:
|
||||||
|
"""解析 query_result 中的 output,提取图片 URL 列表"""
|
||||||
|
try:
|
||||||
|
outer = json.loads(output_str)
|
||||||
|
inner_str = outer.get('Output', '{}')
|
||||||
|
inner = json.loads(inner_str)
|
||||||
|
data_str = inner.get('data', '[]')
|
||||||
|
data = json.loads(data_str)
|
||||||
|
urls = data.get("images")
|
||||||
|
long_image_url = data.get("merged_image_url")
|
||||||
|
return urls if isinstance(urls, list) else [],long_image_url
|
||||||
|
except Exception:
|
||||||
|
return [],None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_output_text(output_str: str) -> list:
|
||||||
|
"""解析 query_result 中的 output,提取图片 URL 列表"""
|
||||||
|
try:
|
||||||
|
outer = json.loads(output_str)
|
||||||
|
inner_str = outer.get('Output', '{}')
|
||||||
|
inner = json.loads(inner_str)
|
||||||
|
data_str = inner.get('data', '')
|
||||||
|
if isinstance(data_str,str):
|
||||||
|
data = json.loads(data_str).get("reverse_prompt")
|
||||||
|
return [data]
|
||||||
|
pattern = r'【?图像 \d+】.*?(?=【?图像 \d+】|$)'
|
||||||
|
segments = re.findall(pattern, data_str, re.DOTALL)
|
||||||
|
return segments
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_until_done(wf_id: str, execute_id: str, interval: float =10, timeout: int = 600*2, is_text: bool = False) -> list:
|
||||||
|
"""轮询直到成功或超时,返回图片 URL 列表"""
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
resp = query_result(wf_id, execute_id)
|
||||||
|
if resp.get('code') != 0:
|
||||||
|
raise RuntimeError(f'查询失败: {resp.get("msg", resp)}')
|
||||||
|
items = resp.get('data') or []
|
||||||
|
if not items:
|
||||||
|
time.sleep(interval)
|
||||||
|
continue
|
||||||
|
item = items[0]
|
||||||
|
status = item.get('execute_status', '')
|
||||||
|
if status == 'Success':
|
||||||
|
output = item.get('output', '{}')
|
||||||
|
if is_text:
|
||||||
|
return _parse_output_text(output)
|
||||||
|
return _parse_output(output)
|
||||||
|
if status and status not in ('Running', 'Pending', ''):
|
||||||
|
raise RuntimeError(f'执行失败: {status}')
|
||||||
|
time.sleep(interval)
|
||||||
|
raise RuntimeError('生成超时')
|
||||||
|
|
||||||
|
|
||||||
|
def _res_to_2k(res: str) -> str:
|
||||||
|
"""统一分辨率为 2K/4K"""
|
||||||
|
r = (res or '2k').strip().upper()
|
||||||
|
return '4K' if r == '4K' else '2K'
|
||||||
|
|
||||||
|
|
||||||
|
def generate(params: dict) -> dict:
|
||||||
|
"""
|
||||||
|
生成图片
|
||||||
|
params: {
|
||||||
|
menu: int, # 1=图片反推 2=图片编辑 3=随机海报 4=克隆海报 5=服饰穿搭
|
||||||
|
prompt: str, # 用户自定义指令 (menu=1 必填)
|
||||||
|
ref_images: list, # base64 data URLs - 参考图
|
||||||
|
video: str, # base64 data URL - 视频 (menu=1 可选,仅支持1个)
|
||||||
|
model_images: list, # base64 data URLs - 多模特图 (menu!=1,最多5张)
|
||||||
|
name: str, desc: str, ratio: str, resolution: str, count: int,
|
||||||
|
language: str, style: str, batch_prompt: list, brand_name: str,
|
||||||
|
Ingredients: str, activity: str, mode: str, texts: list,
|
||||||
|
proc_images: list, layout_image: str,
|
||||||
|
}
|
||||||
|
返回: { success: bool, urls: list, prompts: list, error: str }
|
||||||
|
"""
|
||||||
|
original_urls = []
|
||||||
|
api_key = (params.get('api_key') or '').strip()
|
||||||
|
try:
|
||||||
|
menu = int(params.get('menu', 2))
|
||||||
|
ref_data = params.get('ref_images') or []
|
||||||
|
proc_data = params.get('proc_images') or []
|
||||||
|
layout_data = params.get('layout_image')
|
||||||
|
video_data = params.get('video')
|
||||||
|
model_data = params.get('model_images') or []
|
||||||
|
|
||||||
|
# menu 1: 反推词,简化参数
|
||||||
|
if menu == 1:
|
||||||
|
base_params = {'menu': 1, 'prompt': str(params.get('prompt', '')).strip() or ''}
|
||||||
|
|
||||||
|
ref_ids = _upload_images(ref_data) if ref_data else []
|
||||||
|
if ref_ids:
|
||||||
|
base_params['ref_images'] = [{'file_id': fid} for fid in ref_ids]
|
||||||
|
if video_data:
|
||||||
|
try:
|
||||||
|
vid = _upload_video(video_data)
|
||||||
|
base_params['video'] = {'file_id': vid}
|
||||||
|
except Exception as ve:
|
||||||
|
return {'success': False, 'urls': [], 'prompts': [], 'error': f'视频上传失败: {ve}'}
|
||||||
|
base_params = {k: v for k, v in base_params.items() if v is not None and v != ''}
|
||||||
|
base_params["api_key"] = api_key
|
||||||
|
resp = workflow_run(workflow_id, base_params)
|
||||||
|
if resp.get('code') != 0:
|
||||||
|
return {'success': False, 'urls': [], 'prompts': [], 'error': resp.get('msg', str(resp))}
|
||||||
|
execute_id = resp.get('execute_id')
|
||||||
|
if not execute_id:
|
||||||
|
return {'success': False, 'urls': [], 'prompts': [], 'error': '未返回 execute_id'}
|
||||||
|
prompts = _poll_until_done(workflow_id, str(execute_id), is_text=True)
|
||||||
|
# menu 1 返回提示词列表,统一转为字符串
|
||||||
|
# prompts = [str(x) for x in result_list] if result_list else []
|
||||||
|
# prompts = [prompts]
|
||||||
|
return {'success': True, 'urls': [], 'prompts': prompts, 'error': ''}
|
||||||
|
|
||||||
|
# menu != 1: 原有逻辑
|
||||||
|
all_ref = list(ref_data)
|
||||||
|
if layout_data:
|
||||||
|
all_ref = [layout_data] + list(ref_data)
|
||||||
|
all_originals = list(all_ref) + list(proc_data) + list(model_data)
|
||||||
|
|
||||||
|
if all_originals:
|
||||||
|
try:
|
||||||
|
original_urls = oss_upload_data_urls(all_originals, prefix="originals")
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
pass
|
||||||
|
|
||||||
|
ref_ids = _upload_images(all_ref) if all_ref else []
|
||||||
|
proc_ids = _upload_images(proc_data) if proc_data else []
|
||||||
|
model_ids = _upload_images(model_data) if model_data else []
|
||||||
|
|
||||||
|
res = _res_to_2k(params.get('resolution', '2K'))
|
||||||
|
|
||||||
|
base_params = {
|
||||||
|
'name': str(params.get('name', '')).strip(),
|
||||||
|
'ratio': str(params.get('ratio', '')).strip(),
|
||||||
|
'menu': menu,
|
||||||
|
'resolution': res,
|
||||||
|
'count': int(params.get('count', 1)),
|
||||||
|
'desc': str(params.get('desc', '')).strip(),
|
||||||
|
'language': str(params.get('language', '中文')).strip() or '中文',
|
||||||
|
'style': str(params.get('style', '')).strip(),
|
||||||
|
'prompt': str(params.get('prompt', '')).strip(),
|
||||||
|
'batch_prompt': params.get('batch_prompt') or [],
|
||||||
|
'brand_name': str(params.get('brand_name', '')).strip(),
|
||||||
|
'Ingredients': str(params.get('Ingredients', '')).strip(),
|
||||||
|
'activity': str(params.get('activity', '')).strip(),
|
||||||
|
'mode': str(params.get('mode', '1')).strip() or '1',
|
||||||
|
'texts': params.get('text') or [],
|
||||||
|
"api_key" : api_key
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref_ids:
|
||||||
|
base_params['ref_images'] = [{'file_id': fid} for fid in ref_ids]
|
||||||
|
if proc_ids:
|
||||||
|
base_params['proc_images'] = [{'file_id': fid} for fid in proc_ids]
|
||||||
|
if model_ids:
|
||||||
|
base_params['model_images'] = [{'file_id': fid} for fid in model_ids]
|
||||||
|
base_params = {k: v for k, v in base_params.items() if v is not None and v != ''}
|
||||||
|
|
||||||
|
resp = workflow_run(workflow_id, base_params)
|
||||||
|
if resp.get('code') != 0:
|
||||||
|
return {'success': False, 'urls': [], 'error': resp.get('msg', str(resp))}
|
||||||
|
execute_id = resp.get('execute_id')
|
||||||
|
if not execute_id:
|
||||||
|
return {'success': False, 'urls': [], 'error': '未返回 execute_id'}
|
||||||
|
|
||||||
|
urls,long_image_url = _poll_until_done(workflow_id, str(execute_id))
|
||||||
|
return {'success': True, 'urls': urls, 'original_urls': original_urls, 'error': '',"long_image_url":long_image_url}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'urls': [], 'prompts': [], 'original_urls': [], 'error': str(e),"long_image_url":""}
|
||||||
|
|
||||||
|
|
||||||
68
html_crypto.py
Normal file
68
html_crypto.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
HTML 模板加密/解密模块
|
||||||
|
使用 AES (Fernet) 对 HTML 资源进行加密存储,运行时解密后渲染
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet_key() -> bytes:
|
||||||
|
"""从环境变量 HTML_ENCRYPT_KEY 派生 Fernet 密钥,若未设置则使用默认开发密钥"""
|
||||||
|
raw = os.environ.get(
|
||||||
|
"HTML_ENCRYPT_KEY",
|
||||||
|
"maixiang_html_encrypt_default_key_change_in_production",
|
||||||
|
)
|
||||||
|
# 使用 PBKDF2 派生 32 字节密钥,再转为 Fernet 所需的 base64url
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=b"maixiang_html_salt",
|
||||||
|
iterations=100000,
|
||||||
|
)
|
||||||
|
key_bytes = kdf.derive(raw.encode("utf-8"))
|
||||||
|
return base64.urlsafe_b64encode(key_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(plain_data: bytes) -> bytes:
|
||||||
|
"""加密原始数据,返回密文(bytes)"""
|
||||||
|
key = _get_fernet_key()
|
||||||
|
f = Fernet(key)
|
||||||
|
return f.encrypt(plain_data)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(encrypted_data: bytes) -> bytes:
|
||||||
|
"""解密数据,返回明文(bytes)"""
|
||||||
|
key = _get_fernet_key()
|
||||||
|
f = Fernet(key)
|
||||||
|
return f.decrypt(encrypted_data)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_file(path: str) -> None:
|
||||||
|
"""就地加密文件:读取 UTF-8 内容,加密后写回同一路径"""
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
plain = f.read()
|
||||||
|
encrypted = encrypt(plain)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(encrypted)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_file(path: str) -> None:
|
||||||
|
"""就地解密文件:读取密文,解密后以 UTF-8 写回同一路径"""
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
encrypted = f.read()
|
||||||
|
plain = decrypt(encrypted)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(plain)
|
||||||
|
|
||||||
|
|
||||||
|
def is_encrypted(data: bytes) -> bool:
|
||||||
|
"""简单启发式:Fernet 密文以 b'gAAAAA' 开头(base64 编码后)"""
|
||||||
|
try:
|
||||||
|
return data[:7] == b"gAAAAAB"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
344
main.py
Normal file
344
main.py
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
"""
|
||||||
|
基于 pywebview 实现的跨平台 UI,需先登录方可使用
|
||||||
|
"""
|
||||||
|
from config import cache_path,debug,version
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
os.makedirs(cache_path,exist_ok=True)
|
||||||
|
if not debug:
|
||||||
|
today = datetime.datetime.now().strftime("%Y_%m_%d")
|
||||||
|
sys.stdout = open(os.path.join(cache_path,f'{today}.log'), 'a',buffering=1)
|
||||||
|
sys.stderr = sys.stdout
|
||||||
|
|
||||||
|
import webview
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
with open("version.txt","w",encoding="utf-8") as file:
|
||||||
|
file.write(json.dumps({"version":version}))
|
||||||
|
|
||||||
|
|
||||||
|
# 获取当前脚本所在目录
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
APP_URL = "http://127.0.0.1:5123"
|
||||||
|
PORT = 5123
|
||||||
|
|
||||||
|
|
||||||
|
def generate_images(params):
|
||||||
|
"""供前端调用的生成图片接口"""
|
||||||
|
from generate_api import generate
|
||||||
|
return generate(params)
|
||||||
|
|
||||||
|
|
||||||
|
class WindowAPI:
|
||||||
|
"""暴露给 JavaScript 的窗口控制 API"""
|
||||||
|
|
||||||
|
def __init__(self, window):
|
||||||
|
self._window = window
|
||||||
|
self._is_maximized = False
|
||||||
|
window.events.maximized += lambda _: setattr(self, '_is_maximized', True)
|
||||||
|
window.events.restored += lambda _: setattr(self, '_is_maximized', False)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
sys.exit(1)
|
||||||
|
self._window.destroy()
|
||||||
|
|
||||||
|
def minimize(self):
|
||||||
|
self._window.minimize()
|
||||||
|
|
||||||
|
def maximize(self):
|
||||||
|
self._window.maximize()
|
||||||
|
|
||||||
|
def toggle_maximize(self):
|
||||||
|
if self._is_maximized:
|
||||||
|
self._window.restore()
|
||||||
|
else:
|
||||||
|
self._window.maximize()
|
||||||
|
|
||||||
|
def save_image(self, url_or_data, filename='image.png'):
|
||||||
|
"""弹窗选择保存位置并下载图片。url_or_data 可为 http(s) 链接或 data: base64"""
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
result = self._window.create_file_dialog(
|
||||||
|
webview.SAVE_DIALOG,
|
||||||
|
save_filename=filename,
|
||||||
|
file_types=('图片文件 (*.png;*.jpg;*.jpeg)', '所有文件 (*.*)')
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return {'success': False, 'error': '用户取消'}
|
||||||
|
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||||
|
try:
|
||||||
|
if url_or_data.startswith('data:'):
|
||||||
|
match = re.match(r'data:image/\w+;base64,(.+)', url_or_data)
|
||||||
|
if not match:
|
||||||
|
print(url_or_data)
|
||||||
|
print('无效的 data URL')
|
||||||
|
return {'success': False, 'error': '无效的 data URL'}
|
||||||
|
data = base64.b64decode(match.group(1))
|
||||||
|
else:
|
||||||
|
import requests
|
||||||
|
resp = requests.get(url_or_data, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.content
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
return {'success': True, 'path': path}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def save_image_to_folder(self, url_or_data, dir_path, filename='image.png'):
|
||||||
|
"""将图片保存到指定文件夹。url_or_data 可为 http(s) 链接或 data: base64"""
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
path = os.path.join(dir_path, filename)
|
||||||
|
try:
|
||||||
|
if url_or_data.startswith('data:'):
|
||||||
|
match = re.match(r'data:image/\w+;base64,(.+)', url_or_data)
|
||||||
|
if not match:
|
||||||
|
return {'success': False, 'error': '无效的 data URL'}
|
||||||
|
data = base64.b64decode(match.group(1))
|
||||||
|
else:
|
||||||
|
import requests
|
||||||
|
resp = requests.get(url_or_data, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.content
|
||||||
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(data)
|
||||||
|
return {'success': True, 'path': path}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def select_folder(self):
|
||||||
|
# 此方法会在 JavaScript 中被调用,返回选择的文件夹路径
|
||||||
|
result = webview.windows[0].create_file_dialog(
|
||||||
|
webview.FOLDER_DIALOG, # 指定为文件夹对话框
|
||||||
|
allow_multiple=False, # 是否允许多选
|
||||||
|
directory='' # 初始目录
|
||||||
|
)
|
||||||
|
# create_file_dialog 返回的是列表(多选时)或 None
|
||||||
|
return result[0] if result else ''
|
||||||
|
|
||||||
|
def select_brand_xlsx_files(self):
|
||||||
|
"""品牌爬虫:选择 .xlsx 文件,允许多选,返回路径列表"""
|
||||||
|
result = webview.windows[0].create_file_dialog(
|
||||||
|
webview.OPEN_DIALOG,
|
||||||
|
allow_multiple=True,
|
||||||
|
file_types=('Excel 文件 (*.xlsx)', '所有文件 (*.*)')
|
||||||
|
)
|
||||||
|
print("选择的excel 文件",result)
|
||||||
|
if not result:
|
||||||
|
return []
|
||||||
|
return result if isinstance(result, list) or isinstance(result, tuple) else [result]
|
||||||
|
|
||||||
|
def select_brand_folder(self):
|
||||||
|
"""品牌爬虫:选择文件夹,返回文件夹路径(前端再请求后端展开该目录下所有 xlsx)"""
|
||||||
|
result = webview.windows[0].create_file_dialog(
|
||||||
|
webview.FOLDER_DIALOG,
|
||||||
|
allow_multiple=False,
|
||||||
|
directory=''
|
||||||
|
)
|
||||||
|
return result[0] if result else ''
|
||||||
|
|
||||||
|
def save_file_from_url(self, url, default_filename='download.zip'):
|
||||||
|
"""弹窗选择保存位置,从 url 下载文件并保存。用于品牌任务结果 zip 等。"""
|
||||||
|
if not url or not url.strip():
|
||||||
|
return {'success': False, 'error': '下载地址为空'}
|
||||||
|
result = self._window.create_file_dialog(
|
||||||
|
webview.SAVE_DIALOG,
|
||||||
|
save_filename=default_filename,
|
||||||
|
file_types=('ZIP 压缩包 (*.zip)', '所有文件 (*.*)')
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return {'success': False, 'error': '用户取消'}
|
||||||
|
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
resp = requests.get(url.strip(), timeout=120, stream=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
for chunk in resp.iter_content(chunk_size=65536):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
return {'success': True, 'path': path}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
def save_template_xlsx(self):
|
||||||
|
"""弹窗选择保存位置,将品牌文档格式模板 xlsx 保存到用户选择的位置。"""
|
||||||
|
import shutil
|
||||||
|
template_name = '品牌文档格式_模板.xlsx'
|
||||||
|
template_path = os.path.join(BASE_DIR, 'static', template_name)
|
||||||
|
if not os.path.isfile(template_path):
|
||||||
|
return {'success': False, 'error': '模板文件不存在'}
|
||||||
|
result = self._window.create_file_dialog(
|
||||||
|
webview.SAVE_DIALOG,
|
||||||
|
save_filename=template_name,
|
||||||
|
file_types=('Excel 文件 (*.xlsx)', '所有文件 (*.*)')
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return {'success': False, 'error': '用户取消'}
|
||||||
|
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||||
|
try:
|
||||||
|
shutil.copy2(template_path, path)
|
||||||
|
return {'success': True, 'path': path}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def save_template_zip(self):
|
||||||
|
"""弹窗选择保存位置,将品牌文档格式模板 xlsx 保存到用户选择的位置。"""
|
||||||
|
import shutil
|
||||||
|
template_name = '模板2-以文件夹方式上传.zip'
|
||||||
|
template_path = os.path.join(BASE_DIR, 'static', template_name)
|
||||||
|
if not os.path.isfile(template_path):
|
||||||
|
return {'success': False, 'error': '模板文件不存在'}
|
||||||
|
result = self._window.create_file_dialog(
|
||||||
|
webview.SAVE_DIALOG,
|
||||||
|
save_filename=template_name,
|
||||||
|
file_types=('ZIP 文件 (*.zip)', '所有文件 (*.*)')
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return {'success': False, 'error': '用户取消'}
|
||||||
|
path = result[0] if isinstance(result, (list, tuple)) else result
|
||||||
|
try:
|
||||||
|
shutil.copy2(template_path, path)
|
||||||
|
return {'success': True, 'path': path}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def _select_folder_win32():
|
||||||
|
"""Windows: 用 ctypes 调用系统文件夹选择对话框,打包成 exe 后也能正常弹窗(不依赖 tkinter)"""
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
BIF_RETURNONLYFSDIRS = 0x00000001
|
||||||
|
BIF_NEWDIALOGSTYLE = 0x00000040
|
||||||
|
MAX_PATH = 260
|
||||||
|
shell32 = ctypes.windll.shell32 # type: ignore[attr-defined]
|
||||||
|
# BROWSEINFOW
|
||||||
|
class BROWSEINFOW(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("hwndOwner", wintypes.HWND),
|
||||||
|
("pidlRoot", ctypes.c_void_p),
|
||||||
|
("pszDisplayName", ctypes.c_wchar_p),
|
||||||
|
("lpszTitle", ctypes.c_wchar_p),
|
||||||
|
("ulFlags", wintypes.UINT),
|
||||||
|
("lpfn", ctypes.c_void_p),
|
||||||
|
("lParam", wintypes.LPARAM),
|
||||||
|
("iImage", ctypes.c_int),
|
||||||
|
]
|
||||||
|
display_name_buf = ctypes.create_unicode_buffer(MAX_PATH)
|
||||||
|
bi = BROWSEINFOW()
|
||||||
|
bi.hwndOwner = 0
|
||||||
|
bi.pidlRoot = 0
|
||||||
|
bi.pszDisplayName = ctypes.cast(display_name_buf, ctypes.c_wchar_p)
|
||||||
|
bi.lpszTitle = "选择保存图片的文件夹"
|
||||||
|
bi.ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE
|
||||||
|
bi.lpfn = 0
|
||||||
|
bi.lParam = 0
|
||||||
|
bi.iImage = 0
|
||||||
|
pidl = shell32.SHBrowseForFolderW(ctypes.byref(bi))
|
||||||
|
if not pidl:
|
||||||
|
return ''
|
||||||
|
path_buf = ctypes.create_unicode_buffer(MAX_PATH)
|
||||||
|
if not shell32.SHGetPathFromIDListW(pidl, path_buf):
|
||||||
|
return ''
|
||||||
|
ole32 = ctypes.windll.ole32 # type: ignore[attr-defined]
|
||||||
|
ole32.CoTaskMemFree(pidl)
|
||||||
|
return path_buf.value or ''
|
||||||
|
except Exception as e:
|
||||||
|
print("Windows 文件夹选择失败:", e)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
# def _select_folder_tk():
|
||||||
|
# """使用 tkinter 选择文件夹(在非 Windows 或作为后备)"""
|
||||||
|
# try:
|
||||||
|
#
|
||||||
|
# root = tkinter.Tk()
|
||||||
|
# root.withdraw()
|
||||||
|
# root.attributes('-topmost', True)
|
||||||
|
# path = filedialog.askdirectory(title='选择保存图片的文件夹')
|
||||||
|
# try:
|
||||||
|
# root.destroy()
|
||||||
|
# except Exception:
|
||||||
|
# pass
|
||||||
|
# return path if path else ''
|
||||||
|
# except Exception as e:
|
||||||
|
# print("tkinter 选择文件夹失败:", e)
|
||||||
|
# return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _select_folder_tk():
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog
|
||||||
|
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
root.attributes('-topmost', True)
|
||||||
|
|
||||||
|
# 使用 after 确保 askdirectory 在主循环启动后执行
|
||||||
|
def ask():
|
||||||
|
nonlocal path
|
||||||
|
path = filedialog.askdirectory(title='选择保存图片的文件夹')
|
||||||
|
root.quit() # 关闭主循环
|
||||||
|
|
||||||
|
path = ''
|
||||||
|
root.after(100, ask)
|
||||||
|
root.mainloop() # 启动主循环,直到 root.quit() 被调用
|
||||||
|
try:
|
||||||
|
root.destroy()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def select_folder():
|
||||||
|
"""弹窗选择文件夹,返回路径(空字符串表示取消)。Windows 打包 exe 时优先用系统 API 避免 tkinter 不弹窗。"""
|
||||||
|
# if sys.platform == 'win32':
|
||||||
|
# path = _select_folder_win32()
|
||||||
|
# if path:
|
||||||
|
# return path
|
||||||
|
return _select_folder_tk()
|
||||||
|
|
||||||
|
|
||||||
|
def start_flask():
|
||||||
|
from app import run_app
|
||||||
|
run_app(host='127.0.0.1', port=PORT)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not os.path.exists(cache_path):
|
||||||
|
os.makedirs(cache_path,exist_ok=True)
|
||||||
|
t = threading.Thread(target=start_flask, daemon=True)
|
||||||
|
t.start()
|
||||||
|
# 等待 Flask 启动
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
window = webview.create_window(
|
||||||
|
title="南日AI",
|
||||||
|
url=APP_URL,
|
||||||
|
width=1400,
|
||||||
|
height=900,
|
||||||
|
resizable=True,
|
||||||
|
min_size=(1200, 700),
|
||||||
|
background_color="#1a1a1a",
|
||||||
|
frameless=False,
|
||||||
|
easy_drag=True
|
||||||
|
)
|
||||||
|
api = WindowAPI(window)
|
||||||
|
window.expose(api.close, api.minimize, api.maximize, api.toggle_maximize, generate_images, api.save_image, api.save_image_to_folder, api.select_folder, api.select_brand_xlsx_files, api.select_brand_folder, api.save_file_from_url, api.save_template_xlsx,api.save_template_zip)
|
||||||
|
webview.start(
|
||||||
|
debug=True,
|
||||||
|
storage_path=cache_path,
|
||||||
|
private_mode=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
static/bg.jpg
Normal file
BIN
static/bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
BIN
static/品牌文档格式_模板.xlsx
Normal file
BIN
static/品牌文档格式_模板.xlsx
Normal file
Binary file not shown.
BIN
static/模板2-以文件夹方式上传.zip
Normal file
BIN
static/模板2-以文件夹方式上传.zip
Normal file
Binary file not shown.
1
tool/.device_id
Normal file
1
tool/.device_id
Normal file
@@ -0,0 +1 @@
|
|||||||
|
8cecee3bc02a178bf372ca1c3d02fc5cae3c0a51c8346f6e6a710988599c08be
|
||||||
BIN
tool/__pycache__/devices.cpython-311.pyc
Normal file
BIN
tool/__pycache__/devices.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tool/__pycache__/devices.cpython-39.pyc
Normal file
BIN
tool/__pycache__/devices.cpython-39.pyc
Normal file
Binary file not shown.
249
tool/devices.py
Normal file
249
tool/devices.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import hashlib
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceIDGenerator:
|
||||||
|
"""
|
||||||
|
Windows设备唯一ID生成器
|
||||||
|
通过收集多个硬件特征来生成稳定的设备唯一标识符
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, use_cache: bool = False, cache_file: str = ".device_id"):
|
||||||
|
"""
|
||||||
|
初始化设备ID生成器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
use_cache: 是否使用本地缓存
|
||||||
|
cache_file: 缓存文件名
|
||||||
|
"""
|
||||||
|
self.use_cache = use_cache
|
||||||
|
self.cache_file = cache_file
|
||||||
|
|
||||||
|
def _run_wmic_command(self, command: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
执行WMIC命令并返回结果
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: WMIC命令
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
命令执行结果,失败则返回None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, Exception):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_motherboard_serial(self) -> Optional[str]:
|
||||||
|
"""获取主板序列号"""
|
||||||
|
return self._run_wmic_command("wmic baseboard get serialnumber /value")
|
||||||
|
|
||||||
|
def _get_cpu_id(self) -> Optional[str]:
|
||||||
|
"""获取CPU ID"""
|
||||||
|
return self._run_wmic_command("wmic cpu get processorid /value")
|
||||||
|
|
||||||
|
def _get_bios_serial(self) -> Optional[str]:
|
||||||
|
"""获取BIOS序列号"""
|
||||||
|
return self._run_wmic_command("wmic bios get serialnumber /value")
|
||||||
|
|
||||||
|
def _get_disk_serial(self) -> Optional[str]:
|
||||||
|
"""获取系统盘序列号"""
|
||||||
|
return self._run_wmic_command("wmic diskdrive get serialnumber /value")
|
||||||
|
|
||||||
|
def _get_machine_guid(self) -> Optional[str]:
|
||||||
|
"""获取Windows机器GUID"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
'reg query "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid',
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
if 'MachineGuid' in line:
|
||||||
|
return line.split()[-1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_value(self, wmic_output: str) -> str:
|
||||||
|
"""从WMIC输出中提取实际值"""
|
||||||
|
if not wmic_output:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = wmic_output.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
if '=' in line and not line.strip().endswith('='):
|
||||||
|
return line.split('=', 1)[1].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _collect_hardware_info(self) -> dict:
|
||||||
|
"""
|
||||||
|
收集硬件信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含各种硬件信息的字典
|
||||||
|
"""
|
||||||
|
hardware_info = {}
|
||||||
|
|
||||||
|
# 主板序列号
|
||||||
|
motherboard = self._get_motherboard_serial()
|
||||||
|
hardware_info['motherboard'] = self._extract_value(motherboard) if motherboard else ""
|
||||||
|
|
||||||
|
# CPU ID
|
||||||
|
cpu_id = self._get_cpu_id()
|
||||||
|
hardware_info['cpu'] = self._extract_value(cpu_id) if cpu_id else ""
|
||||||
|
|
||||||
|
# BIOS序列号
|
||||||
|
bios = self._get_bios_serial()
|
||||||
|
hardware_info['bios'] = self._extract_value(bios) if bios else ""
|
||||||
|
|
||||||
|
# 硬盘序列号
|
||||||
|
disk = self._get_disk_serial()
|
||||||
|
hardware_info['disk'] = self._extract_value(disk) if disk else ""
|
||||||
|
|
||||||
|
# Windows机器GUID
|
||||||
|
machine_guid = self._get_machine_guid()
|
||||||
|
hardware_info['machine_guid'] = machine_guid if machine_guid else ""
|
||||||
|
|
||||||
|
# 计算机名称
|
||||||
|
hardware_info['computer_name'] = platform.node()
|
||||||
|
|
||||||
|
# MAC地址(作为备用)
|
||||||
|
hardware_info['mac_address'] = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff)
|
||||||
|
for elements in range(0, 2*6, 2)][::-1])
|
||||||
|
|
||||||
|
return hardware_info
|
||||||
|
|
||||||
|
def _generate_device_id(self, hardware_info: dict) -> str:
|
||||||
|
"""
|
||||||
|
基于硬件信息生成设备ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hardware_info: 硬件信息字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
32位十六进制设备ID
|
||||||
|
"""
|
||||||
|
# 过滤掉空值,并按键排序确保一致性
|
||||||
|
filtered_info = {k: v for k, v in hardware_info.items() if v and v.strip()}
|
||||||
|
|
||||||
|
# 如果没有任何硬件信息,使用MAC地址作为后备方案
|
||||||
|
if not filtered_info:
|
||||||
|
filtered_info = {'mac_address': hardware_info.get('mac_address', str(uuid.getnode()))}
|
||||||
|
|
||||||
|
# 将所有信息连接成字符串
|
||||||
|
info_string = '|'.join(f"{k}:{v}" for k, v in sorted(filtered_info.items()))
|
||||||
|
|
||||||
|
# 使用SHA256生成哈希值
|
||||||
|
hash_object = hashlib.sha256(info_string.encode('utf-8'))
|
||||||
|
device_id = hash_object.hexdigest()
|
||||||
|
|
||||||
|
return device_id
|
||||||
|
|
||||||
|
def _load_cached_device_id(self) -> Optional[str]:
|
||||||
|
"""从缓存文件加载设备ID"""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.cache_file):
|
||||||
|
with open(self.cache_file, 'r', encoding='utf-8') as f:
|
||||||
|
cached_id = f.read().strip()
|
||||||
|
if len(cached_id) == 64: # SHA256哈希长度
|
||||||
|
return cached_id
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _save_device_id_to_cache(self, device_id: str) -> None:
|
||||||
|
"""将设备ID保存到缓存文件"""
|
||||||
|
try:
|
||||||
|
with open(self.cache_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(device_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_device_id(self) -> str:
|
||||||
|
"""
|
||||||
|
获取设备唯一ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
64字符的十六进制设备ID
|
||||||
|
"""
|
||||||
|
# 如果启用缓存,先尝试从缓存加载
|
||||||
|
if self.use_cache:
|
||||||
|
cached_id = self._load_cached_device_id()
|
||||||
|
if cached_id:
|
||||||
|
return cached_id
|
||||||
|
|
||||||
|
# 收集硬件信息
|
||||||
|
hardware_info = self._collect_hardware_info()
|
||||||
|
|
||||||
|
# 生成设备ID
|
||||||
|
device_id = self._generate_device_id(hardware_info)
|
||||||
|
|
||||||
|
# 保存到缓存
|
||||||
|
if self.use_cache:
|
||||||
|
self._save_device_id_to_cache(device_id)
|
||||||
|
|
||||||
|
return device_id
|
||||||
|
|
||||||
|
def get_device_id_short(self, length: int = 16) -> str:
|
||||||
|
"""
|
||||||
|
获取短版本的设备ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
length: 返回ID的长度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
指定长度的设备ID
|
||||||
|
"""
|
||||||
|
full_id = self.get_device_id()
|
||||||
|
return full_id[:length]
|
||||||
|
|
||||||
|
def get_hardware_info(self) -> dict:
|
||||||
|
"""
|
||||||
|
获取硬件信息(用于调试)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
硬件信息字典
|
||||||
|
"""
|
||||||
|
return self._collect_hardware_info()
|
||||||
|
|
||||||
|
|
||||||
|
# 使用示例
|
||||||
|
def main():
|
||||||
|
"""使用示例"""
|
||||||
|
# 创建设备ID生成器实例
|
||||||
|
device_generator = DeviceIDGenerator()
|
||||||
|
|
||||||
|
# 获取完整设备ID(64字符)
|
||||||
|
device_id = device_generator.get_device_id()
|
||||||
|
print(f"完整设备ID: {device_id}")
|
||||||
|
|
||||||
|
# 获取短版本设备ID(16字符)
|
||||||
|
short_id = device_generator.get_device_id_short(16)
|
||||||
|
print(f"短设备ID: {short_id}")
|
||||||
|
|
||||||
|
# 查看硬件信息(调试用)
|
||||||
|
hardware_info = device_generator.get_hardware_info()
|
||||||
|
print("\n硬件信息:")
|
||||||
|
for key, value in hardware_info.items():
|
||||||
|
print(f" {key}: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user