fix: 更新当前界面,支持多公帐号切换

This commit is contained in:
Daniel
2026-04-10 12:47:03 +08:00
parent 5b4bee1939
commit e69666dbb3
20 changed files with 1809 additions and 60 deletions

View File

@@ -64,17 +64,34 @@ def _detail_for_draft_error(data: dict) -> str:
class WechatPublisher:
def __init__(self) -> None:
self._access_token = None
self._expires_at = 0
self._token_cache: dict[str, dict[str, int | str]] = {}
self._runtime_thumb_media_id: str | None = None
async def publish_draft(self, req: WechatPublishRequest, request_id: str = "") -> PublishResponse:
def _resolve_account(self, account: dict | None = None) -> dict[str, str]:
src = account or {}
appid = (src.get("appid") or settings.wechat_appid or "").strip()
secret = (src.get("secret") or settings.wechat_secret or "").strip()
author = (src.get("author") or settings.wechat_author or "").strip()
thumb_media_id = (src.get("thumb_media_id") or settings.wechat_thumb_media_id or "").strip()
thumb_image_path = (src.get("thumb_image_path") or settings.wechat_thumb_image_path or "").strip()
return {
"appid": appid,
"secret": secret,
"author": author,
"thumb_media_id": thumb_media_id,
"thumb_image_path": thumb_image_path,
}
async def publish_draft(
self, req: WechatPublishRequest, request_id: str = "", account: dict | None = None
) -> PublishResponse:
rid = request_id or "-"
if not settings.wechat_appid or not settings.wechat_secret:
acct = self._resolve_account(account)
if not acct["appid"] or not acct["secret"]:
logger.warning("wechat skipped rid=%s reason=missing_appid_or_secret", rid)
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
token, token_from_cache, token_err_body = await self._get_access_token()
token, token_from_cache, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
if not token:
detail = _detail_for_token_error(token_err_body)
logger.error("wechat access_token_unavailable rid=%s detail=%s", rid, detail[:200])
@@ -91,7 +108,7 @@ class WechatPublisher:
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)
thumb_id = await self._resolve_thumb_media_id(token, rid, account=acct)
if not thumb_id:
return PublishResponse(
ok=False,
@@ -115,7 +132,7 @@ class WechatPublisher:
{
"article_type": "news",
"title": req.title[:32] if len(req.title) > 32 else req.title,
"author": (req.author or settings.wechat_author)[:16],
"author": (req.author or acct["author"] or settings.wechat_author)[:16],
"digest": (req.summary or "")[:128],
"content": html,
"content_source_url": "",
@@ -126,7 +143,7 @@ class WechatPublisher:
]
}
explicit_used = bool((settings.wechat_thumb_media_id or "").strip())
explicit_used = bool((acct.get("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:
logger.info(
@@ -142,7 +159,9 @@ class WechatPublisher:
rid,
)
self._runtime_thumb_media_id = None
thumb_alt = await self._resolve_thumb_media_id(token, rid, force_skip_explicit=True)
thumb_alt = await self._resolve_thumb_media_id(
token, rid, force_skip_explicit=True, account=acct
)
if thumb_alt:
payload["articles"][0]["thumb_media_id"] = thumb_alt
async with httpx.AsyncClient(timeout=25) as client:
@@ -167,33 +186,69 @@ class WechatPublisher:
)
return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data)
async def upload_cover(self, filename: str, content: bytes, request_id: str = "") -> PublishResponse:
async def upload_cover(
self, filename: str, content: bytes, request_id: str = "", account: dict | None = None
) -> PublishResponse:
"""上传封面到微信永久素材,返回 thumb_media_id。"""
rid = request_id or "-"
if not settings.wechat_appid or not settings.wechat_secret:
acct = self._resolve_account(account)
if not acct["appid"] or not acct["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()
token, _, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
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:
material = await self._upload_permanent_image(client, token, content, filename)
if not material:
return PublishResponse(
ok=False,
detail="封面上传失败:请检查图片格式/大小,或查看日志中的 wechat_material_add_failed",
)
mid = material["media_id"]
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_body_material(
self, filename: str, content: bytes, request_id: str = "", account: dict | None = None
) -> PublishResponse:
"""上传正文图片到微信永久素材库,返回 media_id 与可插入正文的 URL。"""
rid = request_id or "-"
acct = self._resolve_account(account)
if not acct["appid"] or not acct["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(acct["appid"], acct["secret"])
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:
material = await self._upload_permanent_image(client, token, content, filename)
if not material:
return PublishResponse(
ok=False,
detail="素材上传失败:请检查图片格式/大小,或查看日志中的 wechat_material_add_failed",
)
logger.info(
"wechat_body_material_upload_ok rid=%s filename=%s media_id=%s url=%s",
rid,
filename,
material.get("media_id"),
material.get("url"),
)
return PublishResponse(ok=True, detail="素材上传成功", data=material)
async def _upload_permanent_image(
self, client: httpx.AsyncClient, token: str, content: bytes, filename: str
) -> str | None:
) -> dict[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)}
@@ -206,11 +261,14 @@ class WechatPublisher:
if not mid:
logger.warning("wechat_material_add_no_media_id body=%s", data)
return None
return mid
return {"media_id": mid, "url": data.get("url") or ""}
async def _resolve_thumb_media_id(self, token: str, rid: str, *, force_skip_explicit: bool = False) -> str | None:
async def _resolve_thumb_media_id(
self, token: str, rid: str, *, force_skip_explicit: bool = False, account: dict | None = None
) -> str | None:
"""draft/add 要求 thumb_media_id 为永久图片素材;优先用配置,否则上传文件或内置图。"""
explicit = (settings.wechat_thumb_media_id or "").strip()
acct = self._resolve_account(account)
explicit = (acct.get("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
@@ -219,35 +277,40 @@ class WechatPublisher:
logger.info("wechat_thumb rid=%s source=runtime_cache", rid)
return self._runtime_thumb_media_id
path = (settings.wechat_thumb_image_path or "").strip()
path = (acct.get("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
material = await self._upload_permanent_image(client, token, content, p.name)
if material:
self._runtime_thumb_media_id = material["media_id"]
logger.info("wechat_thumb rid=%s source=path_upload ok=1", rid)
return mid
return material["media_id"]
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
material = await self._upload_permanent_image(client, token, content, fname)
if material:
self._runtime_thumb_media_id = material["media_id"]
logger.info("wechat_thumb rid=%s source=default_jpeg_upload ok=1", rid)
return mid
return material["media_id"]
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]:
async def _get_access_token(self, appid: str, secret: str) -> tuple[str | None, bool, dict | None]:
"""成功时第三项为 None失败时为微信返回的 JSON含 errcode/errmsg"""
now = int(time.time())
if self._access_token and now < self._expires_at - 60:
return self._access_token, True, None
key = appid.strip()
cached = self._token_cache.get(key)
if cached:
token = str(cached.get("token") or "")
exp = int(cached.get("expires_at") or 0)
if token and now < exp - 60:
return token, True, None
logger.info("wechat_http_get endpoint=cgi-bin/token reason=refresh_access_token")
async with httpx.AsyncClient(timeout=20) as client:
@@ -255,8 +318,8 @@ class WechatPublisher:
"https://api.weixin.qq.com/cgi-bin/token",
params={
"grant_type": "client_credential",
"appid": settings.wechat_appid,
"secret": settings.wechat_secret,
"appid": appid,
"secret": secret,
},
)
data = r.json() if r.content else {}
@@ -270,6 +333,5 @@ class WechatPublisher:
)
return None, False, data if isinstance(data, dict) else None
self._access_token = token
self._expires_at = now + int(data.get("expires_in", 7200))
self._token_cache[key] = {"token": token, "expires_at": now + int(data.get("expires_in", 7200))}
return token, False, None