fix: 修复报错内容

This commit is contained in:
Daniel
2026-04-06 15:28:15 +08:00
parent 1d389767e6
commit b342a90f9d
9 changed files with 240 additions and 10 deletions

View File

@@ -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任选其一
# ① 填永久素材 IDWECHAT_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=

View File

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

View File

@@ -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

View File

@@ -29,6 +29,7 @@ class WechatPublishRequest(BaseModel):
summary: str = ""
body_markdown: str
author: str = ""
thumb_media_id: str = ""
class IMPublishRequest(BaseModel):

View File

@@ -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=40007invalid media_idthumb_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,27 +109,44 @@ class WechatPublisher:
len(req.summary or ""),
len(html or ""),
)
# 图文 newsthumb_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:
@@ -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())

View File

@@ -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", "发送中...");

View File

@@ -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;

View File

@@ -74,6 +74,14 @@
</div>
<textarea id="summary" rows="3"></textarea>
<label>公众号封面(可选上传)</label>
<div class="cover-tools">
<input id="coverFile" type="file" accept="image/png,image/jpeg,image/jpg,image/webp" />
<button id="coverUploadBtn" type="button">上传封面并绑定</button>
</div>
<input id="thumbMediaId" type="text" placeholder="thumb_media_id上传后自动填充也可手动粘贴" />
<p id="coverHint" class="muted small">未上传时将使用后端默认封面策略。</p>
<div class="field-head">
<label>正文5 自然段,建议 ≤500 字)</label>
<span id="bodyCount" class="meta">0 字</span>

View File

@@ -6,3 +6,4 @@ httpx==0.28.1
openai==1.108.2
markdown2==2.5.4
python-multipart==0.0.20
Pillow>=10.0.0