Compare commits
11 Commits
124a5f0192
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e69666dbb3 | ||
|
|
5b4bee1939 | ||
|
|
17591de58f | ||
|
|
222bf2e70d | ||
|
|
869e3b5976 | ||
|
|
780871e93c | ||
|
|
9070dfba35 | ||
|
|
005a25b77a | ||
|
|
b342a90f9d | ||
|
|
1d389767e6 | ||
|
|
babf24a0b0 |
31
.env.example
31
.env.example
@@ -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_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
OPENAI_BASE_URL=
|
||||||
OPENAI_MODEL=gpt-4.1-mini
|
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_APPID=
|
||||||
WECHAT_SECRET=
|
WECHAT_SECRET=
|
||||||
WECHAT_AUTHOR=AI 编辑部
|
WECHAT_AUTHOR=AI 编辑部
|
||||||
|
# 封面(图文草稿必填,否则 errcode=40007):任选其一
|
||||||
|
# ① 填永久素材 ID:WECHAT_THUMB_MEDIA_ID=(素材库 → 图片 → 复制 media_id)
|
||||||
|
# ② 填容器内图片路径,由服务自动上传:WECHAT_THUMB_IMAGE_PATH=/app/cover.jpg
|
||||||
|
# ③ 两项都不填:服务会用内置默认图自动上传(需 material 接口权限)
|
||||||
|
# WECHAT_THUMB_MEDIA_ID=
|
||||||
|
# WECHAT_THUMB_IMAGE_PATH=
|
||||||
|
|
||||||
# 可填飞书/Slack/企微等 webhook
|
# 可填飞书/Slack/企微等 webhook
|
||||||
IM_WEBHOOK_URL=
|
IM_WEBHOOK_URL=
|
||||||
# 若 webhook 需要签名可填
|
# 若 webhook 需要签名可填
|
||||||
IM_SECRET=
|
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
2
.gitignore
vendored
@@ -2,3 +2,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
|||||||
38
Dockerfile
38
Dockerfile
@@ -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
|
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 ./
|
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 . .
|
||||||
|
|
||||||
|
# 可选:构建时自动拉取远端仓库最新代码覆盖工作目录(默认关闭)。
|
||||||
|
# 放在 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
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -9,13 +9,50 @@ class Settings(BaseSettings):
|
|||||||
openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")
|
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_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL")
|
||||||
openai_model: str = Field(default="gpt-4.1-mini", alias="OPENAI_MODEL")
|
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=ai,quality_notes 记录问题),仅模型完全失败时用保底稿",
|
||||||
|
)
|
||||||
|
|
||||||
wechat_appid: str | None = Field(default=None, alias="WECHAT_APPID")
|
wechat_appid: str | None = Field(default=None, alias="WECHAT_APPID")
|
||||||
wechat_secret: str | None = Field(default=None, alias="WECHAT_SECRET")
|
wechat_secret: str | None = Field(default=None, alias="WECHAT_SECRET")
|
||||||
wechat_author: str = Field(default="AI 编辑部", alias="WECHAT_AUTHOR")
|
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_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL")
|
||||||
im_secret: str | None = Field(default=None, alias="IM_SECRET")
|
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()
|
settings = Settings()
|
||||||
|
|||||||
25
app/logging_setup.py
Normal file
25
app/logging_setup.py
Normal 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)
|
||||||
380
app/main.py
380
app/main.py
@@ -1,40 +1,402 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
import hmac
|
||||||
from fastapi.responses import HTMLResponse
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.config import settings
|
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.ai_rewriter import AIRewriter
|
||||||
from app.services.im import IMPublisher
|
from app.services.im import IMPublisher
|
||||||
|
from app.services.user_store import UserStore
|
||||||
from app.services.wechat import WechatPublisher
|
from app.services.wechat import WechatPublisher
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(title=settings.app_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")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
rewriter = AIRewriter()
|
rewriter = AIRewriter()
|
||||||
wechat = WechatPublisher()
|
wechat = WechatPublisher()
|
||||||
im = IMPublisher()
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request):
|
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})
|
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")
|
@app.post("/api/rewrite")
|
||||||
async def rewrite(req: RewriteRequest):
|
async def rewrite(req: RewriteRequest, request: Request):
|
||||||
return rewriter.rewrite(req)
|
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")
|
@app.post("/api/publish/wechat")
|
||||||
async def publish_wechat(req: WechatPublishRequest):
|
async def publish_wechat(req: WechatPublishRequest, request: Request):
|
||||||
return await wechat.publish_draft(req)
|
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")
|
@app.post("/api/publish/im")
|
||||||
async def publish_im(req: IMPublishRequest):
|
async def publish_im(req: IMPublishRequest, request: Request):
|
||||||
return await im.publish(req)
|
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
61
app/middleware.py
Normal 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
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class RewriteRequest(BaseModel):
|
class RewriteRequest(BaseModel):
|
||||||
source_text: str = Field(..., min_length=20)
|
source_text: str = Field(..., min_length=20)
|
||||||
title_hint: str = ""
|
title_hint: str = ""
|
||||||
|
writing_style: str = "科普解读"
|
||||||
tone: str = "专业、可信、可读性强"
|
tone: str = "专业、可信、可读性强"
|
||||||
audience: str = "公众号读者"
|
audience: str = "公众号读者"
|
||||||
keep_points: str = ""
|
keep_points: str = ""
|
||||||
@@ -14,6 +17,12 @@ class RewriteResponse(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
summary: str
|
summary: str
|
||||||
body_markdown: str
|
body_markdown: str
|
||||||
|
mode: str = "ai"
|
||||||
|
quality_notes: list[str] = []
|
||||||
|
trace: dict[str, Any] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="改写链路追踪:请求 ID、耗时、模型、质检与降级原因,便于监测与回溯",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WechatPublishRequest(BaseModel):
|
class WechatPublishRequest(BaseModel):
|
||||||
@@ -21,6 +30,7 @@ class WechatPublishRequest(BaseModel):
|
|||||||
summary: str = ""
|
summary: str = ""
|
||||||
body_markdown: str
|
body_markdown: str
|
||||||
author: str = ""
|
author: str = ""
|
||||||
|
thumb_media_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
class IMPublishRequest(BaseModel):
|
class IMPublishRequest(BaseModel):
|
||||||
@@ -32,3 +42,33 @@ class PublishResponse(BaseModel):
|
|||||||
ok: bool
|
ok: bool
|
||||||
detail: str
|
detail: str
|
||||||
data: dict | None = None
|
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
|
||||||
|
|||||||
@@ -1,49 +1,706 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
from textwrap import shorten
|
from textwrap import shorten
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.schemas import RewriteRequest, RewriteResponse
|
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 = """
|
SYSTEM_PROMPT = """
|
||||||
你是中文内容编辑与合规顾问。请把输入内容进行“原创改写”,要求:
|
你是资深中文科普类公众号编辑,擅长把长文、线程贴改写成**极短、好读**的推送。
|
||||||
1) 保留核心事实,但避免逐句复述;
|
目标:在**不偏离原意**的前提下,用最少字数讲清一件事;不要写成技术方案、长文大纲或带很多小标题的文章。
|
||||||
2) 结构清晰:导语、3-5个小节、结尾行动建议;
|
|
||||||
3) 风格适合微信公众号,表达自然,避免AI腔;
|
硬性规则:
|
||||||
4) 如果原文存在未经核实结论,请使用“可能/有待验证”等措辞;
|
1) **忠实原意**:只概括、转述原文已有信息,不编造事实,不偷换主题;
|
||||||
5) 输出必须是 JSON,字段:title, summary, body_markdown。
|
2) 语气通俗、干脆,避免套话堆砌;
|
||||||
|
3) 只输出合法 JSON:title, summary, body_markdown;
|
||||||
|
4) **body_markdown 约束**:按内容密度使用 **4~6 个自然段**;段与段之间用一个空行分隔;**不要**使用 # / ## 标题符号;正文以 **约 500 字**为目标,优先完整表达并避免冗长重复;
|
||||||
|
5) title、summary 也要短:标题约 8~18 字;摘要约 40~80 字;
|
||||||
|
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()
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
class AIRewriter:
|
class AIRewriter:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._client = None
|
self._client = None
|
||||||
|
self._prefer_chat_first = False
|
||||||
if settings.openai_api_key:
|
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(
|
self._client = OpenAI(
|
||||||
api_key=settings.openai_api_key,
|
api_key=settings.openai_api_key,
|
||||||
base_url=settings.openai_base_url,
|
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:
|
raw_in = (req.source_text or "").replace("\r\n", "\n").strip()
|
||||||
if not self._client:
|
_step("clean_source", truncated=len(cleaned_source) < len(raw_in))
|
||||||
return self._fallback_rewrite(req)
|
|
||||||
|
|
||||||
|
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"""
|
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.title_hint or '自动生成'}
|
||||||
- 目标语气:{req.tone}
|
- 写作风格:{req.writing_style}
|
||||||
|
- 语气风格:{req.tone}
|
||||||
- 目标读者:{req.audience}
|
- 目标读者:{req.audience}
|
||||||
- 必须保留观点:{req.keep_points or '无'}
|
- 必须保留观点:{req.keep_points or '无'}
|
||||||
- 避免词汇:{req.avoid_words 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()
|
""".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(
|
completion = self._client.responses.create(
|
||||||
model=settings.openai_model,
|
model=settings.openai_model,
|
||||||
input=[
|
input=[
|
||||||
@@ -51,30 +708,187 @@ class AIRewriter:
|
|||||||
{"role": "user", "content": user_prompt},
|
{"role": "user", "content": user_prompt},
|
||||||
],
|
],
|
||||||
text={"format": {"type": "json_object"}},
|
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
|
if re.search(r"(?m)^#+\s", body):
|
||||||
import json
|
issues.append("正文请勿使用 # 标题符号,只用自然段")
|
||||||
|
|
||||||
data = json.loads(text)
|
if "**" not in body:
|
||||||
return RewriteResponse(**data)
|
issues.append("关键观点未加粗(建议 2~4 处)")
|
||||||
|
|
||||||
def _fallback_rewrite(self, req: RewriteRequest) -> RewriteResponse:
|
if self._looks_like_raw_copy(source, body, lenient=lenient):
|
||||||
clean_text = re.sub(r"\n{2,}", "\n", req.source_text.strip())
|
issues.append("改写与原文相似度过高,疑似未充分重写")
|
||||||
lines = [line.strip() for line in clean_text.split("\n") if line.strip()]
|
|
||||||
head = lines[0] if lines else clean_text[:50]
|
if req.avoid_words:
|
||||||
title = req.title_hint.strip() or f"{shorten(head, width=26, placeholder='')}:可执行解读"
|
bad_words = [w.strip() for w in re.split(r"[,,]\s*", req.avoid_words) if w.strip()]
|
||||||
summary = shorten(clean_text, width=90, placeholder="...")
|
hit = [w for w in bad_words if w in body or w in summary or w in title]
|
||||||
body = (
|
if hit:
|
||||||
f"## 导语\n"
|
issues.append(f"命中禁用词: {', '.join(hit)}")
|
||||||
f"这篇内容值得关注的核心在于:{summary}\n\n"
|
|
||||||
f"## 重点拆解\n"
|
ai_phrases = ["首先", "其次", "最后", "总而言之", "赋能", "闭环", "颠覆"]
|
||||||
f"1. 背景与问题:从原文可以看到关键矛盾已出现。\n"
|
hit_ai = [w for w in ai_phrases if body.count(w) >= 3]
|
||||||
f"2. 方法与动作:建议按“目标-路径-验证”三步推进。\n"
|
if hit_ai:
|
||||||
f"3. 风险与边界:避免绝对化表述,必要时补充数据来源。\n\n"
|
issues.append("存在明显 AI 套话堆叠")
|
||||||
f"## 公众号改写正文\n"
|
|
||||||
f"{clean_text}\n\n"
|
return issues
|
||||||
f"## 结尾\n"
|
|
||||||
f"以上为原创重组版本,可继续补充案例与数据后发布。"
|
def _looks_like_raw_copy(self, source: str, rewritten: str, lenient: bool = False) -> bool:
|
||||||
)
|
src = re.sub(r"\s+", "", source or "")
|
||||||
return RewriteResponse(title=title, summary=summary, body_markdown=body)
|
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"
|
||||||
|
|||||||
@@ -3,20 +3,36 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.schemas import IMPublishRequest, PublishResponse
|
from app.schemas import IMPublishRequest, PublishResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class IMPublisher:
|
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:
|
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 配置")
|
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)
|
webhook = self._with_signature(settings.im_webhook_url, settings.im_secret)
|
||||||
payload = {
|
payload = {
|
||||||
"msg_type": "post",
|
"msg_type": "post",
|
||||||
@@ -31,15 +47,25 @@ class IMPublisher:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
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)
|
r = await client.post(webhook, json=payload)
|
||||||
try:
|
try:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
data = {"status_code": r.status_code, "text": r.text}
|
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:
|
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)
|
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)
|
return PublishResponse(ok=True, detail="IM 推送成功", data=data)
|
||||||
|
|
||||||
def _with_signature(self, webhook: str, secret: str | None) -> str:
|
def _with_signature(self, webhook: str, secret: str | None) -> str:
|
||||||
|
|||||||
512
app/services/user_store.py
Normal file
512
app/services/user_store.py
Normal 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),
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import markdown2
|
import markdown2
|
||||||
@@ -8,65 +11,327 @@ import markdown2
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.schemas import PublishResponse, WechatPublishRequest
|
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=40013:AppSecret 无效或已重置,请检查 WECHAT_SECRET。{msg}"
|
||||||
|
if code == 40125:
|
||||||
|
return f"微信 errcode=40125:AppSecret 配置错误。{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=40007(invalid media_id):thumb_media_id 缺失、不是「永久图片素材」、或已失效。"
|
||||||
|
"请核对 WECHAT_THUMB_MEDIA_ID 是否从素材管理里复制的永久素材;若不确定,可删掉该变量,"
|
||||||
|
"由服务自动上传封面(WECHAT_THUMB_IMAGE_PATH 或内置默认图)。"
|
||||||
|
f" 微信原文:{msg}"
|
||||||
|
)
|
||||||
|
return f"微信草稿失败:errcode={code} errmsg={msg}"
|
||||||
|
|
||||||
|
|
||||||
class WechatPublisher:
|
class WechatPublisher:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._access_token = None
|
self._token_cache: dict[str, dict[str, int | str]] = {}
|
||||||
self._expires_at = 0
|
self._runtime_thumb_media_id: str | None = None
|
||||||
|
|
||||||
async def publish_draft(self, req: WechatPublishRequest) -> PublishResponse:
|
def _resolve_account(self, account: dict | None = None) -> dict[str, str]:
|
||||||
if not settings.wechat_appid or not settings.wechat_secret:
|
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 配置")
|
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:
|
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)
|
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 ""),
|
||||||
|
)
|
||||||
|
# 图文 news:thumb_media_id 为必填(永久素材),否则 errcode=40007
|
||||||
payload = {
|
payload = {
|
||||||
"articles": [
|
"articles": [
|
||||||
{
|
{
|
||||||
"title": req.title,
|
"article_type": "news",
|
||||||
"author": req.author or settings.wechat_author,
|
"title": req.title[:32] if len(req.title) > 32 else req.title,
|
||||||
"digest": req.summary,
|
"author": (req.author or acct["author"] or settings.wechat_author)[:16],
|
||||||
|
"digest": (req.summary or "")[:128],
|
||||||
"content": html,
|
"content": html,
|
||||||
"content_source_url": "",
|
"content_source_url": "",
|
||||||
|
"thumb_media_id": thumb_id,
|
||||||
"need_open_comment": 0,
|
"need_open_comment": 0,
|
||||||
"only_fans_can_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:
|
async with httpx.AsyncClient(timeout=25) as client:
|
||||||
url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
|
logger.info(
|
||||||
r = await client.post(url, json=payload)
|
"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()
|
data = r.json()
|
||||||
|
|
||||||
if data.get("errcode", 0) != 0:
|
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)
|
return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data)
|
||||||
|
|
||||||
async def _get_access_token(self) -> str | None:
|
async def upload_cover(
|
||||||
now = int(time.time())
|
self, filename: str, content: bytes, request_id: str = "", account: dict | None = None
|
||||||
if self._access_token and now < self._expires_at - 60:
|
) -> PublishResponse:
|
||||||
return self._access_token
|
"""上传封面到微信永久素材,返回 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:
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
"https://api.weixin.qq.com/cgi-bin/token",
|
"https://api.weixin.qq.com/cgi-bin/token",
|
||||||
params={
|
params={
|
||||||
"grant_type": "client_credential",
|
"grant_type": "client_credential",
|
||||||
"appid": settings.wechat_appid,
|
"appid": appid,
|
||||||
"secret": settings.wechat_secret,
|
"secret": secret,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
data = r.json()
|
data = r.json() if r.content else {}
|
||||||
|
|
||||||
token = data.get("access_token")
|
token = data.get("access_token")
|
||||||
if not 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._token_cache[key] = {"token": token, "expires_at": now + int(data.get("expires_in", 7200))}
|
||||||
self._expires_at = now + int(data.get("expires_in", 7200))
|
return token, False, None
|
||||||
return token
|
|
||||||
|
|||||||
@@ -1,6 +1,72 @@
|
|||||||
const $ = (id) => document.getElementById(id);
|
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 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) {
|
function setStatus(msg, danger = false) {
|
||||||
statusEl.style.color = danger ? "#b42318" : "#0f5f3d";
|
statusEl.style.color = danger ? "#b42318" : "#0f5f3d";
|
||||||
@@ -15,9 +81,108 @@ async function postJSON(url, body) {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.detail || "请求失败");
|
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||||
|
data._requestId = res.headers.get("X-Request-ID") || "";
|
||||||
return data;
|
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 () => {
|
$("rewriteBtn").addEventListener("click", async () => {
|
||||||
const sourceText = $("sourceText").value.trim();
|
const sourceText = $("sourceText").value.trim();
|
||||||
if (sourceText.length < 20) {
|
if (sourceText.length < 20) {
|
||||||
@@ -25,42 +190,102 @@ $("rewriteBtn").addEventListener("click", async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("AI 改写中...");
|
setStatus("正在改写...");
|
||||||
|
setLoading(rewriteBtn, true, "改写并排版", "改写中...");
|
||||||
try {
|
try {
|
||||||
|
const audience = buildMultiPrompt("audienceChip", "audienceExtra") || "公众号读者";
|
||||||
|
const tone = buildMultiPrompt("toneChip", "toneExtra") || "专业、可信、可读性强";
|
||||||
const data = await postJSON("/api/rewrite", {
|
const data = await postJSON("/api/rewrite", {
|
||||||
source_text: sourceText,
|
source_text: sourceText,
|
||||||
title_hint: $("titleHint").value,
|
title_hint: $("titleHint").value,
|
||||||
tone: $("tone").value,
|
tone,
|
||||||
audience: $("audience").value,
|
audience,
|
||||||
keep_points: $("keepPoints").value,
|
keep_points: $("keepPoints").value,
|
||||||
avoid_words: $("avoidWords").value,
|
avoid_words: $("avoidWords").value,
|
||||||
});
|
});
|
||||||
$("title").value = data.title || "";
|
$("title").value = data.title || "";
|
||||||
$("summary").value = data.summary || "";
|
$("summary").value = data.summary || "";
|
||||||
$("body").value = data.body_markdown || "";
|
$("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) {
|
} catch (e) {
|
||||||
setStatus(`改写失败: ${e.message}`, true);
|
setStatus(`改写失败: ${e.message}`, true);
|
||||||
|
} finally {
|
||||||
|
setLoading(rewriteBtn, false, "改写并排版", "改写中...");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("wechatBtn").addEventListener("click", async () => {
|
$("wechatBtn").addEventListener("click", async () => {
|
||||||
setStatus("正在发布到公众号草稿箱...");
|
setStatus("正在发布到公众号草稿箱...");
|
||||||
|
setLoading(wechatBtn, true, "发布到公众号草稿箱", "发布中...");
|
||||||
try {
|
try {
|
||||||
const data = await postJSON("/api/publish/wechat", {
|
const data = await postJSON("/api/publish/wechat", {
|
||||||
title: $("title").value,
|
title: $("title").value,
|
||||||
summary: $("summary").value,
|
summary: $("summary").value,
|
||||||
body_markdown: $("body").value,
|
body_markdown: $("body").value,
|
||||||
|
thumb_media_id: $("thumbMediaId") ? $("thumbMediaId").value.trim() : "",
|
||||||
});
|
});
|
||||||
if (!data.ok) throw new Error(data.detail);
|
if (!data.ok) throw new Error(data.detail);
|
||||||
setStatus("公众号草稿发布成功");
|
setStatus("公众号草稿发布成功");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`公众号发布失败: ${e.message}`, true);
|
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 () => {
|
$("imBtn").addEventListener("click", async () => {
|
||||||
setStatus("正在发送到 IM...");
|
setStatus("正在发送到 IM...");
|
||||||
|
setLoading(imBtn, true, "发送到 IM", "发送中...");
|
||||||
try {
|
try {
|
||||||
const data = await postJSON("/api/publish/im", {
|
const data = await postJSON("/api/publish/im", {
|
||||||
title: $("title").value,
|
title: $("title").value,
|
||||||
@@ -70,5 +295,15 @@ $("imBtn").addEventListener("click", async () => {
|
|||||||
setStatus("IM 发送成功");
|
setStatus("IM 发送成功");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`IM 发送失败: ${e.message}`, true);
|
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
71
app/static/auth.js
Normal 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
4
app/static/favicon.svg
Normal 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 |
52
app/static/forgot_password.js
Normal file
52
app/static/forgot_password.js
Normal 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
153
app/static/settings.js
Normal 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();
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #f3f7f5;
|
--bg: #f8fafc;
|
||||||
--panel: #ffffff;
|
--panel: #ffffff;
|
||||||
--line: #d7e3dd;
|
--line: #e2e8f0;
|
||||||
--text: #1a3128;
|
--text: #1e293b;
|
||||||
--muted: #5e7a6f;
|
--muted: #64748b;
|
||||||
--accent: #18794e;
|
--accent: #2563eb;
|
||||||
--accent-2: #0f5f3d;
|
--accent-2: #1d4ed8;
|
||||||
|
--accent-soft: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -14,28 +15,182 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: radial-gradient(circle at 10% 20%, #e6f4ec, transparent 35%),
|
background: var(--bg);
|
||||||
radial-gradient(circle at 90% 80%, #dff0ff, transparent 30%),
|
|
||||||
var(--bg);
|
|
||||||
color: var(--text);
|
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 {
|
.layout {
|
||||||
max-width: 1280px;
|
max-width: 1240px;
|
||||||
margin: 24px auto;
|
height: calc(100vh - 72px);
|
||||||
padding: 0 16px;
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: minmax(320px, 42%) 1fr;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
padding: 18px;
|
padding: 14px;
|
||||||
box-shadow: 0 8px 24px rgba(32, 84, 55, 0.07);
|
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -43,27 +198,150 @@ h2 {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-top: -6px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 10px;
|
margin-top: 8px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 4px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
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,
|
input,
|
||||||
|
select,
|
||||||
textarea,
|
textarea,
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
padding: 10px 12px;
|
padding: 8px 10px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
@@ -71,9 +349,17 @@ textarea {
|
|||||||
line-height: 1.5;
|
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 {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-top: 12px;
|
margin-top: 8px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +377,27 @@ button.primary:hover {
|
|||||||
background: var(--accent-2);
|
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 {
|
.actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -103,15 +410,117 @@ button.primary:hover {
|
|||||||
gap: 10px;
|
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 {
|
.status {
|
||||||
min-height: 22px;
|
min-height: 18px;
|
||||||
margin-top: 8px;
|
margin-top: 6px;
|
||||||
color: var(--accent-2);
|
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) {
|
@media (max-width: 960px) {
|
||||||
|
body {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
grid-template-columns: 1fr;
|
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
47
app/templates/auth.html
Normal 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>
|
||||||
42
app/templates/forgot_password.html
Normal file
42
app/templates/forgot_password.html
Normal 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>
|
||||||
@@ -4,32 +4,86 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{{ app_name }}</title>
|
<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>
|
</head>
|
||||||
<body>
|
<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">
|
<main class="layout">
|
||||||
<section class="panel input-panel">
|
<section class="panel input-panel">
|
||||||
<h1>{{ app_name }}</h1>
|
<div class="panel-head">
|
||||||
<p class="muted">粘贴 X 上的优质内容,生成公众号可发布版本,并支持同步到 IM。</p>
|
<h2>内容输入</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>原始内容</label>
|
<div class="field-head">
|
||||||
<textarea id="sourceText" rows="14" placeholder="粘贴 X 长文/线程内容..."></textarea>
|
<label>内容</label>
|
||||||
|
<span id="sourceCount" class="meta">0 字</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="sourceText" rows="9" placeholder="粘贴原文(长帖、线程、摘录均可),洗稿会围绕原文主题展开…"></textarea>
|
||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div>
|
<div>
|
||||||
<label>标题提示</label>
|
<label>标题提示</label>
|
||||||
<input id="titleHint" type="text" placeholder="如:AI Agent 商业化路径" />
|
<input id="titleHint" type="text" placeholder="如:AI Agent 商业化路径" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="multi-field">
|
||||||
|
<div class="field-head">
|
||||||
<label>目标读者</label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div>
|
<div class="multi-field">
|
||||||
|
<div class="field-head">
|
||||||
<label>语气风格</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label>避免词汇</label>
|
<label>避免词汇</label>
|
||||||
@@ -40,29 +94,55 @@
|
|||||||
<label>必须保留观点</label>
|
<label>必须保留观点</label>
|
||||||
<input id="keepPoints" type="text" placeholder="逗号分隔" />
|
<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>
|
<p id="status" class="status"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel output-panel">
|
<section class="panel output-panel">
|
||||||
|
<div class="panel-head">
|
||||||
<h2>发布内容</h2>
|
<h2>发布内容</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>标题</label>
|
<label>标题</label>
|
||||||
<input id="title" type="text" />
|
<input id="title" type="text" />
|
||||||
|
|
||||||
|
<div class="field-head">
|
||||||
<label>摘要</label>
|
<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>
|
<label>公众号封面(可选上传)</label>
|
||||||
<textarea id="body" rows="16"></textarea>
|
<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">
|
<div class="actions">
|
||||||
<button id="wechatBtn">发布到公众号草稿箱</button>
|
<button id="wechatBtn" class="primary">发布到公众号草稿箱</button>
|
||||||
<button id="imBtn">发送到 IM</button>
|
<button id="imBtn" class="secondary">发送到 IM</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
67
app/templates/settings.html
Normal file
67
app/templates/settings.html
Normal 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
1
data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
x2wechat:
|
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
|
container_name: x2wechat-studio
|
||||||
ports:
|
ports:
|
||||||
- "18000:8000"
|
- "18000:8000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
AUTH_DB_PATH: /app/data/app.db
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
25
readme.md
25
readme.md
@@ -11,7 +11,9 @@ cp .env.example .env
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问:`http://localhost:8000`
|
启动后访问:`http://localhost:18000`
|
||||||
|
|
||||||
|
容器默认将数据库挂载到宿主机目录 `./data`(`AUTH_DB_PATH=/app/data/app.db`),更新容器镜像不会清空历史账号和会话数据。
|
||||||
|
|
||||||
## 2. 使用流程
|
## 2. 使用流程
|
||||||
|
|
||||||
@@ -29,8 +31,29 @@ docker compose up --build
|
|||||||
- `WECHAT_AUTHOR`:草稿默认作者名。
|
- `WECHAT_AUTHOR`:草稿默认作者名。
|
||||||
- `IM_WEBHOOK_URL`:IM 推送地址(飞书/Slack/企微等)。
|
- `IM_WEBHOOK_URL`:IM 推送地址(飞书/Slack/企微等)。
|
||||||
- `IM_SECRET`:可选签名。
|
- `IM_SECRET`:可选签名。
|
||||||
|
- `AUTH_DB_PATH`:账号数据库文件路径(SQLite)。
|
||||||
|
- `AUTH_SESSION_TTL_SEC`:普通登录会话时长(秒)。
|
||||||
|
- `AUTH_REMEMBER_SESSION_TTL_SEC`:勾选“限时免登”时的会话时长(秒)。
|
||||||
|
- `AUTH_PASSWORD_RESET_KEY`:忘记密码重置码(用于“用户名+重置码”找回,默认 `x2ws-reset-2026`,建议改掉)。
|
||||||
|
|
||||||
## 4. 说明
|
## 4. 说明
|
||||||
|
|
||||||
- 未配置 `OPENAI_API_KEY` 时,系统会使用本地降级改写模板,便于你先跑通流程。
|
- 未配置 `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
|
||||||
|
```
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ httpx==0.28.1
|
|||||||
openai==1.108.2
|
openai==1.108.2
|
||||||
markdown2==2.5.4
|
markdown2==2.5.4
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
Pillow>=10.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user