from __future__ import annotations import logging from urllib.parse import urlparse from fastapi import FastAPI, File, Request, UploadFile from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from app.config import settings from app.logging_setup import configure_logging from app.middleware import RequestContextMiddleware from app.schemas import IMPublishRequest, RewriteRequest, WechatPublishRequest from app.services.ai_rewriter import AIRewriter from app.services.im import IMPublisher from app.services.wechat import WechatPublisher configure_logging() logger = logging.getLogger(__name__) 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", settings.app_name, bool(settings.openai_api_key), settings.ai_soft_accept, ) app.add_middleware(RequestContextMiddleware) app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") rewriter = AIRewriter() wechat = WechatPublisher() im = IMPublisher() @app.get("/", response_class=HTMLResponse) async def index(request: Request): return templates.TemplateResponse("index.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") @app.get("/api/config") async def api_config(): """供页面展示:当前是否接入模型、模型名、提供方(不含密钥)。""" base = settings.openai_base_url or "" provider = "dashscope" if "dashscope.aliyuncs.com" in base else "openai_compatible" host = urlparse(base).netloc if base else "" return { "openai_configured": bool(settings.openai_api_key), "openai_model": settings.openai_model, "provider": provider, "base_url_host": host or None, "openai_timeout_sec": settings.openai_timeout, "openai_max_output_tokens": settings.openai_max_output_tokens, } @app.post("/api/rewrite") async def rewrite(req: RewriteRequest, request: Request): rid = getattr(request.state, "request_id", "") src = req.source_text or "" logger.info( "api_rewrite_in rid=%s source_chars=%d title_hint_chars=%d tone=%s audience=%s " "keep_points_chars=%d avoid_words_chars=%d", rid, len(src), len(req.title_hint or ""), req.tone, req.audience, len(req.keep_points or ""), len(req.avoid_words or ""), ) result = rewriter.rewrite(req, request_id=rid) 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", rid, result.mode, tr.get("duration_ms"), len(result.quality_notes or []), len((tr.get("steps") or [])), tr.get("quality_soft_accept"), ) return result @app.post("/api/publish/wechat") async def publish_wechat(req: WechatPublishRequest, request: Request): rid = getattr(request.state, "request_id", "") logger.info( "api_wechat_in rid=%s title_chars=%d summary_chars=%d body_md_chars=%d author_set=%s", rid, len(req.title or ""), len(req.summary or ""), len(req.body_markdown or ""), bool((req.author or "").strip()), ) out = await wechat.publish_draft(req, request_id=rid) wcode = (out.data or {}).get("errcode") if isinstance(out.data, dict) else None logger.info( "api_wechat_out rid=%s ok=%s wechat_errcode=%s detail_preview=%s", rid, out.ok, wcode, (out.detail or "")[:240], ) return out @app.post("/api/wechat/cover/upload") async def upload_wechat_cover(request: Request, file: UploadFile = File(...)): rid = getattr(request.state, "request_id", "") fn = file.filename or "cover.jpg" content = await file.read() logger.info("api_wechat_cover_upload_in rid=%s filename=%s bytes=%d", rid, fn, len(content)) out = await wechat.upload_cover(fn, content, request_id=rid) logger.info( "api_wechat_cover_upload_out rid=%s ok=%s detail=%s", rid, out.ok, (out.detail or "")[:160], ) return out @app.post("/api/publish/im") async def publish_im(req: IMPublishRequest, request: Request): rid = getattr(request.state, "request_id", "") logger.info( "api_im_in rid=%s title_chars=%d body_md_chars=%d", rid, len(req.title or ""), len(req.body_markdown or ""), ) out = await im.publish(req, request_id=rid) logger.info("api_im_out rid=%s ok=%s detail=%s", rid, out.ok, (out.detail or "")[:120]) return out