This commit is contained in:
Daniel
2026-04-28 11:50:55 +08:00
parent 1bbabc2a78
commit 2724e69b4f
20 changed files with 3881 additions and 554 deletions

View File

@@ -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", "")