diff --git a/.env.example b/.env.example index 89e41e0..231e863 100644 --- a/.env.example +++ b/.env.example @@ -16,9 +16,17 @@ OPENAI_SOURCE_MAX_CHARS=5000 AI_SOFT_ACCEPT=true LOG_LEVEL=INFO +# 发布到公众号需:公众平台 → 基本配置 → IP 白名单,加入「本服务访问 api.weixin.qq.com 的出口公网 IP」。 +# 若 errcode=40164 invalid ip:把日志里的 IP 加入白名单;本地/Docker 出口 IP 常变,建议用固定 IP 服务器部署。 WECHAT_APPID= WECHAT_SECRET= WECHAT_AUTHOR=AI 编辑部 +# 封面(图文草稿必填,否则 errcode=40007):任选其一 +# ① 填永久素材 ID:WECHAT_THUMB_MEDIA_ID=(素材库 → 图片 → 复制 media_id) +# ② 填容器内图片路径,由服务自动上传:WECHAT_THUMB_IMAGE_PATH=/app/cover.jpg +# ③ 两项都不填:服务会用内置默认图自动上传(需 material 接口权限) +# WECHAT_THUMB_MEDIA_ID= +# WECHAT_THUMB_IMAGE_PATH= # 可填飞书/Slack/企微等 webhook IM_WEBHOOK_URL= diff --git a/app/config.py b/app/config.py index 86d749f..86eba6b 100644 --- a/app/config.py +++ b/app/config.py @@ -34,6 +34,16 @@ class Settings(BaseSettings): wechat_appid: str | None = Field(default=None, alias="WECHAT_APPID") wechat_secret: str | None = Field(default=None, alias="WECHAT_SECRET") wechat_author: str = Field(default="AI 编辑部", alias="WECHAT_AUTHOR") + wechat_thumb_media_id: str | None = Field( + default=None, + alias="WECHAT_THUMB_MEDIA_ID", + description="草稿图文封面:永久素材 media_id(素材库或 add_material)。与 WECHAT_THUMB_IMAGE_PATH 二选一即可", + ) + wechat_thumb_image_path: str | None = Field( + default=None, + alias="WECHAT_THUMB_IMAGE_PATH", + description="本地封面图路径(容器内),将自动上传为永久素材;不配则使用内置灰底图上传", + ) im_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL") im_secret: str | None = Field(default=None, alias="IM_SECRET") diff --git a/app/main.py b/app/main.py index 5d4510c..5449b0d 100644 --- a/app/main.py +++ b/app/main.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging from urllib.parse import urlparse -from fastapi import FastAPI, Request +from fastapi import FastAPI, File, Request, UploadFile from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -103,12 +103,29 @@ async def publish_wechat(req: WechatPublishRequest, request: Request): 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 detail=%s errcode=%s", + "api_wechat_out rid=%s ok=%s wechat_errcode=%s detail_preview=%s", rid, out.ok, - (out.detail or "")[:120], - (out.data or {}).get("errcode") if isinstance(out.data, dict) else None, + 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 diff --git a/app/schemas.py b/app/schemas.py index ffbadbf..82f6ed0 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -29,6 +29,7 @@ class WechatPublishRequest(BaseModel): summary: str = "" body_markdown: str author: str = "" + thumb_media_id: str = "" class IMPublishRequest(BaseModel): diff --git a/app/services/wechat.py b/app/services/wechat.py index 4bcc658..194bfc0 100644 --- a/app/services/wechat.py +++ b/app/services/wechat.py @@ -2,6 +2,8 @@ from __future__ import annotations import logging import time +from io import BytesIO +from pathlib import Path import httpx import markdown2 @@ -11,6 +13,21 @@ from app.schemas import PublishResponse, WechatPublishRequest logger = logging.getLogger(__name__) +_default_cover_jpeg_bytes: bytes | None = None + + +def _build_default_cover_jpeg() -> tuple[bytes, str]: + """生成简单灰底封面(360×200),满足微信对缩略图尺寸的常规要求。""" + global _default_cover_jpeg_bytes + if _default_cover_jpeg_bytes is None: + from PIL import Image + + im = Image.new("RGB", (360, 200), (236, 240, 241)) + buf = BytesIO() + im.save(buf, format="JPEG", quality=88) + _default_cover_jpeg_bytes = buf.getvalue() + return _default_cover_jpeg_bytes, "cover_default.jpg" + def _detail_for_token_error(data: dict | None) -> str: """把微信返回的 errcode 转成可操作的说明。""" @@ -32,10 +49,24 @@ def _detail_for_token_error(data: dict | None) -> str: return f"获取微信 access_token 失败:errcode={code} errmsg={msg}" +def _detail_for_draft_error(data: dict) -> str: + code = data.get("errcode") + msg = (data.get("errmsg") or "").strip() + if code == 40007: + return ( + "微信 errcode=40007(invalid media_id):thumb_media_id 缺失、不是「永久图片素材」、或已失效。" + "请核对 WECHAT_THUMB_MEDIA_ID 是否从素材管理里复制的永久素材;若不确定,可删掉该变量," + "由服务自动上传封面(WECHAT_THUMB_IMAGE_PATH 或内置默认图)。" + f" 微信原文:{msg}" + ) + return f"微信草稿失败:errcode={code} errmsg={msg}" + + class WechatPublisher: def __init__(self) -> None: self._access_token = None self._expires_at = 0 + self._runtime_thumb_media_id: str | None = None async def publish_draft(self, req: WechatPublishRequest, request_id: str = "") -> PublishResponse: rid = request_id or "-" @@ -55,6 +86,21 @@ class WechatPublisher: token_from_cache, ) + req_thumb = (req.thumb_media_id or "").strip() + if req_thumb: + logger.info("wechat_thumb rid=%s source=request_media_id", rid) + thumb_id = req_thumb + else: + thumb_id = await self._resolve_thumb_media_id(token, rid) + if not thumb_id: + return PublishResponse( + ok=False, + detail=( + "无法上传封面素材(material/add_material 失败)。" + "请检查公众号是否开通素材接口权限,或手动在素材库上传后配置 WECHAT_THUMB_MEDIA_ID。" + ), + ) + html = markdown2.markdown(req.body_markdown) logger.info( "wechat_draft_build rid=%s title_chars=%d digest_chars=%d html_chars=%d", @@ -63,29 +109,46 @@ class WechatPublisher: len(req.summary or ""), len(html or ""), ) + # 图文 news:thumb_media_id 为必填(永久素材),否则 errcode=40007 payload = { "articles": [ { - "title": req.title, - "author": req.author or settings.wechat_author, - "digest": req.summary, + "article_type": "news", + "title": req.title[:32] if len(req.title) > 32 else req.title, + "author": (req.author or settings.wechat_author)[:16], + "digest": (req.summary or "")[:128], "content": html, "content_source_url": "", + "thumb_media_id": thumb_id, "need_open_comment": 0, "only_fans_can_comment": 0, } ] } + explicit_used = bool((settings.wechat_thumb_media_id or "").strip()) + draft_url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}" async with httpx.AsyncClient(timeout=25) as client: - url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}" logger.info( "wechat_http_post rid=%s endpoint=cgi-bin/draft/add http_timeout_s=25", rid, ) - r = await client.post(url, json=payload) + r = await client.post(draft_url, json=payload) data = r.json() + if data.get("errcode") == 40007 and explicit_used: + logger.warning( + "wechat_draft_40007_retry rid=%s hint=config_media_id_invalid_try_auto_upload", + rid, + ) + self._runtime_thumb_media_id = None + thumb_alt = await self._resolve_thumb_media_id(token, rid, force_skip_explicit=True) + if thumb_alt: + payload["articles"][0]["thumb_media_id"] = thumb_alt + async with httpx.AsyncClient(timeout=25) as client: + r = await client.post(draft_url, json=payload) + data = r.json() + if data.get("errcode", 0) != 0: logger.warning( "wechat_draft_failed rid=%s errcode=%s errmsg=%s raw=%s", @@ -94,7 +157,8 @@ class WechatPublisher: data.get("errmsg"), data, ) - return PublishResponse(ok=False, detail=f"微信发布失败: {data}", data=data) + detail = _detail_for_draft_error(data) if isinstance(data, dict) else f"微信发布失败: {data}" + return PublishResponse(ok=False, detail=detail, data=data) logger.info( "wechat_draft_ok rid=%s media_id=%s", @@ -103,6 +167,82 @@ class WechatPublisher: ) return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data) + async def upload_cover(self, filename: str, content: bytes, request_id: str = "") -> PublishResponse: + """上传封面到微信永久素材,返回 thumb_media_id。""" + rid = request_id or "-" + if not settings.wechat_appid or not settings.wechat_secret: + return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置") + if not content: + return PublishResponse(ok=False, detail="封面文件为空") + + token, _, token_err_body = await self._get_access_token() + if not token: + return PublishResponse(ok=False, detail=_detail_for_token_error(token_err_body), data=token_err_body) + + async with httpx.AsyncClient(timeout=60) as client: + mid = await self._upload_permanent_image(client, token, content, filename) + if not mid: + return PublishResponse( + ok=False, + detail="封面上传失败:请检查图片格式/大小,或查看日志中的 wechat_material_add_failed", + ) + + self._runtime_thumb_media_id = mid + logger.info("wechat_cover_upload_ok rid=%s filename=%s media_id=%s", rid, filename, mid) + return PublishResponse(ok=True, detail="封面上传成功", data={"thumb_media_id": mid, "filename": filename}) + + async def _upload_permanent_image( + self, client: httpx.AsyncClient, token: str, content: bytes, filename: str + ) -> str | None: + url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image" + ctype = "image/png" if filename.lower().endswith(".png") else "image/jpeg" + files = {"media": (filename, content, ctype)} + r = await client.post(url, files=files) + data = r.json() + if data.get("errcode"): + logger.warning("wechat_material_add_failed body=%s", data) + return None + mid = data.get("media_id") + if not mid: + logger.warning("wechat_material_add_no_media_id body=%s", data) + return None + return mid + + async def _resolve_thumb_media_id(self, token: str, rid: str, *, force_skip_explicit: bool = False) -> str | None: + """draft/add 要求 thumb_media_id 为永久图片素材;优先用配置,否则上传文件或内置图。""" + explicit = (settings.wechat_thumb_media_id or "").strip() + if explicit and not force_skip_explicit: + logger.info("wechat_thumb rid=%s source=config_media_id", rid) + return explicit + + if self._runtime_thumb_media_id and not force_skip_explicit: + logger.info("wechat_thumb rid=%s source=runtime_cache", rid) + return self._runtime_thumb_media_id + + path = (settings.wechat_thumb_image_path or "").strip() + async with httpx.AsyncClient(timeout=60) as client: + if path: + p = Path(path) + if p.is_file(): + content = p.read_bytes() + mid = await self._upload_permanent_image(client, token, content, p.name) + if mid: + self._runtime_thumb_media_id = mid + logger.info("wechat_thumb rid=%s source=path_upload ok=1", rid) + return mid + logger.warning("wechat_thumb rid=%s source=path_upload ok=0 path=%s", rid, path) + else: + logger.warning("wechat_thumb rid=%s path_not_found=%s", rid, path) + + content, fname = _build_default_cover_jpeg() + mid = await self._upload_permanent_image(client, token, content, fname) + if mid: + self._runtime_thumb_media_id = mid + logger.info("wechat_thumb rid=%s source=default_jpeg_upload ok=1", rid) + return mid + logger.error("wechat_thumb rid=%s source=default_jpeg_upload ok=0", rid) + return None + async def _get_access_token(self) -> tuple[str | None, bool, dict | None]: """成功时第三项为 None;失败时为微信返回的 JSON(含 errcode/errmsg)。""" now = int(time.time()) diff --git a/app/static/app.js b/app/static/app.js index 7722ba1..234a804 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -15,6 +15,7 @@ const statusEl = $("status"); const rewriteBtn = $("rewriteBtn"); const wechatBtn = $("wechatBtn"); const imBtn = $("imBtn"); +const coverUploadBtn = $("coverUploadBtn"); function countText(v) { return (v || "").trim().length; @@ -130,6 +131,7 @@ $("wechatBtn").addEventListener("click", async () => { title: $("title").value, summary: $("summary").value, body_markdown: $("body").value, + thumb_media_id: $("thumbMediaId") ? $("thumbMediaId").value.trim() : "", }); if (!data.ok) throw new Error(data.detail); setStatus("公众号草稿发布成功"); @@ -140,6 +142,36 @@ $("wechatBtn").addEventListener("click", async () => { } }); +if (coverUploadBtn) { + coverUploadBtn.addEventListener("click", async () => { + const fileInput = $("coverFile"); + const hint = $("coverHint"); + const file = fileInput && fileInput.files && fileInput.files[0]; + if (!file) { + setStatus("请先选择封面图片再上传", true); + return; + } + if (hint) hint.textContent = "正在上传封面..."; + setLoading(coverUploadBtn, true, "上传封面并绑定", "上传中..."); + try { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch("/api/wechat/cover/upload", { method: "POST", body: fd }); + const data = await res.json(); + if (!res.ok || !data.ok) throw new Error(data.detail || "封面上传失败"); + const mid = data.data && data.data.thumb_media_id ? data.data.thumb_media_id : ""; + if ($("thumbMediaId")) $("thumbMediaId").value = mid; + if (hint) hint.textContent = `封面上传成功,已绑定 media_id:${mid}`; + setStatus("封面上传成功,发布时将优先使用该封面。"); + } catch (e) { + if (hint) hint.textContent = "封面上传失败,请看状态提示。"; + setStatus(`封面上传失败: ${e.message}`, true); + } finally { + setLoading(coverUploadBtn, false, "上传封面并绑定", "上传中..."); + } + }); +} + $("imBtn").addEventListener("click", async () => { setStatus("正在发送到 IM..."); setLoading(imBtn, true, "发送到 IM", "发送中..."); diff --git a/app/static/style.css b/app/static/style.css index 4b11b9d..f6cdf7a 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -159,6 +159,19 @@ button:disabled { gap: 10px; } +.cover-tools { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: center; +} + +.cover-tools button { + margin-top: 0; + width: auto; + white-space: nowrap; +} + .status { min-height: 22px; margin-top: 8px; diff --git a/app/templates/index.html b/app/templates/index.html index 10e3435..55e37ac 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -74,6 +74,14 @@ + +
未上传时将使用后端默认封面策略。
+