fix: 修复报错内容
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
|
||||
25
app/main.py
25
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
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class WechatPublishRequest(BaseModel):
|
||||
summary: str = ""
|
||||
body_markdown: str
|
||||
author: str = ""
|
||||
thumb_media_id: str = ""
|
||||
|
||||
|
||||
class IMPublishRequest(BaseModel):
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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", "发送中...");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user