fix: bug
This commit is contained in:
335
app/main.py
335
app/main.py
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import FastAPI, File, Request, Response, UploadFile
|
||||
import httpx
|
||||
from fastapi import FastAPI, File, HTTPException, Request, Response, UploadFile
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -13,17 +14,26 @@ from app.config import settings
|
||||
from app.logging_setup import configure_logging
|
||||
from app.middleware import RequestContextMiddleware
|
||||
from app.schemas import (
|
||||
AIModelCreateRequest,
|
||||
AIModelDeleteRequest,
|
||||
AIModelSwitchRequest,
|
||||
AuthCredentialRequest,
|
||||
ChangePasswordRequest,
|
||||
DeleteAccountRequest,
|
||||
ForgotPasswordResetRequest,
|
||||
IMPublishRequest,
|
||||
PosterGenerateRequest,
|
||||
RewriteRequest,
|
||||
WechatCoverUploadByUrlRequest,
|
||||
WechatCoverGenerateRequest,
|
||||
WechatDeleteRequest,
|
||||
WechatBindingRequest,
|
||||
WechatPublishRequest,
|
||||
WechatSwitchRequest,
|
||||
)
|
||||
from app.services.ai_rewriter import AIRewriter
|
||||
from app.services.im import IMPublisher
|
||||
from app.services.poster_material import PosterMaterialService
|
||||
from app.services.user_store import UserStore
|
||||
from app.services.wechat import WechatPublisher
|
||||
|
||||
@@ -36,9 +46,9 @@ app = FastAPI(title=settings.app_name)
|
||||
@app.on_event("startup")
|
||||
async def _log_startup() -> None:
|
||||
logger.info(
|
||||
"app_start name=%s openai_configured=%s ai_soft_accept=%s",
|
||||
"app_start name=%s user_model_required=%s ai_soft_accept=%s",
|
||||
settings.app_name,
|
||||
bool(settings.openai_api_key),
|
||||
True,
|
||||
settings.ai_soft_accept,
|
||||
)
|
||||
|
||||
@@ -49,6 +59,7 @@ templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
rewriter = AIRewriter()
|
||||
wechat = WechatPublisher()
|
||||
poster_material = PosterMaterialService(wechat)
|
||||
im = IMPublisher()
|
||||
users = UserStore(settings.auth_db_path)
|
||||
|
||||
@@ -96,25 +107,38 @@ async def settings_page(request: Request):
|
||||
return templates.TemplateResponse("settings.html", {"request": request, "app_name": settings.app_name})
|
||||
|
||||
|
||||
@app.get("/guide", response_class=HTMLResponse)
|
||||
async def guide_page(request: Request):
|
||||
if not _current_user(request):
|
||||
return RedirectResponse(url="/auth?next=/guide", status_code=302)
|
||||
return templates.TemplateResponse("guide.html", {"request": request, "app_name": settings.app_name})
|
||||
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon():
|
||||
# 浏览器通常请求 /favicon.ico,统一跳转到静态图标
|
||||
return RedirectResponse(url="/static/favicon.svg?v=20260406a")
|
||||
return RedirectResponse(url="/static/favicon.svg?v=20260428h")
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
async def api_config():
|
||||
async def api_config(request: Request):
|
||||
"""供页面展示:当前是否接入模型、模型名、提供方(不含密钥)。"""
|
||||
base = settings.openai_base_url or ""
|
||||
user = _current_user(request)
|
||||
model_cfg = users.get_active_ai_model(user["id"]) if user else None
|
||||
base = (model_cfg or {}).get("base_url") or ""
|
||||
provider = "dashscope" if "dashscope.aliyuncs.com" in base else "openai_compatible"
|
||||
host = urlparse(base).netloc if base else ""
|
||||
model_name = (model_cfg or {}).get("model") or None
|
||||
timeout_sec = (model_cfg or {}).get("timeout_sec") or None
|
||||
max_output_tokens = (model_cfg or {}).get("max_output_tokens") or None
|
||||
key_configured = bool((model_cfg or {}).get("api_key"))
|
||||
return {
|
||||
"openai_configured": bool(settings.openai_api_key),
|
||||
"openai_model": settings.openai_model,
|
||||
"openai_configured": key_configured,
|
||||
"openai_model": model_name,
|
||||
"provider": provider,
|
||||
"base_url_host": host or None,
|
||||
"openai_timeout_sec": settings.openai_timeout,
|
||||
"openai_max_output_tokens": settings.openai_max_output_tokens,
|
||||
"openai_timeout_sec": timeout_sec,
|
||||
"openai_max_output_tokens": max_output_tokens,
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +156,8 @@ async def auth_me(request: Request):
|
||||
"wechat_bound": bool(binding and binding.get("appid") and binding.get("secret")),
|
||||
"active_wechat_account": binding,
|
||||
"wechat_accounts": bindings,
|
||||
"active_ai_model": users.get_active_ai_model(user["id"]),
|
||||
"ai_models": users.list_ai_models(user["id"]),
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +186,12 @@ async def auth_register(req: AuthCredentialRequest, response: Response):
|
||||
max_age=ttl,
|
||||
path="/",
|
||||
)
|
||||
return {"ok": True, "detail": "注册并登录成功", "user": user}
|
||||
return {
|
||||
"ok": True,
|
||||
"detail": "注册并登录成功,请保存重置码",
|
||||
"user": {"id": user["id"], "username": user["username"]},
|
||||
"reset_code": user.get("reset_code", ""),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
@@ -201,23 +232,17 @@ async def forgot_password_page(request: Request):
|
||||
|
||||
@app.post("/api/auth/password/forgot")
|
||||
async def auth_forgot_password_reset(req: ForgotPasswordResetRequest):
|
||||
reset_key = (req.reset_key or "").strip()
|
||||
expected_key = (settings.auth_password_reset_key or "x2ws-reset-2026").strip()
|
||||
username = (req.username or "").strip()
|
||||
new_password = req.new_password or ""
|
||||
|
||||
if not expected_key:
|
||||
return {"ok": False, "detail": "系统未启用忘记密码重置功能,请联系管理员"}
|
||||
if len(username) < 2:
|
||||
return {"ok": False, "detail": "请输入正确的用户名"}
|
||||
if len(new_password) < 6:
|
||||
return {"ok": False, "detail": "新密码至少 6 个字符"}
|
||||
if not hmac.compare_digest(reset_key, expected_key):
|
||||
return {"ok": False, "detail": "重置码错误"}
|
||||
|
||||
ok = users.reset_password_by_username(username, new_password)
|
||||
ok = users.reset_password_by_username(username, req.reset_key, new_password)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": "用户不存在,无法重置"}
|
||||
return {"ok": False, "detail": "用户名或重置码错误,无法重置"}
|
||||
return {"ok": True, "detail": "密码重置成功,请返回登录页重新登录"}
|
||||
|
||||
|
||||
@@ -254,6 +279,25 @@ async def auth_change_password(req: ChangePasswordRequest, request: Request, res
|
||||
return {"ok": True, "detail": "密码修改成功,已刷新登录状态"}
|
||||
|
||||
|
||||
@app.post("/api/auth/account/delete")
|
||||
async def auth_delete_account(req: DeleteAccountRequest, request: Request, response: Response):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
if len((req.password or "").strip()) < 1:
|
||||
return {"ok": False, "detail": "请输入当前密码"}
|
||||
if len((req.reset_key or "").strip()) < 4:
|
||||
return {"ok": False, "detail": "请输入重置码"}
|
||||
ok = users.delete_user_logically(user["id"], req.password, req.reset_key)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": "密码或重置码错误,注销失败"}
|
||||
token = request.cookies.get(settings.auth_cookie_name, "")
|
||||
if token:
|
||||
users.delete_session(token)
|
||||
response.delete_cookie(settings.auth_cookie_name, path="/")
|
||||
return {"ok": True, "detail": "账号已注销,关联数据已清空"}
|
||||
|
||||
|
||||
@app.post("/api/auth/wechat/bind")
|
||||
async def auth_wechat_bind(req: WechatBindingRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
@@ -286,6 +330,63 @@ async def auth_wechat_switch(req: WechatSwitchRequest, request: Request):
|
||||
return {"ok": True, "detail": "已切换当前公众号账号"}
|
||||
|
||||
|
||||
@app.post("/api/auth/wechat/delete")
|
||||
async def auth_wechat_delete(req: WechatDeleteRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
ok = users.delete_wechat_binding(user["id"], int(req.account_id))
|
||||
if not ok:
|
||||
return {"ok": False, "detail": "删除失败:账号不存在或无权限"}
|
||||
return {"ok": True, "detail": "公众号账号已删除"}
|
||||
|
||||
|
||||
@app.post("/api/auth/ai-models/add")
|
||||
async def auth_ai_model_add(req: AIModelCreateRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
api_key = (req.api_key or "").strip()
|
||||
model = (req.model or "").strip()
|
||||
if not api_key:
|
||||
return {"ok": False, "detail": "API Key 不能为空"}
|
||||
if not model:
|
||||
return {"ok": False, "detail": "模型名不能为空"}
|
||||
created = users.add_ai_model(
|
||||
user_id=user["id"],
|
||||
model_name=(req.model_name or "").strip(),
|
||||
api_key=api_key,
|
||||
base_url=(req.base_url or "").strip(),
|
||||
model=model,
|
||||
timeout_sec=max(10.0, float(req.timeout_sec or 120.0)),
|
||||
max_output_tokens=max(256, int(req.max_output_tokens or 8192)),
|
||||
max_retries=max(0, int(req.max_retries or 0)),
|
||||
)
|
||||
return {"ok": True, "detail": "模型配置已保存并设为当前", "model_config": created}
|
||||
|
||||
|
||||
@app.post("/api/auth/ai-models/switch")
|
||||
async def auth_ai_model_switch(req: AIModelSwitchRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
ok = users.switch_active_ai_model(user["id"], int(req.model_id))
|
||||
if not ok:
|
||||
return {"ok": False, "detail": "切换失败:模型不存在或无权限"}
|
||||
return {"ok": True, "detail": "已切换当前模型"}
|
||||
|
||||
|
||||
@app.post("/api/auth/ai-models/delete")
|
||||
async def auth_ai_model_delete(req: AIModelDeleteRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
ok = users.delete_ai_model(user["id"], int(req.model_id))
|
||||
if not ok:
|
||||
return {"ok": False, "detail": "删除失败:模型不存在或无权限"}
|
||||
return {"ok": True, "detail": "模型配置已删除"}
|
||||
|
||||
|
||||
@app.post("/api/rewrite")
|
||||
async def rewrite(req: RewriteRequest, request: Request):
|
||||
rid = getattr(request.state, "request_id", "")
|
||||
@@ -302,7 +403,35 @@ async def rewrite(req: RewriteRequest, request: Request):
|
||||
len(req.avoid_words or ""),
|
||||
int(req.target_body_chars or 500),
|
||||
)
|
||||
result = rewriter.rewrite(req, request_id=rid)
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="请先登录")
|
||||
model_cfg = users.get_active_ai_model(user["id"])
|
||||
if not model_cfg:
|
||||
raise HTTPException(status_code=400, detail="请先在设置页配置 AI 模型")
|
||||
backup = {
|
||||
"openai_api_key": settings.openai_api_key,
|
||||
"openai_base_url": settings.openai_base_url,
|
||||
"openai_model": settings.openai_model,
|
||||
"openai_timeout": settings.openai_timeout,
|
||||
"openai_max_output_tokens": settings.openai_max_output_tokens,
|
||||
"openai_max_retries": settings.openai_max_retries,
|
||||
}
|
||||
try:
|
||||
settings.openai_api_key = model_cfg.get("api_key") or ""
|
||||
settings.openai_base_url = model_cfg.get("base_url") or ""
|
||||
settings.openai_model = model_cfg.get("model") or ""
|
||||
settings.openai_timeout = float(model_cfg.get("timeout_sec") or 120.0)
|
||||
settings.openai_max_output_tokens = int(model_cfg.get("max_output_tokens") or 8192)
|
||||
settings.openai_max_retries = int(model_cfg.get("max_retries") or 0)
|
||||
result = AIRewriter().rewrite(req, request_id=rid)
|
||||
finally:
|
||||
settings.openai_api_key = backup["openai_api_key"]
|
||||
settings.openai_base_url = backup["openai_base_url"]
|
||||
settings.openai_model = backup["openai_model"]
|
||||
settings.openai_timeout = backup["openai_timeout"]
|
||||
settings.openai_max_output_tokens = backup["openai_max_output_tokens"]
|
||||
settings.openai_max_retries = backup["openai_max_retries"]
|
||||
tr = result.trace or {}
|
||||
logger.info(
|
||||
"api_rewrite_out rid=%s mode=%s duration_ms=%s quality_notes=%d trace_steps=%s soft_accept=%s",
|
||||
@@ -367,6 +496,112 @@ async def upload_wechat_cover(request: Request, file: UploadFile = File(...)):
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/wechat/cover/upload-by-url")
|
||||
async def upload_wechat_cover_by_url(req: WechatCoverUploadByUrlRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
binding = users.get_active_wechat_binding(user["id"])
|
||||
if not binding:
|
||||
return {"ok": False, "detail": "当前账号未绑定公众号 token,请先在页面绑定"}
|
||||
rid = getattr(request.state, "request_id", "")
|
||||
image_url = (req.image_url or "").strip()
|
||||
if not image_url:
|
||||
return {"ok": False, "detail": "图片 URL 不能为空"}
|
||||
parsed = urlparse(image_url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
return {"ok": False, "detail": "仅支持 http/https 图片地址"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
resp = await client.get(image_url)
|
||||
except Exception as exc:
|
||||
logger.warning("api_wechat_cover_url_fetch_fail rid=%s url=%s err=%s", rid, image_url, exc)
|
||||
return {"ok": False, "detail": "图片下载失败,请检查 URL 是否可访问"}
|
||||
if resp.status_code >= 400:
|
||||
return {"ok": False, "detail": f"图片下载失败,HTTP {resp.status_code}"}
|
||||
content_type = (resp.headers.get("content-type") or "").lower()
|
||||
if not content_type.startswith("image/"):
|
||||
return {"ok": False, "detail": "URL 指向的内容不是图片"}
|
||||
content = resp.content or b""
|
||||
if not content:
|
||||
return {"ok": False, "detail": "图片内容为空"}
|
||||
if len(content) > 10 * 1024 * 1024:
|
||||
return {"ok": False, "detail": "图片过大,请使用 10MB 以内的图片"}
|
||||
|
||||
ext = Path(parsed.path or "").suffix.lower()
|
||||
if ext not in {".jpg", ".jpeg", ".png", ".webp"}:
|
||||
ext = ".png" if "png" in content_type else ".jpg"
|
||||
fn = f"cover_from_url{ext}"
|
||||
logger.info("api_wechat_cover_upload_url_in rid=%s url=%s bytes=%d", rid, image_url, len(content))
|
||||
out = await wechat.upload_cover(fn, content, request_id=rid, account=binding)
|
||||
logger.info(
|
||||
"api_wechat_cover_upload_url_out rid=%s ok=%s detail=%s",
|
||||
rid,
|
||||
out.ok,
|
||||
(out.detail or "")[:160],
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/wechat/cover/generate")
|
||||
async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
binding = users.get_active_wechat_binding(user["id"]) if req.upload_to_wechat else None
|
||||
if req.upload_to_wechat and not binding:
|
||||
return {"ok": False, "detail": "当前账号未绑定公众号 token,请先在页面绑定"}
|
||||
rid = getattr(request.state, "request_id", "")
|
||||
logger.info(
|
||||
"api_wechat_cover_generate_in rid=%s title_chars=%d summary_chars=%d upload_to_wechat=%s",
|
||||
rid,
|
||||
len(req.title or ""),
|
||||
len(req.summary or ""),
|
||||
req.upload_to_wechat,
|
||||
)
|
||||
model_cfg = users.get_active_ai_model(user["id"])
|
||||
backup = {
|
||||
"openai_api_key": settings.openai_api_key,
|
||||
"openai_base_url": settings.openai_base_url,
|
||||
"openai_model": settings.openai_model,
|
||||
"openai_timeout": settings.openai_timeout,
|
||||
"openai_max_output_tokens": settings.openai_max_output_tokens,
|
||||
"openai_max_retries": settings.openai_max_retries,
|
||||
}
|
||||
try:
|
||||
if model_cfg:
|
||||
settings.openai_api_key = model_cfg.get("api_key") or ""
|
||||
settings.openai_base_url = model_cfg.get("base_url") or ""
|
||||
settings.openai_model = model_cfg.get("model") or ""
|
||||
settings.openai_timeout = float(model_cfg.get("timeout_sec") or 120.0)
|
||||
settings.openai_max_output_tokens = int(model_cfg.get("max_output_tokens") or 8192)
|
||||
settings.openai_max_retries = int(model_cfg.get("max_retries") or 0)
|
||||
else:
|
||||
settings.openai_api_key = ""
|
||||
settings.openai_base_url = ""
|
||||
settings.openai_model = ""
|
||||
settings.openai_timeout = 120.0
|
||||
settings.openai_max_output_tokens = 8192
|
||||
settings.openai_max_retries = 0
|
||||
out = await PosterMaterialService(wechat).generate_cover(req, request_id=rid, account=binding)
|
||||
finally:
|
||||
settings.openai_api_key = backup["openai_api_key"]
|
||||
settings.openai_base_url = backup["openai_base_url"]
|
||||
settings.openai_model = backup["openai_model"]
|
||||
settings.openai_timeout = backup["openai_timeout"]
|
||||
settings.openai_max_output_tokens = backup["openai_max_output_tokens"]
|
||||
settings.openai_max_retries = backup["openai_max_retries"]
|
||||
logger.info(
|
||||
"api_wechat_cover_generate_out rid=%s ok=%s thumb=%s note=%s warnings=%d",
|
||||
rid,
|
||||
out.ok,
|
||||
bool(out.thumb_media_id),
|
||||
out.note,
|
||||
len(out.warnings),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/wechat/material/upload")
|
||||
async def upload_wechat_material(request: Request, file: UploadFile = File(...)):
|
||||
user = _require_user(request)
|
||||
@@ -389,6 +624,64 @@ async def upload_wechat_material(request: Request, file: UploadFile = File(...))
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/material/posters/generate")
|
||||
async def generate_posters(req: PosterGenerateRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
binding = users.get_active_wechat_binding(user["id"]) if req.upload_to_wechat else None
|
||||
if req.upload_to_wechat and not binding:
|
||||
return {"ok": False, "detail": "当前账号未绑定公众号 token,请先在页面绑定"}
|
||||
rid = getattr(request.state, "request_id", "")
|
||||
logger.info(
|
||||
"api_poster_generate_in rid=%s body_chars=%d upload_to_wechat=%s max_images=%d",
|
||||
rid,
|
||||
len(req.body_markdown or ""),
|
||||
req.upload_to_wechat,
|
||||
int(req.max_images or 0),
|
||||
)
|
||||
model_cfg = users.get_active_ai_model(user["id"])
|
||||
backup = {
|
||||
"openai_api_key": settings.openai_api_key,
|
||||
"openai_base_url": settings.openai_base_url,
|
||||
"openai_model": settings.openai_model,
|
||||
"openai_timeout": settings.openai_timeout,
|
||||
"openai_max_output_tokens": settings.openai_max_output_tokens,
|
||||
"openai_max_retries": settings.openai_max_retries,
|
||||
}
|
||||
try:
|
||||
if model_cfg:
|
||||
settings.openai_api_key = model_cfg.get("api_key") or ""
|
||||
settings.openai_base_url = model_cfg.get("base_url") or ""
|
||||
settings.openai_model = model_cfg.get("model") or ""
|
||||
settings.openai_timeout = float(model_cfg.get("timeout_sec") or 120.0)
|
||||
settings.openai_max_output_tokens = int(model_cfg.get("max_output_tokens") or 8192)
|
||||
settings.openai_max_retries = int(model_cfg.get("max_retries") or 0)
|
||||
else:
|
||||
settings.openai_api_key = ""
|
||||
settings.openai_base_url = ""
|
||||
settings.openai_model = ""
|
||||
settings.openai_timeout = 120.0
|
||||
settings.openai_max_output_tokens = 8192
|
||||
settings.openai_max_retries = 0
|
||||
out = await PosterMaterialService(wechat).generate(req, request_id=rid, account=binding)
|
||||
finally:
|
||||
settings.openai_api_key = backup["openai_api_key"]
|
||||
settings.openai_base_url = backup["openai_base_url"]
|
||||
settings.openai_model = backup["openai_model"]
|
||||
settings.openai_timeout = backup["openai_timeout"]
|
||||
settings.openai_max_output_tokens = backup["openai_max_output_tokens"]
|
||||
settings.openai_max_retries = backup["openai_max_retries"]
|
||||
logger.info(
|
||||
"api_poster_generate_out rid=%s ok=%s posters=%d warnings=%d",
|
||||
rid,
|
||||
out.ok,
|
||||
len(out.posters),
|
||||
len(out.warnings),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/publish/im")
|
||||
async def publish_im(req: IMPublishRequest, request: Request):
|
||||
rid = getattr(request.state, "request_id", "")
|
||||
|
||||
Reference in New Issue
Block a user