Compare commits

...

9 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
23 changed files with 2281 additions and 259 deletions

View File

@@ -16,11 +16,28 @@ OPENAI_SOURCE_MAX_CHARS=5000
AI_SOFT_ACCEPT=true
LOG_LEVEL=INFO
# 发布到公众号需:公众平台 → 基本配置 → IP 白名单,加入「本服务访问 api.weixin.qq.com 的出口公网 IP」。
# 若 errcode=40164 invalid ip把日志里的 IP 加入白名单;本地/Docker 出口 IP 常变,建议用固定 IP 服务器部署。
WECHAT_APPID=
WECHAT_SECRET=
WECHAT_AUTHOR=AI 编辑部
# 封面(图文草稿必填,否则 errcode=40007任选其一
# ① 填永久素材 IDWECHAT_THUMB_MEDIA_ID=(素材库 → 图片 → 复制 media_id
# ② 填容器内图片路径由服务自动上传WECHAT_THUMB_IMAGE_PATH=/app/cover.jpg
# ③ 两项都不填:服务会用内置默认图自动上传(需 material 接口权限)
# WECHAT_THUMB_MEDIA_ID=
# WECHAT_THUMB_IMAGE_PATH=
# 可填飞书/Slack/企微等 webhook
IM_WEBHOOK_URL=
# 若 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

@@ -18,5 +18,28 @@ RUN --mount=type=cache,target=/root/.cache/pip \
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

@@ -34,9 +34,25 @@ class Settings(BaseSettings):
wechat_appid: str | None = Field(default=None, alias="WECHAT_APPID")
wechat_secret: str | None = Field(default=None, alias="WECHAT_SECRET")
wechat_author: str = Field(default="AI 编辑部", alias="WECHAT_AUTHOR")
wechat_thumb_media_id: str | None = Field(
default=None,
alias="WECHAT_THUMB_MEDIA_ID",
description="草稿图文封面:永久素材 media_id素材库或 add_material。与 WECHAT_THUMB_IMAGE_PATH 二选一即可",
)
wechat_thumb_image_path: str | None = Field(
default=None,
alias="WECHAT_THUMB_IMAGE_PATH",
description="本地封面图路径(容器内),将自动上传为永久素材;不配则使用内置灰底图上传",
)
im_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL")
im_secret: str | None = Field(default=None, alias="IM_SECRET")
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()

View File

@@ -1,19 +1,30 @@
from __future__ import annotations
import hmac
import logging
from urllib.parse import urlparse
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
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.logging_setup import configure_logging
from app.middleware import RequestContextMiddleware
from app.schemas import IMPublishRequest, RewriteRequest, WechatPublishRequest
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()
@@ -39,13 +50,58 @@ 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():
"""供页面展示:当前是否接入模型、模型名、提供方(不含密钥)。"""
@@ -62,6 +118,174 @@ async def api_config():
}
@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, request: Request):
rid = getattr(request.state, "request_id", "")
@@ -93,6 +317,12 @@ async def rewrite(req: RewriteRequest, request: Request):
@app.post("/api/publish/wechat")
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",
@@ -102,13 +332,58 @@ async def publish_wechat(req: WechatPublishRequest, request: Request):
len(req.body_markdown or ""),
bool((req.author or "").strip()),
)
out = await wechat.publish_draft(req, request_id=rid)
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 detail=%s errcode=%s",
"api_wechat_out rid=%s ok=%s wechat_errcode=%s detail_preview=%s",
rid,
out.ok,
(out.detail or "")[:120],
(out.data or {}).get("errcode") if isinstance(out.data, dict) else None,
wcode,
(out.detail or "")[:240],
)
return out
@app.post("/api/wechat/cover/upload")
async def upload_wechat_cover(request: Request, file: UploadFile = File(...)):
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

View File

@@ -6,6 +6,7 @@ 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 = ""
@@ -29,6 +30,7 @@ class WechatPublishRequest(BaseModel):
summary: str = ""
body_markdown: str
author: str = ""
thumb_media_id: str = ""
class IMPublishRequest(BaseModel):
@@ -40,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

@@ -34,9 +34,9 @@ def _is_likely_timeout_error(exc: BaseException) -> bool:
return "timed out" in s or "timeout" in s
# 短文洗稿:5 个自然段、正文总字数上限(含标点
MAX_BODY_CHARS = 500
# 短文洗稿:正文目标约 500 字,优先完整性(软约束,不硬截断
MIN_BODY_CHARS = 80
TARGET_BODY_CHARS = 500
def _preview_for_log(text: str, limit: int = 400) -> str:
@@ -54,9 +54,10 @@ SYSTEM_PROMPT = """
1) **忠实原意**:只概括、转述原文已有信息,不编造事实,不偷换主题;
2) 语气通俗、干脆,避免套话堆砌;
3) 只输出合法 JSONtitle, summary, body_markdown
4) **body_markdown 约束**恰好 **5 个自然段**;段与段之间用一个空行分隔;**不要**使用 # / ## 标题符号;全文(正文)总字数 **不超过 500 字**(含标点)
4) **body_markdown 约束**按内容密度使用 **4~6 个自然段**;段与段之间用一个空行分隔;**不要**使用 # / ## 标题符号;正文以 **约 500 字**为目标,优先完整表达并避免冗长重复
5) title、summary 也要短:标题约 818 字;摘要约 4080 字;
6) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "
6) 关键观点需要加粗:请用 Markdown `**加粗**` 标出 2~4 个重点短语;
7) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "
""".strip()
@@ -69,17 +70,18 @@ REWRITE_SCHEMA_HINT = """
}
body_markdown 写法:
- 必须且只能有 **5 段**:每段若干完整句子,段之间 **\\n\\n**(空一行);
- 使用 **4~6 段**:每段若干完整句子,段之间 **\\n\\n**(空一行);
- **禁止** markdown 标题(不要用 #
- 正文总长 **500 字**,宁可短而清楚,不要写满废话
- 内容顺序建议:第 1 段交代在说什么;中间 3 段展开关键信息;最后 1 段收束或提醒(均须紧扣原文,勿乱发挥)。
- 正文目标约 **500 字**(可上下浮动),以信息完整为先,避免冗长和重复
- 请用 `**...**` 加粗 2~4 个关键观点词;
- 内容顺序建议:首段交代在说什么;中间段展开关键信息;末段收束或提醒(均须紧扣原文,勿乱发挥)。
""".strip()
# 通义等模型若首次过短/结构不对,再要一次
_JSON_BODY_TOO_SHORT_RETRY = """
【系统复检】上一次 body_markdown 不符合要求。请重输出**完整** JSON
- 正文必须 **恰好 5 个自然段**(仅 \\n\\n 分段),无 # 标题,总字数 **≤500 字**
- 正文必须使用 **4~6 个自然段**(仅 \\n\\n 分段),无 # 标题;篇幅尽量收敛到约 500 字,同时保持信息完整
- 忠实原稿、简短高效;
- 引号只用「」『』;
- 只输出 JSON。
@@ -315,6 +317,7 @@ class AIRewriter:
用户改写偏好:
- 标题参考:{req.title_hint or '自动生成'}
- 写作风格:{req.writing_style}
- 语气风格:{req.tone}
- 目标读者:{req.audience}
- 必须保留观点:{req.keep_points or ''}
@@ -331,6 +334,7 @@ class AIRewriter:
用户改写偏好:
- 标题参考:{req.title_hint or '自动生成'}
- 写作风格:{req.writing_style}
- 语气风格:{req.tone}
- 目标读者:{req.audience}
- 必须保留观点:{req.keep_points or ''}
@@ -343,7 +347,8 @@ class AIRewriter:
self, req: RewriteRequest, cleaned_source: str, reason: str, trace: dict[str, Any] | None = None
) -> RewriteResponse:
sentences = self._extract_sentences(cleaned_source)
points = self._pick_key_points(sentences, limit=5)
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)
@@ -354,17 +359,14 @@ class AIRewriter:
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),
_one_line(analysis["cause"], 105),
_one_line(analysis["impact"], 105),
_one_line(analysis["risk"], 105),
_one_line(conclusion, 105),
]
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)
if len(body) > MAX_BODY_CHARS:
body = body[: MAX_BODY_CHARS - 1] + ""
normalized = {
"title": title,
"summary": summary,
@@ -425,6 +427,14 @@ class AIRewriter:
),
}
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)
@@ -474,6 +484,10 @@ class AIRewriter:
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:
@@ -481,14 +495,65 @@ class AIRewriter:
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:
return json.loads(raw[start : end + 1])
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
@@ -721,8 +786,6 @@ class AIRewriter:
text = (body or "").strip()
if not text:
text = "(正文生成失败,请重试。)"
if len(text) > MAX_BODY_CHARS:
text = text[: MAX_BODY_CHARS - 1] + ""
return text
def _quality_issues(
@@ -745,20 +808,25 @@ class AIRewriter:
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()]
pc = len(paragraphs)
need_p = 4 if lenient else 5
if pc < need_p:
issues.append(f"正文需约 5 个自然段、空行分隔(当前 {pc} 段)")
elif not lenient and pc > 6:
issues.append(f"正文段落多(当前 {pc} 段),请合并为 5 段左右")
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) > MAX_BODY_CHARS:
issues.append(f"正文超过 {MAX_BODY_CHARS} 字(当前 {len(body)} 字),请压缩")
elif len(body) < MIN_BODY_CHARS:
if len(body) < MIN_BODY_CHARS:
issues.append(f"正文过短(当前阈值 ≥{MIN_BODY_CHARS} 字)")
elif len(body) > 900:
issues.append(
f"正文偏长(当前 {len(body)} 字),建议收敛到约 {TARGET_BODY_CHARS} 字(可上下浮动)"
)
if re.search(r"(?m)^#+\s", body):
issues.append("正文请勿使用 # 标题符号,只用自然段")
if "**" not in body:
issues.append("关键观点未加粗(建议 2~4 处)")
if self._looks_like_raw_copy(source, body, lenient=lenient):
issues.append("改写与原文相似度过高,疑似未充分重写")
@@ -792,11 +860,35 @@ class AIRewriter:
title = (normalized.get("title") or "").strip()
if len(title) < 4 or len(body) < 40:
return False
if len(body) > MAX_BODY_CHARS + 80:
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)
return body.strip() + "\n"
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"

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

@@ -2,6 +2,8 @@ from __future__ import annotations
import logging
import time
from io import BytesIO
from pathlib import Path
import httpx
import markdown2
@@ -11,6 +13,21 @@ from app.schemas import PublishResponse, WechatPublishRequest
logger = logging.getLogger(__name__)
_default_cover_jpeg_bytes: bytes | None = None
def _build_default_cover_jpeg() -> tuple[bytes, str]:
"""生成简单灰底封面360×200满足微信对缩略图尺寸的常规要求。"""
global _default_cover_jpeg_bytes
if _default_cover_jpeg_bytes is None:
from PIL import Image
im = Image.new("RGB", (360, 200), (236, 240, 241))
buf = BytesIO()
im.save(buf, format="JPEG", quality=88)
_default_cover_jpeg_bytes = buf.getvalue()
return _default_cover_jpeg_bytes, "cover_default.jpg"
def _detail_for_token_error(data: dict | None) -> str:
"""把微信返回的 errcode 转成可操作的说明。"""
@@ -32,18 +49,49 @@ def _detail_for_token_error(data: dict | None) -> str:
return f"获取微信 access_token 失败errcode={code} errmsg={msg}"
def _detail_for_draft_error(data: dict) -> str:
code = data.get("errcode")
msg = (data.get("errmsg") or "").strip()
if code == 40007:
return (
"微信 errcode=40007invalid media_idthumb_media_id 缺失、不是「永久图片素材」、或已失效。"
"请核对 WECHAT_THUMB_MEDIA_ID 是否从素材管理里复制的永久素材;若不确定,可删掉该变量,"
"由服务自动上传封面WECHAT_THUMB_IMAGE_PATH 或内置默认图)。"
f" 微信原文:{msg}"
)
return f"微信草稿失败errcode={code} errmsg={msg}"
class WechatPublisher:
def __init__(self) -> None:
self._access_token = None
self._expires_at = 0
self._token_cache: dict[str, dict[str, int | str]] = {}
self._runtime_thumb_media_id: str | None = None
async def publish_draft(self, req: WechatPublishRequest, request_id: str = "") -> PublishResponse:
def _resolve_account(self, account: dict | None = None) -> dict[str, str]:
src = account or {}
appid = (src.get("appid") or settings.wechat_appid or "").strip()
secret = (src.get("secret") or settings.wechat_secret or "").strip()
author = (src.get("author") or settings.wechat_author or "").strip()
thumb_media_id = (src.get("thumb_media_id") or settings.wechat_thumb_media_id or "").strip()
thumb_image_path = (src.get("thumb_image_path") or settings.wechat_thumb_image_path or "").strip()
return {
"appid": appid,
"secret": secret,
"author": author,
"thumb_media_id": thumb_media_id,
"thumb_image_path": thumb_image_path,
}
async def publish_draft(
self, req: WechatPublishRequest, request_id: str = "", account: dict | None = None
) -> PublishResponse:
rid = request_id or "-"
if not settings.wechat_appid or not settings.wechat_secret:
acct = self._resolve_account(account)
if not acct["appid"] or not acct["secret"]:
logger.warning("wechat skipped rid=%s reason=missing_appid_or_secret", rid)
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
token, token_from_cache, token_err_body = await self._get_access_token()
token, token_from_cache, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
if not token:
detail = _detail_for_token_error(token_err_body)
logger.error("wechat access_token_unavailable rid=%s detail=%s", rid, detail[:200])
@@ -55,6 +103,21 @@ class WechatPublisher:
token_from_cache,
)
req_thumb = (req.thumb_media_id or "").strip()
if req_thumb:
logger.info("wechat_thumb rid=%s source=request_media_id", rid)
thumb_id = req_thumb
else:
thumb_id = await self._resolve_thumb_media_id(token, rid, 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",
@@ -63,29 +126,48 @@ class WechatPublisher:
len(req.summary or ""),
len(html or ""),
)
# 图文 newsthumb_media_id 为必填(永久素材),否则 errcode=40007
payload = {
"articles": [
{
"title": req.title,
"author": req.author or settings.wechat_author,
"digest": req.summary,
"article_type": "news",
"title": req.title[:32] if len(req.title) > 32 else req.title,
"author": (req.author or 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}"
logger.info(
"wechat_http_post rid=%s endpoint=cgi-bin/draft/add http_timeout_s=25",
rid,
)
r = await client.post(url, json=payload)
r = await client.post(draft_url, json=payload)
data = r.json()
if data.get("errcode") == 40007 and explicit_used:
logger.warning(
"wechat_draft_40007_retry rid=%s hint=config_media_id_invalid_try_auto_upload",
rid,
)
self._runtime_thumb_media_id = None
thumb_alt = await self._resolve_thumb_media_id(
token, rid, force_skip_explicit=True, 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:
logger.warning(
"wechat_draft_failed rid=%s errcode=%s errmsg=%s raw=%s",
@@ -94,7 +176,8 @@ class WechatPublisher:
data.get("errmsg"),
data,
)
return PublishResponse(ok=False, detail=f"微信发布失败: {data}", data=data)
detail = _detail_for_draft_error(data) if isinstance(data, dict) else f"微信发布失败: {data}"
return PublishResponse(ok=False, detail=detail, data=data)
logger.info(
"wechat_draft_ok rid=%s media_id=%s",
@@ -103,11 +186,131 @@ class WechatPublisher:
)
return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data)
async def _get_access_token(self) -> tuple[str | None, bool, dict | None]:
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())
if self._access_token and now < self._expires_at - 60:
return self._access_token, True, None
key = appid.strip()
cached = self._token_cache.get(key)
if cached:
token = str(cached.get("token") or "")
exp = int(cached.get("expires_at") or 0)
if token and now < exp - 60:
return token, True, None
logger.info("wechat_http_get endpoint=cgi-bin/token reason=refresh_access_token")
async with httpx.AsyncClient(timeout=20) as client:
@@ -115,8 +318,8 @@ class WechatPublisher:
"https://api.weixin.qq.com/cgi-bin/token",
params={
"grant_type": "client_credential",
"appid": settings.wechat_appid,
"secret": settings.wechat_secret,
"appid": appid,
"secret": secret,
},
)
data = r.json() if r.content else {}
@@ -130,6 +333,5 @@ class WechatPublisher:
)
return None, False, data if isinstance(data, dict) else None
self._access_token = token
self._expires_at = now + int(data.get("expires_in", 7200))
self._token_cache[key] = {"token": token, "expires_at": now + int(data.get("expires_in", 7200))}
return token, False, None

View File

@@ -15,11 +15,46 @@ 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)}`;
@@ -50,30 +85,102 @@ async function postJSON(url, body) {
return data;
}
function renderTrace(trace, headerRid) {
const wrap = $("traceWrap");
const pre = $("traceJson");
const badge = $("traceBadge");
if (!pre || !wrap) return;
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;
}
if (!trace || Object.keys(trace).length === 0) {
pre.textContent = headerRid
? JSON.stringify({ request_id: headerRid, note: "响应中无 trace 字段" }, null, 2)
: "尚无数据完成一次「AI 改写」后,这里会显示请求 ID、耗时、质检与降级原因。";
if (badge) badge.textContent = "";
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);
});
}
const merged = { ...trace };
if (headerRid && !merged.request_id) merged.request_id = headerRid;
pre.textContent = JSON.stringify(merged, null, 2);
const mode = merged.mode || "";
if (badge) {
badge.textContent = mode === "ai" ? "AI" : mode === "fallback" ? "保底" : "";
badge.className = "trace-badge " + (mode === "ai" ? "is-ai" : mode === "fallback" ? "is-fallback" : "");
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);
}
wrap.open = true;
}
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 () => {
@@ -83,14 +190,16 @@ $("rewriteBtn").addEventListener("click", async () => {
return;
}
setStatus("AI 改写...");
setLoading(rewriteBtn, true, "AI 改写并排版", "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,
});
@@ -98,27 +207,20 @@ $("rewriteBtn").addEventListener("click", async () => {
$("summary").value = data.summary || "";
$("body").value = data.body_markdown || "";
updateCounters();
renderTrace(data.trace, data._requestId);
const tr = data.trace || {};
const modelLine = tr.model ? `模型 ${tr.model}` : "";
if (data.mode === "fallback") {
const note = (data.quality_notes || [])[0] || "当前为保底改写稿";
setStatus(
`改写完成(保底模式,未使用或未通过千问长文):${note}${modelLine ? ` · ${modelLine}` : ""}`,
true
);
setStatus(`改写完成(保底模式):${note}`, true);
} else if (tr.quality_soft_accept) {
setStatus(
`改写完成AI质检提示${(data.quality_notes || []).join("") || "见 quality_notes"} · ${modelLine || "AI"}`
);
setStatus(`改写完成(有提示):${(data.quality_notes || []).join("") || "请检查正文"}`);
statusEl.style.color = "#9a3412";
} else {
setStatus(`改写完成AI 洗稿)${modelLine ? ` · ${modelLine}` : ""}`);
setStatus("改写完成。");
}
} catch (e) {
setStatus(`改写失败: ${e.message}`, true);
} finally {
setLoading(rewriteBtn, false, "AI 改写并排版", "AI 改写中...");
setLoading(rewriteBtn, false, "改写并排版", "改写中...");
}
});
@@ -130,16 +232,57 @@ $("wechatBtn").addEventListener("click", async () => {
title: $("title").value,
summary: $("summary").value,
body_markdown: $("body").value,
thumb_media_id: $("thumbMediaId") ? $("thumbMediaId").value.trim() : "",
});
if (!data.ok) throw new Error(data.detail);
setStatus("公众号草稿发布成功");
} 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", "发送中...");
@@ -161,32 +304,6 @@ $("imBtn").addEventListener("click", async () => {
$(id).addEventListener("input", updateCounters);
});
async function loadBackendConfig() {
const el = $("backendConfig");
if (!el) return;
try {
const res = await fetch("/api/config");
const c = await res.json();
if (!c.openai_configured) {
el.textContent =
"后端未配置 OPENAI_API_KEY改写将使用本地保底稿千问不会参与。请在 .env 中配置并重启容器。";
el.style.color = "#b42318";
return;
}
const name =
c.provider === "dashscope"
? "通义千问DashScope 兼容接口)"
: "OpenAI 兼容接口";
const host = c.base_url_host ? ` · ${c.base_url_host}` : "";
const to = c.openai_timeout_sec != null ? ` · 单轮最长等待 ${c.openai_timeout_sec}s` : "";
el.textContent = `已接入:${c.openai_model} · ${name}${host}${to}`;
el.style.color = "";
} catch (e) {
el.textContent = "无法读取 /api/config请确认服务已启动";
el.style.color = "#b42318";
}
}
loadBackendConfig();
updateCounters();
renderTrace(null, "");
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,17 +15,24 @@
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: 1280px;
margin: 20px auto 0;
padding: 0 16px;
max-width: 1240px;
height: 72px;
margin: 0 auto;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
@@ -32,42 +40,157 @@ body {
.brand h1 {
margin: 0;
font-size: 32px;
letter-spacing: -0.02em;
}
.brand .muted {
margin: 6px 0 0;
}
.backend-config {
margin: 8px 0 0;
line-height: 1.5;
}
.badge {
font-size: 12px;
font-weight: 700;
color: #0f5f3d;
background: #eaf7f0;
border: 1px solid #cde6d7;
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: 14px auto 24px;
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,
@@ -75,16 +198,25 @@ 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;
}
@@ -94,19 +226,121 @@ label {
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;
}
@@ -116,15 +350,16 @@ textarea {
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: #8ec6aa;
box-shadow: 0 0 0 3px rgba(24, 121, 78, 0.12);
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;
}
@@ -142,6 +377,22 @@ 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;
@@ -159,68 +410,43 @@ button:disabled {
gap: 10px;
}
.cover-tools {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
}
.cover-tools button {
margin-top: 0;
width: auto;
white-space: nowrap;
}
.status {
min-height: 22px;
margin-top: 8px;
min-height: 18px;
margin-top: 6px;
color: var(--accent-2);
font-weight: 600;
font-weight: 500;
font-size: 12px;
}
.small {
font-size: 13px;
margin: 0 0 12px;
}
.flow-hint {
margin: 0 0 14px 18px;
padding: 0;
font-size: 13px;
line-height: 1.6;
}
.trace-wrap {
margin-top: 12px;
padding: 10px 12px;
border: 1px dashed var(--line);
border-radius: 10px;
background: #f9fbf9;
}
.trace-wrap summary {
cursor: pointer;
font-weight: 700;
color: var(--text);
}
.trace-badge {
margin-left: 8px;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 700;
}
.trace-badge.is-ai {
background: #eaf7f0;
color: #0f5f3d;
border: 1px solid #cde6d7;
}
.trace-badge.is-fallback {
background: #fff4e6;
color: #9a3412;
border: 1px solid #fed7aa;
font-size: 12px;
margin: 0 0 6px;
}
.body-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
grid-template-columns: 1fr 0.9fr;
gap: 10px;
align-items: stretch;
min-height: 0;
}
.body-split textarea {
min-height: 280px;
min-height: 170px;
max-height: 240px;
}
.preview-panel {
@@ -231,10 +457,10 @@ button:disabled {
.markdown-preview {
flex: 1;
min-height: 280px;
max-height: 480px;
min-height: 170px;
max-height: 240px;
overflow: auto;
padding: 12px 14px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fafcfb;
@@ -245,7 +471,7 @@ button:disabled {
.markdown-preview h2 {
font-size: 1.15rem;
margin: 1em 0 0.5em;
color: var(--accent-2);
color: #111827;
}
.markdown-preview h3 {
@@ -267,23 +493,14 @@ button:disabled {
margin: 0.25em 0;
}
.trace-json {
margin: 10px 0 0;
padding: 10px;
max-height: 220px;
overflow: auto;
font-size: 11px;
line-height: 1.45;
background: #fff;
border-radius: 8px;
border: 1px solid var(--line);
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 960px) {
body {
overflow: auto;
}
.layout {
grid-template-columns: 1fr;
height: auto;
}
.body-split {
@@ -295,4 +512,15 @@ button:disabled {
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,49 +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">粘贴原文 → 洗成约 <strong>5 段、500 字内</strong> 的短文(无小标题)→ 右侧预览 → 满意后发布。</p>
<p id="backendConfig" class="backend-config muted small" aria-live="polite"></p>
<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>
<div class="badge">Beta</div>
</header>
<main class="layout">
<section class="panel input-panel">
<h2>输入与改写策略</h2>
<ol class="flow-hint muted">
<li>粘贴原文并设置语气/读者</li>
<li>点击改写 → 右侧为短标题、摘要与<strong>五段正文</strong>(段落间空一行)</li>
<li>看「运行追踪」:<strong>模式为 AI</strong> 且模型名正确,即千问/接口已生效</li>
<li>人工改好后 →「发布到公众号草稿箱」(需配置 WECHAT_*</li>
</ol>
<div class="panel-head">
<h2>内容输入</h2>
</div>
<div class="field-head">
<label>原始内容</label>
<label>内容</label>
<span id="sourceCount" class="meta">0 字</span>
</div>
<textarea id="sourceText" rows="14" placeholder="粘贴原文(长帖、线程、摘录均可),洗稿会围绕原文主题展开…"></textarea>
<textarea id="sourceText" rows="9" placeholder="粘贴原文(长帖、线程、摘录均可),洗稿会围绕原文主题展开…"></textarea>
<div class="grid2">
<div>
<label>标题提示</label>
<input id="titleHint" type="text" placeholder="如AI Agent 商业化路径" />
</div>
<div>
<label>目标读者</label>
<input id="audience" type="text" value="公众号运营者/产品经理" />
<div class="multi-field">
<div class="field-head">
<label>目标读者</label>
<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>
<label>语气风格</label>
<input id="tone" type="text" value="专业、有观点、口语自然" />
<div class="multi-field">
<div class="field-head">
<label>语气风格</label>
<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>
@@ -57,13 +94,14 @@
<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">
<h2>发布内容</h2>
<p class="muted small">下方「运行追踪」会显示本次请求 ID、耗时、质检项与是否降级便于与容器日志对照。</p>
<div class="panel-head">
<h2>发布内容</h2>
</div>
<label>标题</label>
<input id="title" type="text" />
@@ -72,36 +110,39 @@
<label>摘要</label>
<span id="summaryCount" class="meta">0 字</span>
</div>
<textarea id="summary" rows="3"></textarea>
<textarea id="summary" rows="2"></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="10" placeholder="五段之间空一行;无需 # 标题"></textarea>
<textarea id="body" rows="7" placeholder="五段之间空一行;无需 # 标题"></textarea>
<div class="preview-panel">
<div class="field-head">
<label>排版预览</label>
<span class="meta">与公众号 HTML 渲染接近</span>
<span class="meta">实时同步</span>
</div>
<div id="bodyPreview" class="markdown-preview"></div>
</div>
</div>
<details id="traceWrap" class="trace-wrap">
<summary>运行追踪 <span id="traceBadge" class="trace-badge"></span></summary>
<pre id="traceJson" class="trace-json"></pre>
</details>
<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="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="/static/app.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

@@ -12,4 +12,8 @@ services:
- "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