new file: ali_oss.py

new file:   app.py
	new file:   app/ali_oss.py
	new file:   app/app.py
	new file:   app/app_common.py
	new file:   app/config.py
	new file:   app/coze.py
	new file:   app/generate_api.py
	new file:   app/html_crypto.py
	new file:   app/main.py
	new file:   app/web_source/admin.html
	new file:   app/web_source/brand.html
	new file:   app/web_source/home.html
	new file:   app/web_source/index.html
	new file:   app/web_source/login.html
	new file:   app_common.py
	new file:   blueprints/__init__.py
	new file:   blueprints/__pycache__/__init__.cpython-311.pyc
	new file:   blueprints/__pycache__/__init__.cpython-39.pyc
	new file:   blueprints/__pycache__/admin.cpython-311.pyc
	new file:   blueprints/__pycache__/admin.cpython-39.pyc
	new file:   blueprints/__pycache__/auth.cpython-311.pyc
	new file:   blueprints/__pycache__/auth.cpython-39.pyc
	new file:   blueprints/__pycache__/brand.cpython-311.pyc
	new file:   blueprints/__pycache__/brand.cpython-39.pyc
	new file:   blueprints/__pycache__/image.cpython-311.pyc
	new file:   blueprints/__pycache__/image.cpython-39.pyc
	new file:   blueprints/__pycache__/main.cpython-311.pyc
	new file:   blueprints/__pycache__/main.cpython-39.pyc
	new file:   blueprints/admin.py
	new file:   blueprints/auth.py
	new file:   blueprints/brand.py
	new file:   blueprints/image.py
	new file:   blueprints/main.py
	new file:   brand_spider/__pycache__/main.cpython-39.pyc
	new file:   brand_spider/__pycache__/web_dec.cpython-39.pyc
	new file:   brand_spider/main.py
	new file:   brand_spider/web_dec.py
	new file:   config.py
	new file:   coze.py
	new file:   generate_api.py
	new file:   html_crypto.py
	new file:   main.py
	new file:   static/bg.jpg
	new file:   "static/\345\223\201\347\211\214\346\226\207\346\241\243\346\240\274\345\274\217_\346\250\241\346\235\277.xlsx"
	new file:   "static/\346\250\241\346\235\2772-\344\273\245\346\226\207\344\273\266\345\244\271\346\226\271\345\274\217\344\270\212\344\274\240.zip"
	new file:   tool/.device_id
	new file:   tool/__pycache__/devices.cpython-311.pyc
	new file:   tool/__pycache__/devices.cpython-39.pyc
	new file:   tool/devices.py
This commit is contained in:
铭坤
2026-03-20 11:23:57 +08:00
parent 43d508f527
commit 8a64a6cd9b
45 changed files with 5253 additions and 0 deletions

73
app/ali_oss.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()