Compare commits

..

11 Commits

Author SHA1 Message Date
Daniel
e69666dbb3 fix: 更新当前界面,支持多公帐号切换 2026-04-10 12:47:03 +08:00
Daniel
5b4bee1939 fix: 修复生成报错 2026-04-08 19:12:59 +08:00
Daniel
17591de58f fix:优化输出内容 2026-04-08 11:39:17 +08:00
Daniel
222bf2e70d fix: 优化排版内容 2026-04-08 10:48:42 +08:00
Daniel
869e3b5976 fix: 修复样式问题 2026-04-07 19:09:09 +08:00
Daniel
780871e93c 修复本地样式 2026-04-07 18:55:50 +08:00
Daniel
9070dfba35 fix: 强制更新容器样式 2026-04-06 18:17:42 +08:00
Daniel
005a25b77a fix: 优化界面 2026-04-06 18:07:32 +08:00
Daniel
b342a90f9d fix: 修复报错内容 2026-04-06 15:28:15 +08:00
Daniel
1d389767e6 fix: bug 2026-04-06 14:20:53 +08:00
Daniel
babf24a0b0 fix 2026-04-01 18:49:09 +08:00
27 changed files with 3537 additions and 132 deletions

View File

@@ -1,12 +1,43 @@
# —— 通义千问(推荐):阿里云 DashScope 的 OpenAI 兼容地址 + 模型名 + API Key
# OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# OPENAI_API_KEY=sk-你的DashScopeKey
# OPENAI_MODEL=qwen3.5-plus
OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_MODEL=gpt-4.1-mini
# 通义长文 JSON 常需 60~120s+,过短会 APITimeout 后走兜底
OPENAI_TIMEOUT=120
# SDK 自动重试次数。设为 0 可避免单次请求被隐式重试拖长(例如 30s 变 60s+
OPENAI_MAX_RETRIES=0
# 长文 JSON 建议 8192通义等若正文仍偏短可适当再加大
OPENAI_MAX_OUTPUT_TOKENS=8192
OPENAI_SOURCE_MAX_CHARS=5000
# 质检未通过时仍返回模型洗稿正文quality_notes 记录问题);设为 false 则严格退回保底稿
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=
# 若 webhook 需要签名可填
IM_SECRET=
# 账号数据 SQLite 文件(建议放在容器挂载目录,如 /app/data/app.db
AUTH_DB_PATH=./data/app.db
# 普通登录有效期(秒),默认 1 天
AUTH_SESSION_TTL_SEC=86400
# 勾选“限时免登”后的有效期(秒),默认 7 天
AUTH_REMEMBER_SESSION_TTL_SEC=604800
# 忘记密码重置码(建议自定义;为空时将使用默认值 x2ws-reset-2026
AUTH_PASSWORD_RESET_KEY=x2ws-reset-2026

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@
__pycache__/
*.pyc
.pytest_cache/
data/*
!data/.gitkeep

View File

@@ -1,11 +1,45 @@
FROM python:3.11-slim
# syntax=docker/dockerfile:1
# 国内拉基础镜像慢时docker compose build --build-arg PY_BASE=docker.m.daocloud.io/library/python:3.11-slim
ARG PY_BASE=python:3.11-slim
FROM ${PY_BASE}
WORKDIR /app
# 默认清华 PyPI海外可docker compose build --build-arg PIP_INDEX_URL=https://pypi.org/simple
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ENV PIP_INDEX_URL=${PIP_INDEX_URL} \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_DEFAULT_TIMEOUT=120
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# BuildKit 缓存加速重复构建;需 Docker 20.10+compose 默认开 BuildKit
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
# 可选:构建时自动拉取远端仓库最新代码覆盖工作目录(默认关闭)。
# 放在 COPY 之后,避免影响依赖层缓存;仅在你显式开启时才会触发网络更新。
# 用法示例:
# docker compose build \
# --build-arg GIT_AUTO_UPDATE=1 \
# --build-arg GIT_REMOTE_URL=https://github.com/you/repo.git \
# --build-arg GIT_REF=main
ARG GIT_AUTO_UPDATE=0
ARG GIT_REMOTE_URL=
ARG GIT_REF=main
RUN if [ "$GIT_AUTO_UPDATE" = "1" ] && [ -n "$GIT_REMOTE_URL" ]; then \
set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends git ca-certificates; \
rm -rf /var/lib/apt/lists/*; \
tmpdir="$(mktemp -d)"; \
git clone --depth=1 --branch "$GIT_REF" "$GIT_REMOTE_URL" "$tmpdir/repo"; \
cp -a "$tmpdir/repo/." /app/; \
rm -rf "$tmpdir"; \
else \
echo "Skip remote code update (GIT_AUTO_UPDATE=0 or GIT_REMOTE_URL empty)"; \
fi
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -9,13 +9,50 @@ class Settings(BaseSettings):
openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")
openai_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL")
openai_model: str = Field(default="gpt-4.1-mini", alias="OPENAI_MODEL")
openai_timeout: float = Field(
default=120.0,
alias="OPENAI_TIMEOUT",
description="HTTP 等待模型单轮响应的最长时间(秒)。通义长文 JSON 建议 120~180",
)
openai_max_retries: int = Field(
default=0,
alias="OPENAI_MAX_RETRIES",
description="OpenAI SDK 自动重试次数。为避免单次请求被重试拉长,默认 0。",
)
openai_max_output_tokens: int = Field(
default=8192,
alias="OPENAI_MAX_OUTPUT_TOKENS",
description="单次模型输出 token 上限;通义等长文 JSON 需足够大",
)
openai_source_max_chars: int = Field(default=5000, alias="OPENAI_SOURCE_MAX_CHARS")
ai_soft_accept: bool = Field(
default=True,
alias="AI_SOFT_ACCEPT",
description="质检未通过时仍输出模型洗稿正文mode=aiquality_notes 记录问题),仅模型完全失败时用保底稿",
)
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")
auth_db_path: str = Field(default="./data/app.db", alias="AUTH_DB_PATH")
auth_cookie_name: str = Field(default="x2ws_session", alias="AUTH_COOKIE_NAME")
auth_session_ttl_sec: int = Field(default=86400, alias="AUTH_SESSION_TTL_SEC")
auth_remember_session_ttl_sec: int = Field(default=604800, alias="AUTH_REMEMBER_SESSION_TTL_SEC")
auth_password_reset_key: str | None = Field(default="x2ws-reset-2026", alias="AUTH_PASSWORD_RESET_KEY")
settings = Settings()

25
app/logging_setup.py Normal file
View File

@@ -0,0 +1,25 @@
"""统一日志格式,便于 grep / 日志平台解析。"""
from __future__ import annotations
import logging
import os
def configure_logging() -> None:
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
fmt = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
datefmt = "%Y-%m-%d %H:%M:%S"
root = logging.getLogger()
if not root.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
root.addHandler(handler)
root.setLevel(level)
# 降噪:第三方库默认 WARNING
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("openai").setLevel(logging.WARNING)

View File

@@ -1,40 +1,402 @@
from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
import hmac
import logging
from urllib.parse import urlparse
from fastapi import FastAPI, File, Request, Response, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config import settings
from app.schemas import IMPublishRequest, RewriteRequest, WechatPublishRequest
from app.logging_setup import configure_logging
from app.middleware import RequestContextMiddleware
from app.schemas import (
AuthCredentialRequest,
ChangePasswordRequest,
ForgotPasswordResetRequest,
IMPublishRequest,
RewriteRequest,
WechatBindingRequest,
WechatPublishRequest,
WechatSwitchRequest,
)
from app.services.ai_rewriter import AIRewriter
from app.services.im import IMPublisher
from app.services.user_store import UserStore
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()
users = UserStore(settings.auth_db_path)
def _session_ttl(remember_me: bool) -> int:
normal = max(600, int(settings.auth_session_ttl_sec))
remembered = max(normal, int(settings.auth_remember_session_ttl_sec))
return remembered if remember_me else normal
def _current_user(request: Request) -> dict | None:
token = request.cookies.get(settings.auth_cookie_name, "")
return users.get_user_by_session(token) if token else None
def _require_user(request: Request) -> dict | None:
u = _current_user(request)
if not u:
return None
return u
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
if not _current_user(request):
return RedirectResponse(url="/auth?next=/", status_code=302)
return templates.TemplateResponse("index.html", {"request": request, "app_name": settings.app_name})
@app.get("/auth", response_class=HTMLResponse)
async def auth_page(request: Request):
nxt = (request.query_params.get("next") or "/").strip() or "/"
if _current_user(request):
return RedirectResponse(url=nxt, status_code=302)
return templates.TemplateResponse(
"auth.html",
{"request": request, "app_name": settings.app_name, "next": nxt},
)
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
if not _current_user(request):
return RedirectResponse(url="/auth?next=/settings", status_code=302)
return templates.TemplateResponse("settings.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.get("/api/auth/me")
async def auth_me(request: Request):
user = _current_user(request)
if not user:
return {"ok": True, "logged_in": False}
binding = users.get_active_wechat_binding(user["id"])
bindings = users.list_wechat_bindings(user["id"])
return {
"ok": True,
"logged_in": True,
"user": {"id": user["id"], "username": user["username"]},
"wechat_bound": bool(binding and binding.get("appid") and binding.get("secret")),
"active_wechat_account": binding,
"wechat_accounts": bindings,
}
@app.post("/api/auth/register")
async def auth_register(req: AuthCredentialRequest, response: Response):
username = (req.username or "").strip()
password = req.password or ""
if len(username) < 2:
return {"ok": False, "detail": "用户名至少 2 个字符"}
if len(password) < 6:
return {"ok": False, "detail": "密码至少 6 个字符"}
try:
user = users.create_user(username, password)
except Exception as exc:
logger.exception("auth_register_failed username=%s detail=%s", username, str(exc))
return {"ok": False, "detail": "注册失败:账号库异常,请稍后重试"}
if not user:
return {"ok": False, "detail": "用户名已存在"}
ttl = _session_ttl(bool(req.remember_me))
token = users.create_session(user["id"], ttl_seconds=ttl)
response.set_cookie(
key=settings.auth_cookie_name,
value=token,
httponly=True,
samesite="lax",
max_age=ttl,
path="/",
)
return {"ok": True, "detail": "注册并登录成功", "user": user}
@app.post("/api/auth/login")
async def auth_login(req: AuthCredentialRequest, response: Response):
try:
user = users.verify_user((req.username or "").strip(), req.password or "")
except Exception as exc:
logger.exception("auth_login_failed username=%s detail=%s", (req.username or "").strip(), str(exc))
return {"ok": False, "detail": "登录失败:账号库异常,请稍后重试"}
if not user:
return {"ok": False, "detail": "用户名或密码错误"}
ttl = _session_ttl(bool(req.remember_me))
token = users.create_session(user["id"], ttl_seconds=ttl)
response.set_cookie(
key=settings.auth_cookie_name,
value=token,
httponly=True,
samesite="lax",
max_age=ttl,
path="/",
)
return {"ok": True, "detail": "登录成功", "user": user}
@app.post("/api/auth/logout")
async def auth_logout(request: Request, response: Response):
token = request.cookies.get(settings.auth_cookie_name, "")
if token:
users.delete_session(token)
response.delete_cookie(settings.auth_cookie_name, path="/")
return {"ok": True, "detail": "已退出登录"}
@app.get("/auth/forgot", response_class=HTMLResponse)
async def forgot_password_page(request: Request):
return templates.TemplateResponse("forgot_password.html", {"request": request, "app_name": settings.app_name})
@app.post("/api/auth/password/forgot")
async def auth_forgot_password_reset(req: ForgotPasswordResetRequest):
reset_key = (req.reset_key or "").strip()
expected_key = (settings.auth_password_reset_key or "x2ws-reset-2026").strip()
username = (req.username or "").strip()
new_password = req.new_password or ""
if not expected_key:
return {"ok": False, "detail": "系统未启用忘记密码重置功能,请联系管理员"}
if len(username) < 2:
return {"ok": False, "detail": "请输入正确的用户名"}
if len(new_password) < 6:
return {"ok": False, "detail": "新密码至少 6 个字符"}
if not hmac.compare_digest(reset_key, expected_key):
return {"ok": False, "detail": "重置码错误"}
ok = users.reset_password_by_username(username, new_password)
if not ok:
return {"ok": False, "detail": "用户不存在,无法重置"}
return {"ok": True, "detail": "密码重置成功,请返回登录页重新登录"}
@app.post("/api/auth/password/change")
async def auth_change_password(req: ChangePasswordRequest, request: Request, response: Response):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
old_password = req.old_password or ""
new_password = req.new_password or ""
if len(old_password) < 1:
return {"ok": False, "detail": "请输入当前密码"}
if len(new_password) < 6:
return {"ok": False, "detail": "新密码至少 6 个字符"}
if old_password == new_password:
return {"ok": False, "detail": "新密码不能与当前密码相同"}
ok = users.change_password(user["id"], old_password, new_password)
if not ok:
return {"ok": False, "detail": "当前密码错误,修改失败"}
users.delete_sessions_by_user(user["id"])
ttl = _session_ttl(False)
token = users.create_session(user["id"], ttl_seconds=ttl)
response.set_cookie(
key=settings.auth_cookie_name,
value=token,
httponly=True,
samesite="lax",
max_age=ttl,
path="/",
)
return {"ok": True, "detail": "密码修改成功,已刷新登录状态"}
@app.post("/api/auth/wechat/bind")
async def auth_wechat_bind(req: WechatBindingRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
appid = (req.appid or "").strip()
secret = (req.secret or "").strip()
if not appid or not secret:
return {"ok": False, "detail": "appid/secret 不能为空"}
created = users.add_wechat_binding(
user_id=user["id"],
account_name=(req.account_name or "").strip() or "公众号账号",
appid=appid,
secret=secret,
author=(req.author or "").strip(),
thumb_media_id=(req.thumb_media_id or "").strip(),
thumb_image_path=(req.thumb_image_path or "").strip(),
)
return {"ok": True, "detail": "公众号账号绑定成功", "account": created}
@app.post("/api/auth/wechat/switch")
async def auth_wechat_switch(req: WechatSwitchRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
ok = users.switch_active_wechat_binding(user["id"], int(req.account_id))
if not ok:
return {"ok": False, "detail": "切换失败:账号不存在或无权限"}
return {"ok": True, "detail": "已切换当前公众号账号"}
@app.post("/api/rewrite")
async def rewrite(req: RewriteRequest):
return rewriter.rewrite(req)
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):
return await wechat.publish_draft(req)
async def publish_wechat(req: WechatPublishRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
binding = users.get_active_wechat_binding(user["id"])
if not binding:
return {"ok": False, "detail": "当前账号未绑定公众号 token请先在页面绑定"}
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, account=binding)
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(...)):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
binding = users.get_active_wechat_binding(user["id"])
if not binding:
return {"ok": False, "detail": "当前账号未绑定公众号 token请先在页面绑定"}
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, account=binding)
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/wechat/material/upload")
async def upload_wechat_material(request: Request, file: UploadFile = File(...)):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
binding = users.get_active_wechat_binding(user["id"])
if not binding:
return {"ok": False, "detail": "当前账号未绑定公众号 token请先在页面绑定"}
rid = getattr(request.state, "request_id", "")
fn = file.filename or "material.jpg"
content = await file.read()
logger.info("api_wechat_material_upload_in rid=%s filename=%s bytes=%d", rid, fn, len(content))
out = await wechat.upload_body_material(fn, content, request_id=rid, account=binding)
logger.info(
"api_wechat_material_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):
return await im.publish(req)
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

61
app/middleware.py Normal file
View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import logging
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger("app.http")
class RequestContextMiddleware(BaseHTTPMiddleware):
"""注入 request_id记录访问日志与耗时。"""
async def dispatch(self, request: Request, call_next) -> Response:
rid = request.headers.get("X-Request-ID") or str(uuid.uuid4())
request.state.request_id = rid
path = request.url.path
if path.startswith("/static"):
response = await call_next(request)
response.headers["X-Request-ID"] = rid
return response
client = request.client.host if request.client else "-"
if path.startswith("/api"):
logger.info(
"http_in method=%s path=%s rid=%s client=%s",
request.method,
path,
rid,
client,
)
started = time.perf_counter()
try:
response = await call_next(request)
except Exception:
duration_ms = (time.perf_counter() - started) * 1000
logger.exception(
"http_error method=%s path=%s duration_ms=%.1f rid=%s",
request.method,
path,
duration_ms,
rid,
)
raise
duration_ms = (time.perf_counter() - started) * 1000
response.headers["X-Request-ID"] = rid
logger.info(
"http_out method=%s path=%s status=%s duration_ms=%.1f rid=%s",
request.method,
path,
response.status_code,
duration_ms,
rid,
)
return response

View File

@@ -1,9 +1,12 @@
from typing import Any
from pydantic import BaseModel, Field
class RewriteRequest(BaseModel):
source_text: str = Field(..., min_length=20)
title_hint: str = ""
writing_style: str = "科普解读"
tone: str = "专业、可信、可读性强"
audience: str = "公众号读者"
keep_points: str = ""
@@ -14,6 +17,12 @@ class RewriteResponse(BaseModel):
title: str
summary: str
body_markdown: str
mode: str = "ai"
quality_notes: list[str] = []
trace: dict[str, Any] | None = Field(
default=None,
description="改写链路追踪:请求 ID、耗时、模型、质检与降级原因便于监测与回溯",
)
class WechatPublishRequest(BaseModel):
@@ -21,6 +30,7 @@ class WechatPublishRequest(BaseModel):
summary: str = ""
body_markdown: str
author: str = ""
thumb_media_id: str = ""
class IMPublishRequest(BaseModel):
@@ -32,3 +42,33 @@ class PublishResponse(BaseModel):
ok: bool
detail: str
data: dict | None = None
class AuthCredentialRequest(BaseModel):
username: str
password: str
remember_me: bool = False
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
class ForgotPasswordResetRequest(BaseModel):
username: str
reset_key: str
new_password: str
class WechatBindingRequest(BaseModel):
account_name: str = ""
appid: str
secret: str
author: str = ""
thumb_media_id: str = ""
thumb_image_path: str = ""
class WechatSwitchRequest(BaseModel):
account_id: int

View File

@@ -1,49 +1,706 @@
from __future__ import annotations
import difflib
import json
import logging
import re
import time
from typing import Any
from textwrap import shorten
from urllib.parse import urlparse
from openai import OpenAI
from app.config import settings
from app.schemas import RewriteRequest, RewriteResponse
logger = logging.getLogger(__name__)
def _api_host(url: str | None) -> str:
if not url:
return ""
try:
return urlparse(url).netloc or ""
except Exception:
return ""
def _is_likely_timeout_error(exc: BaseException) -> bool:
n = type(exc).__name__.lower()
if "timeout" in n:
return True
s = str(exc).lower()
return "timed out" in s or "timeout" in s
# 短文洗稿:正文目标约 500 字,优先完整性(软约束,不硬截断)
MIN_BODY_CHARS = 80
TARGET_BODY_CHARS = 500
def _preview_for_log(text: str, limit: int = 400) -> str:
t = (text or "").replace("\r\n", "\n").replace("\n", " ").strip()
if len(t) <= limit:
return t
return t[: limit - 1] + ""
SYSTEM_PROMPT = """
你是中文内容编辑与合规顾问。请把输入内容进行“原创改写”,要求:
1) 保留核心事实,但避免逐句复述;
2) 结构清晰导语、3-5个小节、结尾行动建议
3) 风格适合微信公众号表达自然避免AI腔
4) 如果原文存在未经核实结论,请使用“可能/有待验证”等措辞
5) 输出必须是 JSON字段title, summary, body_markdown。
你是资深中文科普类公众号编辑,擅长把长文、线程贴改写成**极短、好读**的推送。
目标:在**不偏离原意**的前提下,用最少字数讲清一件事;不要写成技术方案、长文大纲或带很多小标题的文章。
硬性规则:
1) **忠实原意**:只概括、转述原文已有信息,不编造事实,不偷换主题
2) 语气通俗、干脆,避免套话堆砌;
3) 只输出合法 JSONtitle, summary, body_markdown
4) **body_markdown 约束**:按内容密度使用 **4~6 个自然段**;段与段之间用一个空行分隔;**不要**使用 # / ## 标题符号;正文以 **约 500 字**为目标,优先完整表达并避免冗长重复;
5) title、summary 也要短:标题约 818 字;摘要约 4080 字;
6) 关键观点需要加粗:请用 Markdown `**加粗**` 标出 2~4 个重点短语;
7) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "
""".strip()
REWRITE_SCHEMA_HINT = """
请输出 JSON勿包在 ``` 里),例如:
{
"title": "短标题,点明主题",
"summary": "一句话到两句话摘要",
"body_markdown": "第一段内容…\\n\\n第二段…\\n\\n第三段…\\n\\n第四段…\\n\\n第五段…"
}
body_markdown 写法:
- 使用 **4~6 段**:每段若干完整句子,段之间 **\\n\\n**(空一行);
- **禁止** markdown 标题(不要用 #
- 正文目标约 **500 字**(可上下浮动),以信息完整为先,避免冗长和重复;
- 请用 `**...**` 加粗 2~4 个关键观点词;
- 内容顺序建议:首段交代在说什么;中间段展开关键信息;末段收束或提醒(均须紧扣原文,勿乱发挥)。
""".strip()
# 通义等模型若首次过短/结构不对,再要一次
_JSON_BODY_TOO_SHORT_RETRY = """
【系统复检】上一次 body_markdown 不符合要求。请重输出**完整** JSON
- 正文必须使用 **4~6 个自然段**(仅 \\n\\n 分段),无 # 标题;篇幅尽量收敛到约 500 字,同时保持信息完整;
- 忠实原稿、简短高效;
- 引号只用「」『』;
- 只输出 JSON。
""".strip()
class AIRewriter:
def __init__(self) -> None:
self._client = None
self._prefer_chat_first = False
if settings.openai_api_key:
base_url = settings.openai_base_url or ""
self._prefer_chat_first = "dashscope.aliyuncs.com" in base_url
self._client = OpenAI(
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
timeout=settings.openai_timeout,
max_retries=max(0, int(settings.openai_max_retries)),
)
logger.info(
"AIRewriter_init model=%s api_host=%s prefer_chat_first=%s timeout_s=%s max_retries=%s",
settings.openai_model,
_api_host(settings.openai_base_url) or "(default)",
self._prefer_chat_first,
settings.openai_timeout,
settings.openai_max_retries,
)
else:
logger.warning("AIRewriter_init openai_key_missing=1 rewrite_will_use_fallback_only=1")
def rewrite(self, req: RewriteRequest, request_id: str = "") -> RewriteResponse:
cleaned_source = self._clean_source(req.source_text)
started = time.monotonic()
trace: dict[str, Any] = {
"request_id": request_id or None,
"model": settings.openai_model,
"provider": "dashscope" if self._prefer_chat_first else "openai_compatible",
"source_chars_in": len(req.source_text or ""),
"cleaned_chars": len(cleaned_source),
"openai_timeout_env_sec": settings.openai_timeout,
"steps": [],
}
def _step(name: str, **extra: Any) -> None:
elapsed_ms = round((time.monotonic() - started) * 1000, 1)
trace["steps"].append({"name": name, "elapsed_ms": elapsed_ms, **extra})
extra_fmt = ""
if extra:
parts: list[str] = []
for k, v in extra.items():
s = repr(v)
if len(s) > 200:
s = s[:197] + "..."
parts.append(f"{k}={s}")
extra_fmt = " " + " ".join(parts)
logger.info(
"rewrite_step rid=%s step=%s elapsed_ms=%s%s",
request_id or "-",
name,
elapsed_ms,
extra_fmt,
)
def rewrite(self, req: RewriteRequest) -> RewriteResponse:
if not self._client:
return self._fallback_rewrite(req)
raw_in = (req.source_text or "").replace("\r\n", "\n").strip()
_step("clean_source", truncated=len(cleaned_source) < len(raw_in))
logger.info(
"rewrite_enter rid=%s model=%s client_ok=%s prefer_chat_first=%s "
"source_chars=%d cleaned_chars=%d ai_soft_accept=%s",
request_id or "-",
settings.openai_model,
bool(self._client),
self._prefer_chat_first,
trace["source_chars_in"],
len(cleaned_source),
settings.ai_soft_accept,
)
# Primary: model rewrite + quality gate + optional second-pass polish.
if self._client:
# 通义长文 JSON 常需 40~90s+。旧代码错误地将首轮 cap 在 30s → APITimeoutError → 仅走兜底。
if self._prefer_chat_first:
first_pass_timeout = max(45.0, min(300.0, float(settings.openai_timeout)))
else:
first_pass_timeout = max(20.0, min(120.0, float(settings.openai_timeout)))
trace["first_pass_http_timeout_sec"] = round(first_pass_timeout, 1)
logger.info(
"rewrite_model_first_pass rid=%s first_pass_http_timeout_s=%.1f openai_timeout_env_s=%.1f "
"lenient_qa=%s note=dashscope_uses_full_openai_timeout_not_capped_30",
request_id or "-",
first_pass_timeout,
settings.openai_timeout,
self._prefer_chat_first,
)
t0 = time.monotonic()
draft = self._model_rewrite(req, cleaned_source, timeout_sec=first_pass_timeout, request_id=request_id)
_step(
"model_first_pass",
duration_ms=round((time.monotonic() - t0) * 1000, 1),
ok=bool(draft),
timeout_sec=first_pass_timeout,
)
if not draft:
trace["quality_issues_final"] = ["模型未返回有效 JSON 或请求超时"]
trace["model_unavailable_hint"] = (
"排查:① 日志是否 APITimeoutError → 提高 OPENAI_TIMEOUT通义建议 120~180并确认 "
"first_pass_http_timeout_sec 与 trace.openai_timeout_env_sec 一致;② 网络到 "
"dashscope.aliyuncs.com③ 见 model_call_fail 的 is_likely_timeout。"
)
_step("model_first_pass_failed", detail="timeout_or_invalid_json")
if draft:
normalized = self._normalize_result(draft)
issues = self._quality_issues(
req, cleaned_source, normalized, lenient=self._prefer_chat_first
)
trace["quality_issues_first"] = issues
logger.info(
"rewrite quality check rid=%s first_issues=%s body_chars=%d",
request_id,
issues,
len(normalized.get("body_markdown", "") or ""),
)
elapsed = time.monotonic() - started
remaining_budget = max(0.0, (first_pass_timeout + 25.0) - elapsed)
polish_budget = min(22.0, remaining_budget) if self._prefer_chat_first else min(30.0, remaining_budget)
if issues and not (
remaining_budget >= 8.0 and polish_budget >= 6.0
):
logger.info(
"rewrite_polish_skipped rid=%s first_issues=%d remaining_budget_s=%.1f polish_budget_s=%.1f",
request_id or "-",
len(issues),
remaining_budget,
polish_budget,
)
if issues and remaining_budget >= 8.0 and polish_budget >= 6.0:
t1 = time.monotonic()
polished = self._model_polish(
req,
cleaned_source,
normalized,
issues,
timeout_sec=polish_budget,
request_id=request_id,
)
_step(
"model_polish",
duration_ms=round((time.monotonic() - t1) * 1000, 1),
ok=bool(polished),
)
if polished:
normalized = self._normalize_result(polished)
final_issues = self._quality_issues(
req, cleaned_source, normalized, lenient=self._prefer_chat_first
)
trace["quality_issues_final"] = final_issues
if not final_issues:
trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1)
trace["mode"] = "ai"
logger.info(
"rewrite success rid=%s duration_ms=%.1f mode=ai",
request_id,
trace["duration_ms"],
)
return RewriteResponse(**normalized, mode="ai", quality_notes=[], trace=trace)
# 模型已返回有效 JSON默认「软接受」——仍视为 AI 洗稿,质检问题写入 quality_notes避免误用模板稿
if settings.ai_soft_accept and self._model_output_usable(normalized):
trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1)
trace["mode"] = "ai"
trace["quality_soft_accept"] = True
trace["quality_warnings"] = final_issues
logger.warning(
"rewrite soft-accept rid=%s warnings=%s body_chars=%d",
request_id,
final_issues,
len(normalized.get("body_markdown", "") or ""),
)
return RewriteResponse(
**normalized,
mode="ai",
quality_notes=final_issues,
trace=trace,
)
logger.warning(
"rewrite quality gate fallback rid=%s issues=%s",
request_id,
final_issues,
)
_step("quality_gate_failed", issues=final_issues)
else:
_step("skip_model", reason="OPENAI_API_KEY 未配置")
trace["quality_issues_final"] = ["未配置 OPENAI_API_KEY使用本地保底稿"]
# Secondary: deterministic fallback with publishable structure.
reason = "模型未返回有效 JSON、超时或质量未达标已使用结构化保底稿"
trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1)
logger.info(
"rewrite fallback rid=%s duration_ms=%.1f last_issues=%s",
request_id,
trace["duration_ms"],
trace.get("quality_issues_final"),
)
return self._fallback_rewrite(req, cleaned_source, reason=reason, trace=trace)
def _model_rewrite(
self, req: RewriteRequest, cleaned_source: str, timeout_sec: float, request_id: str = ""
) -> dict | None:
user_prompt = self._build_user_prompt(req, cleaned_source)
return self._call_model_json(user_prompt, timeout_sec=timeout_sec, request_id=request_id)
def _model_polish(
self,
req: RewriteRequest,
cleaned_source: str,
normalized: dict,
issues: list[str],
timeout_sec: float,
request_id: str = "",
) -> dict | None:
issue_text = "\n".join([f"- {i}" for i in issues])
user_prompt = f"""
原始内容:
{req.source_text}
你上一次的改写稿未通过质检,请针对下列问题重写;体裁仍为**科普介绍类公众号****忠实原稿**,不要写成技术方案或内部汇报。
{issue_text}
改写约束
原始内容
{cleaned_source}
上一次草稿:
标题:{normalized.get('title', '')}
摘要:{normalized.get('summary', '')}
正文:
{normalized.get('body_markdown', '')}
用户改写偏好:
- 标题参考:{req.title_hint or '自动生成'}
- 目标语气{req.tone}
- 写作风格{req.writing_style}
- 语气风格:{req.tone}
- 目标读者:{req.audience}
- 必须保留观点:{req.keep_points or ''}
- 避免词汇:{req.avoid_words or ''}
请输出一版全新稿件。{REWRITE_SCHEMA_HINT}
""".strip()
return self._call_model_json(user_prompt, timeout_sec=timeout_sec, request_id=request_id)
def _build_user_prompt(self, req: RewriteRequest, cleaned_source: str) -> str:
return f"""
原始内容(已清洗):
{cleaned_source}
用户改写偏好:
- 标题参考:{req.title_hint or '自动生成'}
- 写作风格:{req.writing_style}
- 语气风格:{req.tone}
- 目标读者:{req.audience}
- 必须保留观点:{req.keep_points or ''}
- 避免词汇:{req.avoid_words or ''}
任务:在**不偏离原帖主题与事实**的前提下,改写成科普介绍风格的公众号正文(好读、讲清楚,而非技术实施方案)。{REWRITE_SCHEMA_HINT}
""".strip()
def _fallback_rewrite(
self, req: RewriteRequest, cleaned_source: str, reason: str, trace: dict[str, Any] | None = None
) -> RewriteResponse:
sentences = self._extract_sentences(cleaned_source)
para_count = self._fallback_para_count(cleaned_source)
points = self._pick_key_points(sentences, limit=max(5, para_count))
title = req.title_hint.strip() or self._build_fallback_title(sentences)
summary = self._build_fallback_summary(points, cleaned_source)
analysis = self._build_analysis(points)
conclusion = "细节仍以原帖为准;若话题在更新,请对照出处核对。"
def _one_line(s: str, n: int) -> str:
t = re.sub(r"\s+", " ", (s or "").strip())
return t if len(t) <= n else t[: n - 1] + ""
paras = [_one_line(self._build_intro(points, cleaned_source), 105)]
if para_count >= 4:
paras.append(_one_line(analysis["cause"], 105))
paras.append(_one_line(analysis["impact"], 105))
if para_count >= 5:
paras.append(_one_line(analysis["risk"], 105))
paras.append(_one_line(conclusion, 105))
body = "\n\n".join(paras)
normalized = {
"title": title,
"summary": summary,
"body_markdown": self._format_markdown(body),
}
if trace is not None:
trace["mode"] = "fallback"
trace["fallback_reason"] = reason
rid = (trace or {}).get("request_id") or "-"
logger.info(
"rewrite_fallback_compose rid=%s reason=%s title_chars=%d summary_chars=%d body_chars=%d points=%d",
rid,
reason[:120],
len(normalized["title"]),
len(normalized["summary"]),
len(normalized["body_markdown"]),
len(points),
)
return RewriteResponse(**normalized, mode="fallback", quality_notes=[reason], trace=trace)
def _build_fallback_title(self, sentences: list[str]) -> str:
seed = sentences[0] if sentences else "内容导读"
seed = shorten(seed, width=16, placeholder="")
return f"{seed}:一文读懂在说什么"
def _build_fallback_summary(self, points: list[str], source: str) -> str:
if len(points) >= 2:
return shorten(
f"原帖在谈:{points[0]};另一点:{points[1]}",
width=85,
placeholder="",
)
return shorten(re.sub(r"\s+", " ", source), width=85, placeholder="")
def _build_intro(self, points: list[str], source: str) -> str:
focus = points[0] if points else shorten(source, width=42, placeholder="...")
return (
f"原帖主要在谈:{focus}。下面用更适合公众号阅读的方式,把脉络和重点捋清楚,方便你快速抓住作者在表达什么。\n\n"
"说明:这是基于原文的导读式整理,若需引用细节,请以原帖为准。"
)
def _build_analysis(self, points: list[str]) -> dict[str, str]:
p1 = points[0] if points else "原文讨论的核心现象"
p2 = points[1] if len(points) > 1 else "与读者日常能感知到的关联"
p3 = points[2] if len(points) > 2 else "原文可能提到的限制或尚未定论之处"
return {
"cause": (
f"先把事情放在原文的语境里理解:{p1}"
"这里侧重讲清楚「作者在说什么」,而不是替原文下结论。"
),
"impact": (
f"对大多数读者来说,更关心的是:这和自己有什么关系。{p2}"
"若原帖偏专业,这里尽量用通俗说法转述,避免写成给决策层的公文。"
),
"risk": (
f"任何公开讨论都有边界:{p3}"
"若话题仍在变化,结论可能更新,阅读时建议保留一点审慎,必要时回看原始出处。"
),
}
def _fallback_para_count(self, source: str) -> int:
length = len((source or "").strip())
if length < 240:
return 4
if length > 1200:
return 6
return 5
def _clean_source(self, text: str) -> str:
src = (text or "").replace("\r\n", "\n").strip()
src = re.sub(r"https?://\S+", "", src)
src = re.sub(r"(?m)^\s*>+\s*", "", src)
src = re.sub(r"(?m)^\s*[@#][^\s]+\s*$", "", src)
src = re.sub(r"\n{3,}", "\n\n", src)
src = re.sub(r"\s+", " ", src)
src = src.strip()
max_chars = max(1200, settings.openai_source_max_chars)
if len(src) > max_chars:
src = src[:max_chars] + " ...(原文过长,已截断后改写)"
return src
def _extract_sentences(self, text: str) -> list[str]:
parts = re.split(r"[。!?;;.!?\n]+", text)
cleaned = [p.strip(" ,;::。") for p in parts if p.strip()]
return cleaned
def _pick_key_points(self, sentences: list[str], limit: int) -> list[str]:
points: list[str] = []
templates = [
"值得关注:{}",
"背景要点:{}",
"原文强调:{}",
"延伸信息:{}",
"阅读提示:{}",
]
for s in sentences:
if len(s) < 12:
continue
if len(points) >= limit:
break
normalized = re.sub(r"^(第一|第二|第三|第四|第五)[,:]?", "", s).strip()
normalized = re.sub(r"^[-•\\d\\.\\)\\s]+", "", normalized)
text = shorten(normalized, width=50, placeholder="...")
points.append(templates[len(points) % len(templates)].format(text))
if not points:
points = ["原始内容信息密度较高,建议先聚焦一个核心问题再展开"]
return points
def _parse_response_json(self, text: str) -> dict:
raw = (text or "").strip()
if not raw:
raise ValueError("empty model output")
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
try:
return json.loads(self._escape_control_chars_in_json_string(raw))
except json.JSONDecodeError:
pass
fenced = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE).strip()
if fenced != raw:
try:
return json.loads(fenced)
except json.JSONDecodeError:
pass
try:
return json.loads(self._escape_control_chars_in_json_string(fenced))
except json.JSONDecodeError:
pass
start = raw.find("{")
end = raw.rfind("}")
if start != -1 and end != -1 and end > start:
sliced = raw[start : end + 1]
try:
return json.loads(sliced)
except json.JSONDecodeError:
return json.loads(self._escape_control_chars_in_json_string(sliced))
raise ValueError("model output is not valid JSON")
def _escape_control_chars_in_json_string(self, s: str) -> str:
"""
修复“近似 JSON”中字符串里的裸控制字符尤其是换行
避免 `Invalid control character` 导致误判为无效 JSON。
"""
out: list[str] = []
in_string = False
escaped = False
for ch in s:
if in_string:
if escaped:
out.append(ch)
escaped = False
continue
if ch == "\\":
out.append(ch)
escaped = True
continue
if ch == '"':
out.append(ch)
in_string = False
continue
if ch == "\n":
out.append("\\n")
continue
if ch == "\r":
out.append("\\r")
continue
if ch == "\t":
out.append("\\t")
continue
if ord(ch) < 0x20:
out.append(f"\\u{ord(ch):04x}")
continue
out.append(ch)
continue
else:
out.append(ch)
if ch == '"':
in_string = True
escaped = False
return "".join(out)
def _chat_completions_json(self, user_prompt: str, timeout_sec: float, request_id: str) -> dict | None:
"""chat.completions通义兼容层在 json_object 下易产出极短 JSON故 DashScope 不传 response_format并支持短文自动重试。"""
max_attempts = 2 if self._prefer_chat_first else 1
deadline = time.monotonic() + max(0.0, timeout_sec)
pe = user_prompt
for attempt in range(max_attempts):
if attempt == 1:
pe = user_prompt + _JSON_BODY_TOO_SHORT_RETRY
remaining = deadline - time.monotonic()
if remaining <= 0:
logger.warning(
"model_call_budget_exhausted rid=%s api=chat.completions attempt=%d/%d",
request_id or "-",
attempt + 1,
max_attempts,
)
return None
try:
logger.info(
"model_call_try rid=%s api=chat.completions.create attempt=%d/%d max_tokens=%d json_object=%s timeout_s=%.1f",
request_id or "-",
attempt + 1,
max_attempts,
settings.openai_max_output_tokens,
not self._prefer_chat_first,
remaining,
)
t0 = time.monotonic()
create_kwargs: dict[str, Any] = {
"model": settings.openai_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": pe},
],
"max_tokens": settings.openai_max_output_tokens,
"temperature": 0.4,
"extra_body": {"enable_thinking": False},
"timeout": remaining,
}
# OpenAI 官方 API 在 json_object 下表现稳定;通义兼容模式若开启则常出现正文被压成一两百字。
if not self._prefer_chat_first:
create_kwargs["response_format"] = {"type": "json_object"}
completion = self._client.chat.completions.create(**create_kwargs)
except Exception as exc:
is_to = _is_likely_timeout_error(exc)
logger.warning(
"model_call_fail rid=%s api=chat.completions attempt=%d/%d exc_type=%s exc=%s "
"is_likely_timeout=%s http_timeout_budget_s=%.1f openai_timeout_env_s=%.1f max_tokens=%d "
"hint=%s",
request_id or "-",
attempt + 1,
max_attempts,
type(exc).__name__,
exc,
is_to,
remaining,
settings.openai_timeout,
settings.openai_max_output_tokens,
(
"典型原因:单轮 HTTP 等待短于模型生成长文 JSON 所需时间;已取消错误的 30s 上限,"
"请确认 OPENAI_TIMEOUT>=120 并重启进程。"
)
if is_to and self._prefer_chat_first
else (
"若为超时:增大 OPENAI_TIMEOUT否则检查 Key/模型名/网络。"
if is_to
else ""
),
)
if self._prefer_chat_first:
return None
raise
choice = completion.choices[0] if completion.choices else None
msg = (choice.message.content if choice else "") or ""
fr = getattr(choice, "finish_reason", None) if choice else None
usage = getattr(completion, "usage", None)
udump = (
usage.model_dump()
if usage is not None and hasattr(usage, "model_dump")
else usage
)
ms = (time.monotonic() - t0) * 1000
logger.info(
"model_call_ok rid=%s api=chat.completions attempt=%d duration_ms=%.1f output_chars=%d "
"finish_reason=%s usage=%s preview=%s",
request_id or "-",
attempt + 1,
ms,
len(msg),
fr,
udump,
_preview_for_log(msg, 380),
)
logger.debug(
"model_call_raw rid=%s api=chat.completions attempt=%d body=%s",
request_id or "-",
attempt + 1,
msg,
)
try:
parsed = self._parse_response_json(msg)
except Exception as exc:
logger.warning(
"model_json_parse_fail rid=%s attempt=%d err=%s",
request_id or "-",
attempt + 1,
exc,
)
if not self._prefer_chat_first:
raise
if attempt == max_attempts - 1:
return None
continue
raw_body = str(parsed.get("body_markdown", "")).strip()
bl = len(raw_body)
pc = len([p for p in re.split(r"\n\s*\n", raw_body) if p.strip()])
if self._prefer_chat_first and attempt == 0 and (bl < 40 or pc < 3):
logger.warning(
"model_body_retry rid=%s body_chars=%d paragraphs=%d reason=too_thin_or_not_segmented",
request_id or "-",
bl,
pc,
)
continue
return parsed
return None
def _call_model_json(self, user_prompt: str, timeout_sec: float, request_id: str = "") -> dict | None:
methods = ["chat", "responses"] if self._prefer_chat_first else ["responses", "chat"]
logger.info(
"model_call_begin rid=%s model=%s timeout_s=%.1f prefer_chat_first=%s prompt_chars=%d "
"try_order=%s",
request_id or "-",
settings.openai_model,
timeout_sec,
self._prefer_chat_first,
len(user_prompt),
methods,
)
for method in methods:
t0 = time.monotonic()
if method == "responses":
try:
logger.info("model_call_try rid=%s api=OpenAI.responses.create", request_id or "-")
completion = self._client.responses.create(
model=settings.openai_model,
input=[
@@ -51,30 +708,187 @@ class AIRewriter:
{"role": "user", "content": user_prompt},
],
text={"format": {"type": "json_object"}},
timeout=timeout_sec,
)
output_text = completion.output_text or ""
ms = (time.monotonic() - t0) * 1000
logger.info(
"model_call_ok rid=%s api=responses duration_ms=%.1f output_chars=%d preview=%s",
request_id or "-",
ms,
len(output_text),
_preview_for_log(output_text, 380),
)
logger.debug("model_call_raw rid=%s api=responses body=%s", request_id or "-", output_text)
return self._parse_response_json(output_text)
except Exception as exc:
logger.warning(
"model_call_fail rid=%s api=responses duration_ms=%.1f exc_type=%s exc=%s",
request_id or "-",
(time.monotonic() - t0) * 1000,
type(exc).__name__,
exc,
)
continue
if method == "chat":
try:
t_chat = time.monotonic()
out = self._chat_completions_json(user_prompt, timeout_sec, request_id)
if out is not None:
return out
if self._prefer_chat_first:
logger.info(
"model_call_stop rid=%s reason=dashscope_chat_no_valid_json duration_ms=%.1f",
request_id or "-",
(time.monotonic() - t_chat) * 1000,
)
return None
except Exception as exc:
logger.warning(
"model_call_fail rid=%s api=chat.completions duration_ms=%.1f exc_type=%s exc=%s",
request_id or "-",
(time.monotonic() - t0) * 1000,
type(exc).__name__,
exc,
)
if self._prefer_chat_first:
logger.info(
"model_call_stop rid=%s reason=dashscope_chat_exception",
request_id or "-",
)
return None
continue
logger.error(
"model_call_exhausted rid=%s methods_tried=%s result=none",
request_id or "-",
methods,
)
return None
def _normalize_result(self, data: dict) -> dict:
title = str(data.get("title", "")).strip()
summary = str(data.get("summary", "")).strip()
body = str(data.get("body_markdown", "")).strip()
if not title:
title = "公众号改写稿"
if not summary:
summary = shorten(re.sub(r"\s+", " ", body), width=90, placeholder="...")
body = re.sub(r"(?m)^#{1,6}\s+[^\n]*\n?", "", body).strip()
body = self._normalize_body_length(body)
body = self._format_markdown(body)
return {"title": title, "summary": summary, "body_markdown": body}
def _normalize_body_length(self, body: str) -> str:
text = (body or "").strip()
if not text:
text = "(正文生成失败,请重试。)"
return text
def _quality_issues(
self, req: RewriteRequest, source: str, normalized: dict, lenient: bool = False
) -> list[str]:
issues: list[str] = []
title = normalized.get("title", "")
summary = normalized.get("summary", "")
body = normalized.get("body_markdown", "")
min_title, max_title = (4, 30) if lenient else (6, 24)
if len(title) < min_title or len(title) > max_title:
issues.append(f"标题长度不理想(建议 {min_title}-{max_title} 字,短标题即可)")
min_summary, max_summary = (20, 100) if lenient else (25, 90)
if len(summary) < min_summary:
issues.append("摘要过短")
elif len(summary) > max_summary:
issues.append(f"摘要过长(建议 ≤{max_summary} 字)")
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()]
pc = len(paragraphs)
min_p, max_p = (3, 6) if lenient else (4, 6)
if pc < min_p:
issues.append(f"正文段落偏少(当前 {pc} 段),建议 {min_p}-{max_p}")
elif pc > max_p:
issues.append(f"正文段落偏多(当前 {pc} 段),建议控制在 {min_p}-{max_p}")
if len(body) < MIN_BODY_CHARS:
issues.append(f"正文过短(当前阈值 ≥{MIN_BODY_CHARS} 字)")
elif len(body) > 900:
issues.append(
f"正文偏长(当前 {len(body)} 字),建议收敛到约 {TARGET_BODY_CHARS} 字(可上下浮动)"
)
text = completion.output_text
import json
if re.search(r"(?m)^#+\s", body):
issues.append("正文请勿使用 # 标题符号,只用自然段")
data = json.loads(text)
return RewriteResponse(**data)
if "**" not in body:
issues.append("关键观点未加粗(建议 2~4 处)")
def _fallback_rewrite(self, req: RewriteRequest) -> RewriteResponse:
clean_text = re.sub(r"\n{2,}", "\n", req.source_text.strip())
lines = [line.strip() for line in clean_text.split("\n") if line.strip()]
head = lines[0] if lines else clean_text[:50]
title = req.title_hint.strip() or f"{shorten(head, width=26, placeholder='')}:可执行解读"
summary = shorten(clean_text, width=90, placeholder="...")
body = (
f"## 导语\n"
f"这篇内容值得关注的核心在于:{summary}\n\n"
f"## 重点拆解\n"
f"1. 背景与问题:从原文可以看到关键矛盾已出现。\n"
f"2. 方法与动作:建议按“目标-路径-验证”三步推进。\n"
f"3. 风险与边界:避免绝对化表述,必要时补充数据来源。\n\n"
f"## 公众号改写正文\n"
f"{clean_text}\n\n"
f"## 结尾\n"
f"以上为原创重组版本,可继续补充案例与数据后发布。"
)
return RewriteResponse(title=title, summary=summary, body_markdown=body)
if self._looks_like_raw_copy(source, body, lenient=lenient):
issues.append("改写与原文相似度过高,疑似未充分重写")
if req.avoid_words:
bad_words = [w.strip() for w in re.split(r"[,]\s*", req.avoid_words) if w.strip()]
hit = [w for w in bad_words if w in body or w in summary or w in title]
if hit:
issues.append(f"命中禁用词: {', '.join(hit)}")
ai_phrases = ["首先", "其次", "最后", "总而言之", "赋能", "闭环", "颠覆"]
hit_ai = [w for w in ai_phrases if body.count(w) >= 3]
if hit_ai:
issues.append("存在明显 AI 套话堆叠")
return issues
def _looks_like_raw_copy(self, source: str, rewritten: str, lenient: bool = False) -> bool:
src = re.sub(r"\s+", "", source or "")
dst = re.sub(r"\s+", "", rewritten or "")
if not src or not dst:
return True
if dst in src or src in dst:
return True
ratio = difflib.SequenceMatcher(a=src[:3500], b=dst[:3500]).ratio()
threshold = 0.88 if lenient else 0.80
return ratio >= threshold
def _model_output_usable(self, normalized: dict) -> bool:
"""模型 JSON 可解析且正文有实质内容时,允许软接受(不走模板保底)。"""
body = (normalized.get("body_markdown") or "").strip()
title = (normalized.get("title") or "").strip()
if len(title) < 4 or len(body) < 40:
return False
return True
def _format_markdown(self, text: str) -> str:
body = text.replace("\r\n", "\n").strip()
body = re.sub(r"\n{3,}", "\n\n", body)
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()]
if not paragraphs:
return body.strip() + "\n"
# 若模型未加粗,兜底给第一段的核心短语加粗一次
merged = "\n\n".join(paragraphs)
if "**" not in merged:
first = paragraphs[0]
first_plain = first.lstrip("  ").strip()
phrase = re.split(r"[,。;:,:]", first_plain, maxsplit=1)[0].strip()
phrase = phrase[:14]
if len(phrase) >= 4 and phrase in first:
paragraphs[0] = first.replace(phrase, f"**{phrase}**", 1)
# 段首全角缩进:保持阅读习惯,避免顶格
out: list[str] = []
for p in paragraphs:
seg = p.strip()
if not seg:
continue
if seg.startswith("  "):
out.append(seg)
else:
out.append("  " + seg.lstrip())
# 不能用 .strip(),否则会把首段的全角缩进「  」去掉
return "\n\n".join(out).rstrip() + "\n"

View File

@@ -3,20 +3,36 @@ from __future__ import annotations
import hashlib
import hmac
import base64
import logging
import time
from urllib.parse import quote_plus
import httpx
from urllib.parse import urlparse
from app.config import settings
from app.schemas import IMPublishRequest, PublishResponse
logger = logging.getLogger(__name__)
class IMPublisher:
async def publish(self, req: IMPublishRequest) -> PublishResponse:
async def publish(self, req: IMPublishRequest, request_id: str = "") -> PublishResponse:
rid = request_id or "-"
if not settings.im_webhook_url:
logger.warning("im_skipped rid=%s reason=empty_webhook_url", rid)
return PublishResponse(ok=False, detail="缺少 IM_WEBHOOK_URL 配置")
parsed = urlparse(settings.im_webhook_url)
host = parsed.netloc or "(invalid_url)"
logger.info(
"im_publish_start rid=%s webhook_host=%s sign_enabled=%s title_chars=%d body_truncated_to=3800",
rid,
host,
bool(settings.im_secret),
len(req.title or ""),
)
webhook = self._with_signature(settings.im_webhook_url, settings.im_secret)
payload = {
"msg_type": "post",
@@ -31,15 +47,25 @@ class IMPublisher:
}
async with httpx.AsyncClient(timeout=20) as client:
logger.info("im_http_post rid=%s method=POST timeout_s=20", rid)
r = await client.post(webhook, json=payload)
try:
data = r.json()
except Exception:
data = {"status_code": r.status_code, "text": r.text}
logger.info(
"im_http_response rid=%s status=%s body_preview=%s",
rid,
r.status_code,
str(data)[:500],
)
if r.status_code >= 400:
logger.warning("im_push_failed rid=%s http_status=%s", rid, r.status_code)
return PublishResponse(ok=False, detail=f"IM 推送失败: {data}", data=data)
logger.info("im_push_ok rid=%s", rid)
return PublishResponse(ok=True, detail="IM 推送成功", data=data)
def _with_signature(self, webhook: str, secret: str | None) -> str:

512
app/services/user_store.py Normal file
View File

@@ -0,0 +1,512 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
import sqlite3
import time
from pathlib import Path
class UserStore:
def __init__(self, db_path: str) -> None:
self._db_path = db_path
p = Path(db_path)
if p.parent and not p.parent.exists():
p.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _conn(self) -> sqlite3.Connection:
c = sqlite3.connect(self._db_path)
c.row_factory = sqlite3.Row
return c
def _init_db(self) -> None:
with self._conn() as c:
self._ensure_users_table(c)
self._ensure_sessions_table(c)
c.execute(
"""
CREATE TABLE IF NOT EXISTS wechat_bindings (
user_id INTEGER PRIMARY KEY,
appid TEXT NOT NULL,
secret TEXT NOT NULL,
author TEXT NOT NULL DEFAULT '',
thumb_media_id TEXT NOT NULL DEFAULT '',
thumb_image_path TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
)
"""
)
c.execute(
"""
CREATE TABLE IF NOT EXISTS wechat_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
account_name TEXT NOT NULL,
appid TEXT NOT NULL,
secret TEXT NOT NULL,
author TEXT NOT NULL DEFAULT '',
thumb_media_id TEXT NOT NULL DEFAULT '',
thumb_image_path TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL,
UNIQUE(user_id, account_name),
FOREIGN KEY(user_id) REFERENCES users(id)
)
"""
)
c.execute(
"""
CREATE TABLE IF NOT EXISTS user_prefs (
user_id INTEGER PRIMARY KEY,
active_wechat_account_id INTEGER,
updated_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
)
"""
)
# 兼容历史单绑定结构,自动迁移为默认账号
rows = c.execute(
"SELECT user_id, appid, secret, author, thumb_media_id, thumb_image_path, updated_at FROM wechat_bindings"
).fetchall()
for r in rows:
now = int(time.time())
cur = c.execute(
"""
INSERT OR IGNORE INTO wechat_accounts
(user_id, account_name, appid, secret, author, thumb_media_id, thumb_image_path, updated_at)
VALUES (?, '默认公众号', ?, ?, ?, ?, ?, ?)
""",
(
int(r["user_id"]),
r["appid"] or "",
r["secret"] or "",
r["author"] or "",
r["thumb_media_id"] or "",
r["thumb_image_path"] or "",
int(r["updated_at"] or now),
),
)
if cur.rowcount:
aid = int(
c.execute(
"SELECT id FROM wechat_accounts WHERE user_id=? AND account_name='默认公众号'",
(int(r["user_id"]),),
).fetchone()["id"]
)
c.execute(
"""
INSERT INTO user_prefs(user_id, active_wechat_account_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
active_wechat_account_id=COALESCE(user_prefs.active_wechat_account_id, excluded.active_wechat_account_id),
updated_at=excluded.updated_at
""",
(int(r["user_id"]), aid, now),
)
def _table_columns(self, c: sqlite3.Connection, table_name: str) -> set[str]:
rows = c.execute(f"PRAGMA table_info({table_name})").fetchall()
return {str(r["name"]) for r in rows}
def _ensure_users_table(self, c: sqlite3.Connection) -> None:
required = {"id", "username", "password_hash", "password_salt", "created_at"}
c.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
created_at INTEGER NOT NULL
)
"""
)
cols = self._table_columns(c, "users")
if required.issubset(cols):
return
now = int(time.time())
c.execute("PRAGMA foreign_keys=OFF")
c.execute("DROP TABLE IF EXISTS users_new")
c.execute(
"""
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
created_at INTEGER NOT NULL
)
"""
)
if {"username", "password_hash", "password_salt"}.issubset(cols):
if "created_at" in cols:
c.execute(
"""
INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at)
SELECT id, username, password_hash, password_salt, COALESCE(created_at, ?)
FROM users
""",
(now,),
)
else:
c.execute(
"""
INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at)
SELECT id, username, password_hash, password_salt, ?
FROM users
""",
(now,),
)
elif {"username", "password"}.issubset(cols):
if "created_at" in cols:
rows = c.execute("SELECT id, username, password, created_at FROM users").fetchall()
else:
rows = c.execute("SELECT id, username, password FROM users").fetchall()
for r in rows:
username = (r["username"] or "").strip()
raw_pwd = str(r["password"] or "")
if not username or not raw_pwd:
continue
salt = secrets.token_hex(16)
pwd_hash = self._hash_password(raw_pwd, salt)
created_at = int(r["created_at"]) if "created_at" in r.keys() and r["created_at"] else now
c.execute(
"""
INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(int(r["id"]), username, pwd_hash, salt, created_at),
)
c.execute("DROP TABLE users")
c.execute("ALTER TABLE users_new RENAME TO users")
c.execute("PRAGMA foreign_keys=ON")
def _ensure_sessions_table(self, c: sqlite3.Connection) -> None:
required = {"token_hash", "user_id", "expires_at", "created_at"}
c.execute(
"""
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
)
"""
)
cols = self._table_columns(c, "sessions")
if required.issubset(cols):
return
c.execute("DROP TABLE IF EXISTS sessions")
c.execute(
"""
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
)
"""
)
def _hash_password(self, password: str, salt: str) -> str:
data = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
return data.hex()
def _hash_token(self, token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_user(self, username: str, password: str) -> dict | None:
now = int(time.time())
salt = secrets.token_hex(16)
pwd_hash = self._hash_password(password, salt)
try:
with self._conn() as c:
cur = c.execute(
"INSERT INTO users(username, password_hash, password_salt, created_at) VALUES (?, ?, ?, ?)",
(username, pwd_hash, salt, now),
)
uid = int(cur.lastrowid)
return {"id": uid, "username": username}
except sqlite3.IntegrityError:
return None
except sqlite3.Error as exc:
raise RuntimeError(f"create_user_db_error: {exc}") from exc
def verify_user(self, username: str, password: str) -> dict | None:
try:
with self._conn() as c:
row = c.execute(
"SELECT id, username, password_hash, password_salt FROM users WHERE username=?",
(username,),
).fetchone()
except sqlite3.Error as exc:
raise RuntimeError(f"verify_user_db_error: {exc}") from exc
if not row:
return None
calc = self._hash_password(password, row["password_salt"])
if not hmac.compare_digest(calc, row["password_hash"]):
return None
return {"id": int(row["id"]), "username": row["username"]}
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
with self._conn() as c:
row = c.execute(
"SELECT password_hash, password_salt FROM users WHERE id=?",
(user_id,),
).fetchone()
if not row:
return False
calc_old = self._hash_password(old_password, row["password_salt"])
if not hmac.compare_digest(calc_old, row["password_hash"]):
return False
new_salt = secrets.token_hex(16)
new_hash = self._hash_password(new_password, new_salt)
c.execute(
"UPDATE users SET password_hash=?, password_salt=? WHERE id=?",
(new_hash, new_salt, user_id),
)
return True
def reset_password_by_username(self, username: str, new_password: str) -> bool:
uname = (username or "").strip()
if not uname:
return False
with self._conn() as c:
row = c.execute("SELECT id FROM users WHERE username=?", (uname,)).fetchone()
if not row:
return False
new_salt = secrets.token_hex(16)
new_hash = self._hash_password(new_password, new_salt)
c.execute(
"UPDATE users SET password_hash=?, password_salt=? WHERE id=?",
(new_hash, new_salt, int(row["id"])),
)
return True
def create_session(self, user_id: int, ttl_seconds: int = 7 * 24 * 3600) -> str:
token = secrets.token_urlsafe(32)
token_hash = self._hash_token(token)
now = int(time.time())
exp = now + max(600, int(ttl_seconds))
with self._conn() as c:
c.execute(
"INSERT OR REPLACE INTO sessions(token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)",
(token_hash, user_id, exp, now),
)
return token
def delete_session(self, token: str) -> None:
if not token:
return
with self._conn() as c:
c.execute("DELETE FROM sessions WHERE token_hash=?", (self._hash_token(token),))
def delete_sessions_by_user(self, user_id: int) -> None:
with self._conn() as c:
c.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
def get_user_by_session(self, token: str) -> dict | None:
if not token:
return None
now = int(time.time())
th = self._hash_token(token)
with self._conn() as c:
c.execute("DELETE FROM sessions WHERE expires_at < ?", (now,))
row = c.execute(
"""
SELECT u.id, u.username
FROM sessions s
JOIN users u ON u.id=s.user_id
WHERE s.token_hash=? AND s.expires_at>=?
""",
(th, now),
).fetchone()
if not row:
return None
return {"id": int(row["id"]), "username": row["username"]}
def save_wechat_binding(
self,
user_id: int,
appid: str,
secret: str,
author: str = "",
thumb_media_id: str = "",
thumb_image_path: str = "",
) -> None:
now = int(time.time())
with self._conn() as c:
c.execute(
"""
INSERT INTO wechat_bindings(user_id, appid, secret, author, thumb_media_id, thumb_image_path, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
appid=excluded.appid,
secret=excluded.secret,
author=excluded.author,
thumb_media_id=excluded.thumb_media_id,
thumb_image_path=excluded.thumb_image_path,
updated_at=excluded.updated_at
""",
(user_id, appid, secret, author, thumb_media_id, thumb_image_path, now),
)
def get_wechat_binding(self, user_id: int) -> dict | None:
return self.get_active_wechat_binding(user_id)
def list_wechat_bindings(self, user_id: int) -> list[dict]:
with self._conn() as c:
rows = c.execute(
"""
SELECT id, account_name, appid, author, thumb_media_id, thumb_image_path, updated_at
FROM wechat_accounts
WHERE user_id=?
ORDER BY updated_at DESC, id DESC
""",
(user_id,),
).fetchall()
pref = c.execute(
"SELECT active_wechat_account_id FROM user_prefs WHERE user_id=?",
(user_id,),
).fetchone()
active_id = int(pref["active_wechat_account_id"]) if pref and pref["active_wechat_account_id"] else None
out: list[dict] = []
for r in rows:
out.append(
{
"id": int(r["id"]),
"account_name": r["account_name"] or "",
"appid": r["appid"] or "",
"author": r["author"] or "",
"thumb_media_id": r["thumb_media_id"] or "",
"thumb_image_path": r["thumb_image_path"] or "",
"updated_at": int(r["updated_at"] or 0),
"active": int(r["id"]) == active_id,
}
)
return out
def add_wechat_binding(
self,
user_id: int,
account_name: str,
appid: str,
secret: str,
author: str = "",
thumb_media_id: str = "",
thumb_image_path: str = "",
) -> dict:
now = int(time.time())
name = account_name.strip() or f"公众号{now % 10000}"
with self._conn() as c:
try:
cur = c.execute(
"""
INSERT INTO wechat_accounts
(user_id, account_name, appid, secret, author, thumb_media_id, thumb_image_path, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(user_id, name, appid, secret, author, thumb_media_id, thumb_image_path, now),
)
except sqlite3.IntegrityError:
name = f"{name}-{now % 1000}"
cur = c.execute(
"""
INSERT INTO wechat_accounts
(user_id, account_name, appid, secret, author, thumb_media_id, thumb_image_path, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(user_id, name, appid, secret, author, thumb_media_id, thumb_image_path, now),
)
aid = int(cur.lastrowid)
c.execute(
"""
INSERT INTO user_prefs(user_id, active_wechat_account_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
active_wechat_account_id=excluded.active_wechat_account_id,
updated_at=excluded.updated_at
""",
(user_id, aid, now),
)
return {"id": aid, "account_name": name}
def switch_active_wechat_binding(self, user_id: int, account_id: int) -> bool:
now = int(time.time())
with self._conn() as c:
row = c.execute(
"SELECT id FROM wechat_accounts WHERE id=? AND user_id=?",
(account_id, user_id),
).fetchone()
if not row:
return False
c.execute(
"""
INSERT INTO user_prefs(user_id, active_wechat_account_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
active_wechat_account_id=excluded.active_wechat_account_id,
updated_at=excluded.updated_at
""",
(user_id, account_id, now),
)
return True
def get_active_wechat_binding(self, user_id: int) -> dict | None:
with self._conn() as c:
pref = c.execute(
"SELECT active_wechat_account_id FROM user_prefs WHERE user_id=?",
(user_id,),
).fetchone()
aid = int(pref["active_wechat_account_id"]) if pref and pref["active_wechat_account_id"] else None
row = None
if aid:
row = c.execute(
"""
SELECT id, account_name, appid, secret, author, thumb_media_id, thumb_image_path, updated_at
FROM wechat_accounts
WHERE id=? AND user_id=?
""",
(aid, user_id),
).fetchone()
if not row:
row = c.execute(
"""
SELECT id, account_name, appid, secret, author, thumb_media_id, thumb_image_path, updated_at
FROM wechat_accounts
WHERE user_id=?
ORDER BY updated_at DESC, id DESC
LIMIT 1
""",
(user_id,),
).fetchone()
if row:
c.execute(
"""
INSERT INTO user_prefs(user_id, active_wechat_account_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
active_wechat_account_id=excluded.active_wechat_account_id,
updated_at=excluded.updated_at
""",
(user_id, int(row["id"]), int(time.time())),
)
if not row:
return None
return {
"id": int(row["id"]),
"account_name": row["account_name"] or "",
"appid": row["appid"] or "",
"secret": row["secret"] or "",
"author": row["author"] or "",
"thumb_media_id": row["thumb_media_id"] or "",
"thumb_image_path": row["thumb_image_path"] or "",
"updated_at": int(row["updated_at"] or 0),
}

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
import logging
import time
from io import BytesIO
from pathlib import Path
import httpx
import markdown2
@@ -8,65 +11,327 @@ import markdown2
from app.config import settings
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 转成可操作的说明。"""
if not data:
return "获取微信 access_token 失败(无返回内容)"
code = data.get("errcode")
msg = (data.get("errmsg") or "").strip()
if code == 40164:
return (
"微信 errcode=40164当前请求使用的出口 IP 未在公众号「IP 白名单」中。"
"请到 微信公众平台 → 设置与开发 → 基本配置 → IP 白名单,添加本服务对外的公网 IP"
"(日志里 invalid ip 后面的地址)。若在本地/Docker 调试,出口 IP 常会变,需填当前出口或改用固定出口的服务器。"
f" 微信原文:{msg}"
)
if code == 40013:
return f"微信 errcode=40013AppSecret 无效或已重置,请检查 WECHAT_SECRET。{msg}"
if code == 40125:
return f"微信 errcode=40125AppSecret 配置错误。{msg}"
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._token_cache: dict[str, dict[str, int | str]] = {}
self._runtime_thumb_media_id: str | None = None
async def publish_draft(self, req: WechatPublishRequest) -> PublishResponse:
if not settings.wechat_appid or not settings.wechat_secret:
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 "-"
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 = await self._get_access_token()
token, token_from_cache, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
if not token:
return PublishResponse(ok=False, detail="获取微信 access_token 失败")
detail = _detail_for_token_error(token_err_body)
logger.error("wechat access_token_unavailable rid=%s detail=%s", rid, detail[:200])
return PublishResponse(ok=False, detail=detail, data=token_err_body)
logger.info(
"wechat_token rid=%s cache_hit=%s",
rid,
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, account=acct)
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",
rid,
len(req.title or ""),
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 acct["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((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:
url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
r = await client.post(url, json=payload)
logger.info(
"wechat_http_post rid=%s endpoint=cgi-bin/draft/add http_timeout_s=25",
rid,
)
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, account=acct
)
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:
return PublishResponse(ok=False, detail=f"微信发布失败: {data}", data=data)
logger.warning(
"wechat_draft_failed rid=%s errcode=%s errmsg=%s raw=%s",
rid,
data.get("errcode"),
data.get("errmsg"),
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",
rid,
data.get("media_id", data),
)
return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data)
async def _get_access_token(self) -> str | None:
now = int(time.time())
if self._access_token and now < self._expires_at - 60:
return self._access_token
async def upload_cover(
self, filename: str, content: bytes, request_id: str = "", account: dict | None = None
) -> PublishResponse:
"""上传封面到微信永久素材,返回 thumb_media_id。"""
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",
)
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
) -> 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)}
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 {"media_id": mid, "url": data.get("url") or ""}
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 为永久图片素材;优先用配置,否则上传文件或内置图。"""
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
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 = (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()
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 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()
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 material["media_id"]
logger.error("wechat_thumb rid=%s source=default_jpeg_upload ok=0", rid)
return 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())
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:
r = await client.get(
"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()
data = r.json() if r.content else {}
token = data.get("access_token")
if not token:
return None
logger.warning(
"wechat_token_refresh_failed http_status=%s body=%s",
r.status_code,
data,
)
return None, False, data if isinstance(data, dict) else None
self._access_token = token
self._expires_at = now + int(data.get("expires_in", 7200))
return token
self._token_cache[key] = {"token": token, "expires_at": now + int(data.get("expires_in", 7200))}
return token, False, None

View File

@@ -1,6 +1,72 @@
const $ = (id) => document.getElementById(id);
function renderBodyPreview() {
const raw = ($("body") && $("body").value) || "";
const el = $("bodyPreview");
if (!el) return;
if (typeof marked !== "undefined" && marked.parse) {
el.innerHTML = marked.parse(raw, { breaks: true });
} else {
el.textContent = raw;
}
}
const statusEl = $("status");
const rewriteBtn = $("rewriteBtn");
const wechatBtn = $("wechatBtn");
const imBtn = $("imBtn");
const coverUploadBtn = $("coverUploadBtn");
const logoutBtn = $("logoutBtn");
function countText(v) {
return (v || "").trim().length;
}
/** 多选子项用顿号拼接,可选补充用分号接在末尾 */
function buildMultiPrompt(nameAttr, extraId) {
const boxes = document.querySelectorAll(`input[name="${nameAttr}"]:checked`);
const parts = Array.from(boxes).map((b) => (b.value || "").trim()).filter(Boolean);
const extraEl = extraId ? $(extraId) : null;
const extra = extraEl ? (extraEl.value || "").trim() : "";
let s = parts.join("、");
if (extra) s = s ? `${s}${extra}` : extra;
return s;
}
function updateMultiDropdownSummary(nameAttr, summaryId, emptyHint) {
const el = $(summaryId);
if (!el) return;
const parts = Array.from(document.querySelectorAll(`input[name="${nameAttr}"]:checked`)).map((b) =>
(b.value || "").trim(),
);
el.textContent = parts.length ? parts.join("、") : emptyHint;
}
function initMultiDropdowns() {
const pairs = [
{ name: "audienceChip", summary: "audienceSummary", empty: "点击展开,选择目标读者…" },
{ name: "toneChip", summary: "toneSummary", empty: "点击展开,选择语气风格…" },
];
pairs.forEach(({ name, summary, empty }) => {
updateMultiDropdownSummary(name, summary, empty);
document.querySelectorAll(`input[name="${name}"]`).forEach((cb) => {
cb.addEventListener("change", () => updateMultiDropdownSummary(name, summary, empty));
});
});
}
function updateCounters() {
$("sourceCount").textContent = `${countText($("sourceText").value)}`;
$("summaryCount").textContent = `${countText($("summary").value)}`;
$("bodyCount").textContent = `${countText($("body").value)}`;
renderBodyPreview();
}
function setLoading(button, loading, idleText, loadingText) {
if (!button) return;
button.disabled = loading;
button.textContent = loading ? loadingText : idleText;
}
function setStatus(msg, danger = false) {
statusEl.style.color = danger ? "#b42318" : "#0f5f3d";
@@ -15,9 +81,108 @@ async function postJSON(url, body) {
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "请求失败");
data._requestId = res.headers.get("X-Request-ID") || "";
return data;
}
async function fetchAuthMe() {
const res = await fetch("/api/auth/me");
const data = await res.json();
if (!data.ok || !data.logged_in) {
window.location.href = "/auth?next=/";
return null;
}
return data;
}
function renderWechatAccountSelect(me) {
const sel = $("wechatAccountSelect");
const hint = $("wechatAccountStatus");
if (!sel) return;
const list = Array.isArray(me.wechat_accounts) ? me.wechat_accounts : [];
const activeId =
me.active_wechat_account && me.active_wechat_account.id
? Number(me.active_wechat_account.id)
: 0;
sel.innerHTML = "";
if (!list.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "暂无公众号";
sel.appendChild(opt);
sel.disabled = true;
if (hint) hint.textContent = "请先在「公众号设置」绑定";
return;
}
sel.disabled = false;
list.forEach((a) => {
const opt = document.createElement("option");
opt.value = String(a.id);
const name = (a.account_name || "未命名").trim();
const appid = (a.appid || "").trim();
opt.textContent = appid ? `${name} (${appid})` : name;
if ((activeId && Number(a.id) === activeId) || a.active) opt.selected = true;
sel.appendChild(opt);
});
}
function flashWechatAccountHint(msg, clearMs = 2600) {
const hint = $("wechatAccountStatus");
if (!hint) return;
hint.textContent = msg;
if (clearMs > 0) {
window.clearTimeout(flashWechatAccountHint._t);
flashWechatAccountHint._t = window.setTimeout(() => {
hint.textContent = "";
}, clearMs);
}
}
const wechatAccountSelect = $("wechatAccountSelect");
if (wechatAccountSelect) {
wechatAccountSelect.addEventListener("change", async () => {
const id = Number(wechatAccountSelect.value || 0);
if (!id) return;
try {
const out = await postJSON("/api/auth/wechat/switch", { account_id: id });
if (!out.ok) {
flashWechatAccountHint(out.detail || "切换失败", 4000);
const me = await fetchAuthMe();
if (me) renderWechatAccountSelect(me);
return;
}
const me = await fetchAuthMe();
if (me) renderWechatAccountSelect(me);
flashWechatAccountHint("已切换");
} catch (e) {
flashWechatAccountHint(e.message || "切换失败", 4000);
const me = await fetchAuthMe();
if (me) renderWechatAccountSelect(me);
}
});
}
async function initWechatAccountSwitch() {
const me = await fetchAuthMe();
if (me) renderWechatAccountSelect(me);
}
async function logoutAndGoAuth() {
try {
await postJSON("/api/auth/logout", {});
} catch {
// 忽略退出接口异常,直接跳转认证页
}
window.location.href = "/auth?next=/";
}
if (logoutBtn) {
logoutBtn.addEventListener("click", async () => {
setLoading(logoutBtn, true, "退出登录", "退出中...");
await logoutAndGoAuth();
});
}
$("rewriteBtn").addEventListener("click", async () => {
const sourceText = $("sourceText").value.trim();
if (sourceText.length < 20) {
@@ -25,42 +190,102 @@ $("rewriteBtn").addEventListener("click", async () => {
return;
}
setStatus("AI 改写...");
setStatus("正在改写...");
setLoading(rewriteBtn, true, "改写并排版", "改写中...");
try {
const audience = buildMultiPrompt("audienceChip", "audienceExtra") || "公众号读者";
const tone = buildMultiPrompt("toneChip", "toneExtra") || "专业、可信、可读性强";
const data = await postJSON("/api/rewrite", {
source_text: sourceText,
title_hint: $("titleHint").value,
tone: $("tone").value,
audience: $("audience").value,
tone,
audience,
keep_points: $("keepPoints").value,
avoid_words: $("avoidWords").value,
});
$("title").value = data.title || "";
$("summary").value = data.summary || "";
$("body").value = data.body_markdown || "";
setStatus("改写完成,可直接发布。");
updateCounters();
const tr = data.trace || {};
if (data.mode === "fallback") {
const note = (data.quality_notes || [])[0] || "当前为保底改写稿";
setStatus(`改写完成(保底模式):${note}`, true);
} else if (tr.quality_soft_accept) {
setStatus(`改写完成(有提示):${(data.quality_notes || []).join("") || "请检查正文"}`);
statusEl.style.color = "#9a3412";
} else {
setStatus("改写完成。");
}
} catch (e) {
setStatus(`改写失败: ${e.message}`, true);
} finally {
setLoading(rewriteBtn, false, "改写并排版", "改写中...");
}
});
$("wechatBtn").addEventListener("click", async () => {
setStatus("正在发布到公众号草稿箱...");
setLoading(wechatBtn, true, "发布到公众号草稿箱", "发布中...");
try {
const data = await postJSON("/api/publish/wechat", {
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("公众号草稿发布成功");
} catch (e) {
setStatus(`公众号发布失败: ${e.message}`, true);
if ((e.message || "").includes("请先登录")) {
window.location.href = "/auth?next=/";
} else if ((e.message || "").includes("未绑定公众号")) {
window.location.href = "/settings";
}
} finally {
setLoading(wechatBtn, false, "发布到公众号草稿箱", "发布中...");
}
});
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);
if ((e.message || "").includes("请先登录")) {
window.location.href = "/auth?next=/";
} else if ((e.message || "").includes("未绑定公众号")) {
window.location.href = "/settings";
}
} finally {
setLoading(coverUploadBtn, false, "上传封面并绑定", "上传中...");
}
});
}
$("imBtn").addEventListener("click", async () => {
setStatus("正在发送到 IM...");
setLoading(imBtn, true, "发送到 IM", "发送中...");
try {
const data = await postJSON("/api/publish/im", {
title: $("title").value,
@@ -70,5 +295,15 @@ $("imBtn").addEventListener("click", async () => {
setStatus("IM 发送成功");
} catch (e) {
setStatus(`IM 发送失败: ${e.message}`, true);
} finally {
setLoading(imBtn, false, "发送到 IM", "发送中...");
}
});
["sourceText", "summary", "body"].forEach((id) => {
$(id).addEventListener("input", updateCounters);
});
updateCounters();
initMultiDropdowns();
initWechatAccountSwitch();

71
app/static/auth.js Normal file
View File

@@ -0,0 +1,71 @@
const $ = (id) => document.getElementById(id);
function setStatus(msg, danger = false) {
const el = $("status");
if (!el) return;
el.style.color = danger ? "#b42318" : "#0f5f3d";
el.textContent = msg;
}
function setLoading(button, loading, idleText, loadingText) {
if (!button) return;
button.disabled = loading;
button.textContent = loading ? loadingText : idleText;
}
async function postJSON(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "请求失败");
return data;
}
function nextPath() {
const nxt = (window.__NEXT_PATH__ || "/").trim();
if (!nxt.startsWith("/")) return "/";
return nxt;
}
function fields() {
return {
username: ($("username") && $("username").value.trim()) || "",
password: ($("password") && $("password").value) || "",
remember_me: Boolean($("rememberMe") && $("rememberMe").checked),
};
}
async function authAction(url, button, idleText, loadingText, okMessage) {
setLoading(button, true, idleText, loadingText);
try {
const data = await postJSON(url, fields());
if (!data.ok) {
setStatus(data.detail || "操作失败", true);
return;
}
setStatus(okMessage);
window.location.href = nextPath();
} catch (e) {
setStatus(e.message || "请求异常", true);
} finally {
setLoading(button, false, idleText, loadingText);
}
}
const loginBtn = $("loginBtn");
const registerBtn = $("registerBtn");
if (loginBtn) {
loginBtn.addEventListener("click", async () => {
await authAction("/api/auth/login", loginBtn, "登录", "登录中...", "登录成功,正在跳转...");
});
}
if (registerBtn) {
registerBtn.addEventListener("click", async () => {
await authAction("/api/auth/register", registerBtn, "注册", "注册中...", "注册成功,正在跳转...");
});
}

4
app/static/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="AIcreat">
<rect width="64" height="64" rx="14" fill="#0891b2"/>
<path d="M20 46l10-28h4l10 28h-5l-2-6H27l-2 6h-5zm9-10h14l-7-20-7 20z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@@ -0,0 +1,52 @@
const $ = (id) => document.getElementById(id);
function setStatus(msg, danger = false) {
const el = $("status");
if (!el) return;
el.style.color = danger ? "#b42318" : "#0f5f3d";
el.textContent = msg;
}
function setLoading(button, loading, idleText, loadingText) {
if (!button) return;
button.disabled = loading;
button.textContent = loading ? loadingText : idleText;
}
async function postJSON(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "请求失败");
return data;
}
const resetBtn = $("resetBtn");
if (resetBtn) {
resetBtn.addEventListener("click", async () => {
setLoading(resetBtn, true, "重置密码", "提交中...");
try {
const out = await postJSON("/api/auth/password/forgot", {
username: ($("username") && $("username").value.trim()) || "",
reset_key: ($("resetKey") && $("resetKey").value.trim()) || "",
new_password: ($("newPassword") && $("newPassword").value) || "",
});
if (!out.ok) {
setStatus(out.detail || "重置失败", true);
return;
}
setStatus("密码重置成功2 秒后跳转登录页。");
setTimeout(() => {
window.location.href = "/auth?next=/";
}, 2000);
} catch (e) {
setStatus(e.message || "重置失败", true);
} finally {
setLoading(resetBtn, false, "重置密码", "提交中...");
}
});
}

153
app/static/settings.js Normal file
View File

@@ -0,0 +1,153 @@
const $ = (id) => document.getElementById(id);
function setStatus(msg, danger = false) {
const el = $("status");
if (!el) return;
el.style.color = danger ? "#b42318" : "#0f5f3d";
el.textContent = msg;
}
function setLoading(button, loading, idleText, loadingText) {
if (!button) return;
button.disabled = loading;
button.textContent = loading ? loadingText : idleText;
}
async function postJSON(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "请求失败");
return data;
}
async function authMe() {
const res = await fetch("/api/auth/me");
const data = await res.json();
if (!data.logged_in) {
window.location.href = "/auth?next=/settings";
return null;
}
return data;
}
function renderAccounts(me) {
const sel = $("accountSelect");
if (!sel) return;
const list = Array.isArray(me.wechat_accounts) ? me.wechat_accounts : [];
const active = me.active_wechat_account && me.active_wechat_account.id ? Number(me.active_wechat_account.id) : 0;
sel.innerHTML = "";
if (!list.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "暂无公众号,请先绑定";
sel.appendChild(opt);
return;
}
list.forEach((a) => {
const opt = document.createElement("option");
opt.value = String(a.id);
opt.textContent = `${a.account_name} (${a.appid})`;
if ((active && a.id === active) || a.active) opt.selected = true;
sel.appendChild(opt);
});
}
async function refresh() {
const me = await authMe();
if (!me) return;
renderAccounts(me);
}
const accountSelect = $("accountSelect");
const bindBtn = $("bindBtn");
const logoutBtn = $("logoutBtn");
const changePwdBtn = $("changePwdBtn");
if (accountSelect) {
accountSelect.addEventListener("change", async () => {
const id = Number(accountSelect.value || 0);
if (!id) return;
try {
const out = await postJSON("/api/auth/wechat/switch", { account_id: id });
if (!out.ok) {
setStatus(out.detail || "切换失败", true);
return;
}
setStatus("已切换当前公众号。");
await refresh();
} catch (e) {
setStatus(e.message || "切换失败", true);
}
});
}
if (bindBtn) {
bindBtn.addEventListener("click", async () => {
setLoading(bindBtn, true, "绑定并设为当前账号", "绑定中...");
try {
const out = await postJSON("/api/auth/wechat/bind", {
account_name: ($("accountName") && $("accountName").value.trim()) || "",
appid: ($("appid") && $("appid").value.trim()) || "",
secret: ($("secret") && $("secret").value.trim()) || "",
author: "",
thumb_media_id: "",
thumb_image_path: "",
});
if (!out.ok) {
setStatus(out.detail || "绑定失败", true);
return;
}
setStatus("公众号绑定成功,已切换为当前账号。");
if ($("appid")) $("appid").value = "";
if ($("secret")) $("secret").value = "";
await refresh();
} catch (e) {
setStatus(e.message || "绑定失败", true);
} finally {
setLoading(bindBtn, false, "绑定并设为当前账号", "绑定中...");
}
});
}
if (logoutBtn) {
logoutBtn.addEventListener("click", async () => {
setLoading(logoutBtn, true, "退出登录", "退出中...");
try {
await postJSON("/api/auth/logout", {});
window.location.href = "/auth?next=/";
} catch (e) {
setStatus(e.message || "退出失败", true);
} finally {
setLoading(logoutBtn, false, "退出登录", "退出中...");
}
});
}
if (changePwdBtn) {
changePwdBtn.addEventListener("click", async () => {
setLoading(changePwdBtn, true, "修改密码", "提交中...");
try {
const out = await postJSON("/api/auth/password/change", {
old_password: ($("oldPassword") && $("oldPassword").value) || "",
new_password: ($("newPassword") && $("newPassword").value) || "",
});
if (!out.ok) {
setStatus(out.detail || "修改密码失败", true);
return;
}
setStatus("密码修改成功。");
if ($("oldPassword")) $("oldPassword").value = "";
if ($("newPassword")) $("newPassword").value = "";
} catch (e) {
setStatus(e.message || "修改密码失败", true);
} finally {
setLoading(changePwdBtn, false, "修改密码", "提交中...");
}
});
}
refresh();

View File

@@ -1,11 +1,12 @@
:root {
--bg: #f3f7f5;
--bg: #f8fafc;
--panel: #ffffff;
--line: #d7e3dd;
--text: #1a3128;
--muted: #5e7a6f;
--accent: #18794e;
--accent-2: #0f5f3d;
--line: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
--accent: #2563eb;
--accent-2: #1d4ed8;
--accent-soft: #eff6ff;
}
* {
@@ -14,28 +15,182 @@
body {
margin: 0;
background: radial-gradient(circle at 10% 20%, #e6f4ec, transparent 35%),
radial-gradient(circle at 90% 80%, #dff0ff, transparent 30%),
var(--bg);
background: var(--bg);
color: var(--text);
font-family: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
font-family: Inter, "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif;
height: 100vh;
overflow: hidden;
}
body.simple-page {
height: auto;
min-height: 100vh;
overflow: auto;
}
.topbar {
max-width: 1240px;
height: 72px;
margin: 0 auto;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.brand h1 {
margin: 0;
font-size: 32px;
letter-spacing: -0.02em;
}
.brand .muted {
margin: 6px 0 0;
}
.badge {
font-size: 12px;
font-weight: 700;
color: #fafafa;
background: #334155;
border: 1px solid #334155;
padding: 5px 10px;
border-radius: 999px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.wechat-account-switch {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
max-width: min(420px, 100%);
}
.wechat-account-label {
font-size: 13px;
font-weight: 600;
color: var(--muted);
white-space: nowrap;
}
.topbar-select {
min-width: 160px;
max-width: 260px;
flex: 1 1 auto;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--text);
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 8px 10px;
background: #fff;
cursor: pointer;
}
.topbar-select:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.wechat-account-status {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-btn {
width: auto;
margin-top: 0;
white-space: nowrap;
}
.subtle-link {
display: inline-block;
text-decoration: none;
color: var(--accent-2);
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 8px 12px;
background: #fff;
font-size: 13px;
font-weight: 700;
}
.simple-wrap {
max-width: 760px;
margin: 48px auto;
padding: 0 20px;
}
.simple-panel {
overflow: visible;
}
.section-title {
margin: 16px 0 4px;
font-size: 16px;
}
.actions-inline {
display: flex;
gap: 8px;
align-items: end;
justify-content: flex-end;
}
.check-row {
margin-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.check-label {
margin: 0;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--muted);
}
.check-label input[type="checkbox"] {
width: 16px;
height: 16px;
}
.layout {
max-width: 1280px;
margin: 24px auto;
padding: 0 16px;
max-width: 1240px;
height: calc(100vh - 72px);
margin: 0 auto;
padding: 0 20px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
grid-template-columns: minmax(320px, 42%) 1fr;
gap: 12px;
overflow: hidden;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 18px;
box-shadow: 0 8px 24px rgba(32, 84, 55, 0.07);
border-radius: 12px;
padding: 14px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
min-height: 0;
overflow: hidden;
}
h1,
@@ -43,27 +198,150 @@ h2 {
margin-top: 0;
}
.panel-head {
margin-bottom: 10px;
}
.panel-head h2 {
margin: 0;
font-size: 18px;
}
.muted {
color: var(--muted);
margin-top: -6px;
margin-top: 2px;
}
label {
display: block;
margin-top: 10px;
margin-bottom: 6px;
font-size: 14px;
margin-top: 8px;
margin-bottom: 4px;
font-size: 13px;
font-weight: 600;
}
.field-head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.multi-field .field-head label {
margin-top: 0;
}
.multi-field {
min-width: 0;
}
.multi-dropdown {
width: 100%;
}
.multi-dropdown > summary {
list-style: none;
cursor: pointer;
border: 1px solid var(--line);
border-radius: 10px;
padding: 8px 10px;
font-size: 13px;
font-weight: 600;
background: #fff;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.multi-dropdown > summary::-webkit-details-marker {
display: none;
}
.multi-dropdown > summary::after {
content: "";
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--muted);
flex-shrink: 0;
transition: transform 0.15s ease;
}
.multi-dropdown[open] > summary::after {
transform: rotate(180deg);
}
.multi-dropdown[open] > summary {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.multi-dropdown-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
color: var(--text);
}
.multi-dropdown-body {
margin-top: 4px;
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px 4px;
background: var(--panel);
max-height: 200px;
overflow-y: auto;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
}
.multi-dropdown-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
margin: 0;
border-radius: 8px;
width: auto;
}
.multi-dropdown-option:hover {
background: var(--accent-soft);
}
.multi-dropdown-option input {
width: auto;
margin: 0;
flex-shrink: 0;
accent-color: var(--accent);
}
.multi-extra {
margin-top: 4px;
}
.meta {
color: var(--muted);
font-size: 12px;
}
input,
select,
textarea,
button {
width: 100%;
border-radius: 10px;
border: 1px solid var(--line);
padding: 10px 12px;
font-size: 14px;
padding: 8px 10px;
font-size: 13px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
textarea {
@@ -71,9 +349,17 @@ textarea {
line-height: 1.5;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: #93c5fd;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
button {
cursor: pointer;
margin-top: 12px;
margin-top: 8px;
font-weight: 700;
}
@@ -91,6 +377,27 @@ button.primary:hover {
background: var(--accent-2);
}
button.secondary {
background: #fff;
color: var(--text);
border-color: var(--line);
}
button.secondary:hover {
background: #f8fdff;
}
.subtle-btn {
background: #fff;
border-color: #cbd5e1;
color: var(--accent-2);
}
button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -103,15 +410,117 @@ button.primary:hover {
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;
min-height: 18px;
margin-top: 6px;
color: var(--accent-2);
font-weight: 600;
font-weight: 500;
font-size: 12px;
}
.small {
font-size: 12px;
margin: 0 0 6px;
}
.body-split {
display: grid;
grid-template-columns: 1fr 0.9fr;
gap: 10px;
align-items: stretch;
min-height: 0;
}
.body-split textarea {
min-height: 170px;
max-height: 240px;
}
.preview-panel {
display: flex;
flex-direction: column;
min-width: 0;
}
.markdown-preview {
flex: 1;
min-height: 170px;
max-height: 240px;
overflow: auto;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fafcfb;
font-size: 14px;
line-height: 1.65;
}
.markdown-preview h2 {
font-size: 1.15rem;
margin: 1em 0 0.5em;
color: #111827;
}
.markdown-preview h3 {
font-size: 1.05rem;
margin: 0.9em 0 0.4em;
}
.markdown-preview p {
margin: 0.5em 0;
}
.markdown-preview ul,
.markdown-preview ol {
margin: 0.4em 0 0.6em 1.2em;
padding: 0;
}
.markdown-preview li {
margin: 0.25em 0;
}
@media (max-width: 960px) {
body {
overflow: auto;
}
.layout {
grid-template-columns: 1fr;
height: auto;
}
.body-split {
grid-template-columns: 1fr;
}
.topbar {
align-items: flex-start;
gap: 8px;
flex-direction: column;
}
.topbar-actions {
width: 100%;
justify-content: space-between;
}
.actions-inline {
justify-content: flex-start;
align-items: center;
margin-top: 8px;
}
}

47
app/templates/auth.html Normal file
View File

@@ -0,0 +1,47 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 登录注册</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260406a" />
<link rel="stylesheet" href="/static/style.css?v=20260410a" />
</head>
<body class="simple-page">
<main class="simple-wrap">
<section class="panel simple-panel">
<h2>登录 / 注册</h2>
<p class="muted small">登录后将跳转到编辑主页。</p>
<div class="grid2">
<div>
<label>用户名</label>
<input id="username" type="text" placeholder="请输入用户名" />
</div>
<div>
<label>密码</label>
<input id="password" type="password" placeholder="请输入密码(至少 6 位)" />
</div>
</div>
<div class="check-row">
<label class="check-label">
<input id="rememberMe" type="checkbox" checked />
<span>7 天内免登录(限时)</span>
</label>
<a class="subtle-link" href="/auth/forgot">忘记密码?</a>
</div>
<div class="actions">
<button id="loginBtn" class="primary" type="button">登录</button>
<button id="registerBtn" class="secondary" type="button">注册</button>
</div>
<p id="status" class="status"></p>
</section>
</main>
<script>
window.__NEXT_PATH__ = {{ next|tojson }};
</script>
<script src="/static/auth.js?v=20260410a"></script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 忘记密码</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260406a" />
<link rel="stylesheet" href="/static/style.css?v=20260410a" />
</head>
<body class="simple-page">
<main class="simple-wrap">
<section class="panel simple-panel">
<h2>忘记密码</h2>
<div class="grid2">
<div>
<label>用户名</label>
<input id="username" type="text" placeholder="请输入账号用户名" />
</div>
<div>
<label>重置码</label>
<input id="resetKey" type="password" placeholder="请输入管理员提供的重置码" />
</div>
</div>
<div>
<label>新密码</label>
<input id="newPassword" type="password" placeholder="请输入新密码(至少 6 位)" />
</div>
<p class="muted small">请向管理员获取重置码。若未改配置,默认重置码为 x2ws-reset-2026建议尽快修改</p>
<div class="actions">
<button id="resetBtn" class="primary" type="button">重置密码</button>
</div>
<p id="status" class="status"></p>
<div class="actions">
<a class="subtle-link" href="/auth?next=/">返回登录页</a>
<a class="subtle-link" href="/settings">去设置页</a>
</div>
</section>
</main>
<script src="/static/forgot_password.js?v=20260410b"></script>
</body>
</html>

View File

@@ -4,32 +4,86 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }}</title>
<link rel="stylesheet" href="/static/style.css" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260406a" />
<link rel="stylesheet" href="/static/style.css?v=20260410d" />
</head>
<body>
<header class="topbar">
<div class="brand">
<h1>{{ app_name }}</h1>
<p class="muted">从原文到公众号草稿,一页完成编辑、封面和发布。</p>
</div>
<div class="topbar-actions">
<div class="wechat-account-switch" title="草稿发布、封面上传均使用此处选中的公众号">
<label for="wechatAccountSelect" class="wechat-account-label">发表主体</label>
<select id="wechatAccountSelect" class="topbar-select" aria-label="切换公众号"></select>
<span id="wechatAccountStatus" class="muted small wechat-account-status" aria-live="polite"></span>
</div>
<a class="subtle-link" href="/settings">公众号设置</a>
<button id="logoutBtn" class="subtle-btn topbar-btn" type="button">退出登录</button>
</div>
</header>
<main class="layout">
<section class="panel input-panel">
<h1>{{ app_name }}</h1>
<p class="muted">粘贴 X 上的优质内容,生成公众号可发布版本,并支持同步到 IM。</p>
<div class="panel-head">
<h2>内容输入</h2>
</div>
<label>原始内容</label>
<textarea id="sourceText" rows="14" placeholder="粘贴 X 长文/线程内容..."></textarea>
<div class="field-head">
<label>内容</label>
<span id="sourceCount" class="meta">0 字</span>
</div>
<textarea id="sourceText" rows="9" placeholder="粘贴原文(长帖、线程、摘录均可),洗稿会围绕原文主题展开…"></textarea>
<div class="grid2">
<div>
<label>标题提示</label>
<input id="titleHint" type="text" placeholder="如AI Agent 商业化路径" />
</div>
<div>
<div class="multi-field">
<div class="field-head">
<label>目标读者</label>
<input id="audience" type="text" value="公众号运营者/产品经理" />
<span class="meta">下拉多选</span>
</div>
<details class="multi-dropdown" id="audienceDetails">
<summary>
<span class="multi-dropdown-text" id="audienceSummary"></span>
</summary>
<div class="multi-dropdown-body" role="group" aria-label="目标读者选项">
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="公众号运营者" checked />公众号运营者</label>
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="产品经理" checked />产品经理</label>
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="技术开发者" />技术开发者</label>
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="创业者" />创业者</label>
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="学生与研究者" />学生与研究者</label>
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="普通读者" />普通读者</label>
</div>
</details>
<input id="audienceExtra" type="text" class="multi-extra" placeholder="其他补充(可选)" />
</div>
</div>
<div class="grid2">
<div>
<div class="multi-field">
<div class="field-head">
<label>语气风格</label>
<input id="tone" type="text" value="专业、有观点、口语自然" />
<span class="meta">下拉多选</span>
</div>
<details class="multi-dropdown" id="toneDetails">
<summary>
<span class="multi-dropdown-text" id="toneSummary"></span>
</summary>
<div class="multi-dropdown-body" role="group" aria-label="语气风格选项">
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="专业严谨" checked />专业严谨</label>
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="有观点" checked />有观点</label>
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="口语自然" checked />口语自然</label>
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="轻松幽默" />轻松幽默</label>
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="故事化叙事" />故事化叙事</label>
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="科普解读" />科普解读</label>
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="理性克制" />理性克制</label>
</div>
</details>
<input id="toneExtra" type="text" class="multi-extra" placeholder="其他补充(可选)" />
</div>
<div>
<label>避免词汇</label>
@@ -40,29 +94,55 @@
<label>必须保留观点</label>
<input id="keepPoints" type="text" placeholder="逗号分隔" />
<button id="rewriteBtn" class="primary">AI 改写</button>
<button id="rewriteBtn" class="primary">改写并排版</button>
<p id="status" class="status"></p>
</section>
<section class="panel output-panel">
<div class="panel-head">
<h2>发布内容</h2>
</div>
<label>标题</label>
<input id="title" type="text" />
<div class="field-head">
<label>摘要</label>
<textarea id="summary" rows="3"></textarea>
<span id="summaryCount" class="meta">0 字</span>
</div>
<textarea id="summary" rows="2"></textarea>
<label>Markdown 正文</label>
<textarea id="body" rows="16"></textarea>
<label>公众号封面(可选上传)</label>
<div class="cover-tools">
<input id="coverFile" type="file" accept="image/png,image/jpeg,image/jpg,image/webp" />
<button id="coverUploadBtn" class="subtle-btn" 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>
</div>
<div class="body-split">
<textarea id="body" rows="7" placeholder="五段之间空一行;无需 # 标题"></textarea>
<div class="preview-panel">
<div class="field-head">
<label>排版预览</label>
<span class="meta">实时同步</span>
</div>
<div id="bodyPreview" class="markdown-preview"></div>
</div>
</div>
<div class="actions">
<button id="wechatBtn">发布到公众号草稿箱</button>
<button id="imBtn">发送到 IM</button>
<button id="wechatBtn" class="primary">发布到公众号草稿箱</button>
<button id="imBtn" class="secondary">发送到 IM</button>
</div>
</section>
</main>
<script src="/static/app.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="/static/app.js?v=20260410d"></script>
</body>
</html>

View File

@@ -0,0 +1,67 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 公众号设置</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260406a" />
<link rel="stylesheet" href="/static/style.css?v=20260410a" />
</head>
<body class="simple-page">
<main class="simple-wrap">
<section class="panel simple-panel">
<div class="panel-head">
<h2>公众号设置</h2>
<p class="muted small">支持绑定多个公众号并切换当前发布账号。</p>
</div>
<div class="grid2">
<div>
<label>当前账号</label>
<select id="accountSelect"></select>
</div>
<div class="actions-inline">
<a class="subtle-link" href="/">返回主页</a>
<button id="logoutBtn" class="subtle-btn topbar-btn" type="button">退出登录</button>
</div>
</div>
<h3 class="section-title">新增公众号</h3>
<div class="grid2">
<div>
<label>账号名</label>
<input id="accountName" type="text" placeholder="如:公司主号 / 客户A号" />
</div>
<div>
<label>AppID</label>
<input id="appid" type="text" placeholder="请输入公众号 AppID" />
</div>
</div>
<div>
<label>Secret</label>
<input id="secret" type="password" placeholder="请输入公众号 Secret" />
</div>
<button id="bindBtn" class="primary" type="button">绑定并设为当前账号</button>
<h3 class="section-title">账号安全</h3>
<div class="grid2">
<div>
<label>当前密码</label>
<input id="oldPassword" type="password" placeholder="请输入当前密码" />
</div>
<div>
<label>新密码</label>
<input id="newPassword" type="password" placeholder="请输入新密码(至少 6 位)" />
</div>
</div>
<div class="actions-inline">
<a class="subtle-link" href="/auth/forgot">忘记密码提示</a>
<button id="changePwdBtn" class="secondary topbar-btn" type="button">修改密码</button>
</div>
<p id="status" class="status"></p>
</section>
</main>
<script src="/static/settings.js?v=20260410a"></script>
</body>
</html>

1
data/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -1,9 +1,19 @@
services:
x2wechat:
build: .
build:
context: .
args:
# 海外 PyPI 可改为 https://pypi.org/simple
PIP_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
# 拉 python 镜像慢时取消下一行注释DaoCloud 同步 Docker Hub
# PY_BASE: docker.m.daocloud.io/library/python:3.11-slim
container_name: x2wechat-studio
ports:
- "18000:8000"
env_file:
- .env
environment:
AUTH_DB_PATH: /app/data/app.db
volumes:
- ./data:/app/data
restart: unless-stopped

View File

@@ -11,7 +11,9 @@ cp .env.example .env
docker compose up --build
```
启动后访问:`http://localhost:8000`
启动后访问:`http://localhost:18000`
容器默认将数据库挂载到宿主机目录 `./data``AUTH_DB_PATH=/app/data/app.db`),更新容器镜像不会清空历史账号和会话数据。
## 2. 使用流程
@@ -29,8 +31,29 @@ docker compose up --build
- `WECHAT_AUTHOR`:草稿默认作者名。
- `IM_WEBHOOK_URL`IM 推送地址(飞书/Slack/企微等)。
- `IM_SECRET`:可选签名。
- `AUTH_DB_PATH`账号数据库文件路径SQLite
- `AUTH_SESSION_TTL_SEC`:普通登录会话时长(秒)。
- `AUTH_REMEMBER_SESSION_TTL_SEC`:勾选“限时免登”时的会话时长(秒)。
- `AUTH_PASSWORD_RESET_KEY`:忘记密码重置码(用于“用户名+重置码”找回,默认 `x2ws-reset-2026`,建议改掉)。
## 4. 说明
- 未配置 `OPENAI_API_KEY` 时,系统会使用本地降级改写模板,便于你先跑通流程。
- 建议发布前人工复核事实与引用,避免版权和失真风险。
- 登录页支持“限时免登”,设置页支持修改密码;忘记密码页支持通过“用户名 + 重置码”重置密码。
## 5. 数据备份与恢复
数据库文件默认在 `./data/app.db`,可直接备份该文件:
```bash
cp ./data/app.db ./data/app.db.bak.$(date +%Y%m%d_%H%M%S)
```
恢复时停止服务后覆盖回去:
```bash
docker compose down
cp ./data/app.db.bak.YYYYMMDD_HHMMSS ./data/app.db
docker compose up -d
```

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

View File

@@ -33,3 +33,4 @@ echo
echo "Useful commands:"
echo " $COMPOSE_CMD logs -f"
echo " $COMPOSE_CMD down"
$COMPOSE_CMD logs -f