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

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

View File

@@ -32,3 +32,12 @@ WECHAT_AUTHOR=AI 编辑部
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
View File

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

View File

@@ -48,5 +48,11 @@ class Settings(BaseSettings):
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()

View File

@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import hmac
import logging import logging
from urllib.parse import urlparse from urllib.parse import urlparse
from fastapi import FastAPI, File, Request, UploadFile from fastapi import FastAPI, File, Request, Response, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse 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
@@ -11,9 +12,19 @@ from fastapi.templating import Jinja2Templates
from app.config import settings from app.config import settings
from app.logging_setup import configure_logging from app.logging_setup import configure_logging
from app.middleware import RequestContextMiddleware from app.middleware import RequestContextMiddleware
from app.schemas import IMPublishRequest, RewriteRequest, WechatPublishRequest from app.schemas import (
AuthCredentialRequest,
ChangePasswordRequest,
ForgotPasswordResetRequest,
IMPublishRequest,
RewriteRequest,
WechatBindingRequest,
WechatPublishRequest,
WechatSwitchRequest,
)
from app.services.ai_rewriter import AIRewriter from app.services.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() configure_logging()
@@ -39,13 +50,52 @@ 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) @app.get("/favicon.ico", include_in_schema=False)
async def favicon(): async def favicon():
# 浏览器通常请求 /favicon.ico统一跳转到静态图标 # 浏览器通常请求 /favicon.ico统一跳转到静态图标
@@ -68,6 +118,174 @@ async def api_config():
} }
@app.get("/api/auth/me")
async def auth_me(request: Request):
user = _current_user(request)
if not user:
return {"ok": True, "logged_in": False}
binding = users.get_active_wechat_binding(user["id"])
bindings = users.list_wechat_bindings(user["id"])
return {
"ok": True,
"logged_in": True,
"user": {"id": user["id"], "username": user["username"]},
"wechat_bound": bool(binding and binding.get("appid") and binding.get("secret")),
"active_wechat_account": binding,
"wechat_accounts": bindings,
}
@app.post("/api/auth/register")
async def auth_register(req: AuthCredentialRequest, response: Response):
username = (req.username or "").strip()
password = req.password or ""
if len(username) < 2:
return {"ok": False, "detail": "用户名至少 2 个字符"}
if len(password) < 6:
return {"ok": False, "detail": "密码至少 6 个字符"}
try:
user = users.create_user(username, password)
except Exception as exc:
logger.exception("auth_register_failed username=%s detail=%s", username, str(exc))
return {"ok": False, "detail": "注册失败:账号库异常,请稍后重试"}
if not user:
return {"ok": False, "detail": "用户名已存在"}
ttl = _session_ttl(bool(req.remember_me))
token = users.create_session(user["id"], ttl_seconds=ttl)
response.set_cookie(
key=settings.auth_cookie_name,
value=token,
httponly=True,
samesite="lax",
max_age=ttl,
path="/",
)
return {"ok": True, "detail": "注册并登录成功", "user": user}
@app.post("/api/auth/login")
async def auth_login(req: AuthCredentialRequest, response: Response):
try:
user = users.verify_user((req.username or "").strip(), req.password or "")
except Exception as exc:
logger.exception("auth_login_failed username=%s detail=%s", (req.username or "").strip(), str(exc))
return {"ok": False, "detail": "登录失败:账号库异常,请稍后重试"}
if not user:
return {"ok": False, "detail": "用户名或密码错误"}
ttl = _session_ttl(bool(req.remember_me))
token = users.create_session(user["id"], ttl_seconds=ttl)
response.set_cookie(
key=settings.auth_cookie_name,
value=token,
httponly=True,
samesite="lax",
max_age=ttl,
path="/",
)
return {"ok": True, "detail": "登录成功", "user": user}
@app.post("/api/auth/logout")
async def auth_logout(request: Request, response: Response):
token = request.cookies.get(settings.auth_cookie_name, "")
if token:
users.delete_session(token)
response.delete_cookie(settings.auth_cookie_name, path="/")
return {"ok": True, "detail": "已退出登录"}
@app.get("/auth/forgot", response_class=HTMLResponse)
async def forgot_password_page(request: Request):
return templates.TemplateResponse("forgot_password.html", {"request": request, "app_name": settings.app_name})
@app.post("/api/auth/password/forgot")
async def auth_forgot_password_reset(req: ForgotPasswordResetRequest):
reset_key = (req.reset_key or "").strip()
expected_key = (settings.auth_password_reset_key or "x2ws-reset-2026").strip()
username = (req.username or "").strip()
new_password = req.new_password or ""
if not expected_key:
return {"ok": False, "detail": "系统未启用忘记密码重置功能,请联系管理员"}
if len(username) < 2:
return {"ok": False, "detail": "请输入正确的用户名"}
if len(new_password) < 6:
return {"ok": False, "detail": "新密码至少 6 个字符"}
if not hmac.compare_digest(reset_key, expected_key):
return {"ok": False, "detail": "重置码错误"}
ok = users.reset_password_by_username(username, new_password)
if not ok:
return {"ok": False, "detail": "用户不存在,无法重置"}
return {"ok": True, "detail": "密码重置成功,请返回登录页重新登录"}
@app.post("/api/auth/password/change")
async def auth_change_password(req: ChangePasswordRequest, request: Request, response: Response):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
old_password = req.old_password or ""
new_password = req.new_password or ""
if len(old_password) < 1:
return {"ok": False, "detail": "请输入当前密码"}
if len(new_password) < 6:
return {"ok": False, "detail": "新密码至少 6 个字符"}
if old_password == new_password:
return {"ok": False, "detail": "新密码不能与当前密码相同"}
ok = users.change_password(user["id"], old_password, new_password)
if not ok:
return {"ok": False, "detail": "当前密码错误,修改失败"}
users.delete_sessions_by_user(user["id"])
ttl = _session_ttl(False)
token = users.create_session(user["id"], ttl_seconds=ttl)
response.set_cookie(
key=settings.auth_cookie_name,
value=token,
httponly=True,
samesite="lax",
max_age=ttl,
path="/",
)
return {"ok": True, "detail": "密码修改成功,已刷新登录状态"}
@app.post("/api/auth/wechat/bind")
async def auth_wechat_bind(req: WechatBindingRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
appid = (req.appid or "").strip()
secret = (req.secret or "").strip()
if not appid or not secret:
return {"ok": False, "detail": "appid/secret 不能为空"}
created = users.add_wechat_binding(
user_id=user["id"],
account_name=(req.account_name or "").strip() or "公众号账号",
appid=appid,
secret=secret,
author=(req.author or "").strip(),
thumb_media_id=(req.thumb_media_id or "").strip(),
thumb_image_path=(req.thumb_image_path or "").strip(),
)
return {"ok": True, "detail": "公众号账号绑定成功", "account": created}
@app.post("/api/auth/wechat/switch")
async def auth_wechat_switch(req: WechatSwitchRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
ok = users.switch_active_wechat_binding(user["id"], int(req.account_id))
if not ok:
return {"ok": False, "detail": "切换失败:账号不存在或无权限"}
return {"ok": True, "detail": "已切换当前公众号账号"}
@app.post("/api/rewrite") @app.post("/api/rewrite")
async def rewrite(req: RewriteRequest, request: Request): async def rewrite(req: RewriteRequest, request: Request):
rid = getattr(request.state, "request_id", "") rid = getattr(request.state, "request_id", "")
@@ -99,6 +317,12 @@ async def rewrite(req: RewriteRequest, request: Request):
@app.post("/api/publish/wechat") @app.post("/api/publish/wechat")
async def publish_wechat(req: WechatPublishRequest, request: Request): async def publish_wechat(req: WechatPublishRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
binding = users.get_active_wechat_binding(user["id"])
if not binding:
return {"ok": False, "detail": "当前账号未绑定公众号 token请先在页面绑定"}
rid = getattr(request.state, "request_id", "") rid = getattr(request.state, "request_id", "")
logger.info( logger.info(
"api_wechat_in rid=%s title_chars=%d summary_chars=%d body_md_chars=%d author_set=%s", "api_wechat_in rid=%s title_chars=%d summary_chars=%d body_md_chars=%d author_set=%s",
@@ -108,7 +332,7 @@ async def publish_wechat(req: WechatPublishRequest, request: Request):
len(req.body_markdown or ""), len(req.body_markdown or ""),
bool((req.author or "").strip()), bool((req.author or "").strip()),
) )
out = await wechat.publish_draft(req, request_id=rid) out = await wechat.publish_draft(req, request_id=rid, account=binding)
wcode = (out.data or {}).get("errcode") if isinstance(out.data, dict) else None wcode = (out.data or {}).get("errcode") if isinstance(out.data, dict) else None
logger.info( logger.info(
"api_wechat_out rid=%s ok=%s wechat_errcode=%s detail_preview=%s", "api_wechat_out rid=%s ok=%s wechat_errcode=%s detail_preview=%s",
@@ -122,11 +346,17 @@ async def publish_wechat(req: WechatPublishRequest, request: Request):
@app.post("/api/wechat/cover/upload") @app.post("/api/wechat/cover/upload")
async def upload_wechat_cover(request: Request, file: UploadFile = File(...)): 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", "") rid = getattr(request.state, "request_id", "")
fn = file.filename or "cover.jpg" fn = file.filename or "cover.jpg"
content = await file.read() content = await file.read()
logger.info("api_wechat_cover_upload_in rid=%s filename=%s bytes=%d", rid, fn, len(content)) 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) out = await wechat.upload_cover(fn, content, request_id=rid, account=binding)
logger.info( logger.info(
"api_wechat_cover_upload_out rid=%s ok=%s detail=%s", "api_wechat_cover_upload_out rid=%s ok=%s detail=%s",
rid, rid,
@@ -136,6 +366,28 @@ async def upload_wechat_cover(request: Request, file: UploadFile = File(...)):
return out 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, request: Request): async def publish_im(req: IMPublishRequest, request: Request):
rid = getattr(request.state, "request_id", "") rid = getattr(request.state, "request_id", "")

View File

@@ -6,6 +6,7 @@ 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 = ""
@@ -41,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

View File

@@ -56,9 +56,8 @@ SYSTEM_PROMPT = """
3) 只输出合法 JSONtitle, summary, body_markdown 3) 只输出合法 JSONtitle, summary, body_markdown
4) **body_markdown 约束**:按内容密度使用 **4~6 个自然段**;段与段之间用一个空行分隔;**不要**使用 # / ## 标题符号;正文以 **约 500 字**为目标,优先完整表达并避免冗长重复; 4) **body_markdown 约束**:按内容密度使用 **4~6 个自然段**;段与段之间用一个空行分隔;**不要**使用 # / ## 标题符号;正文以 **约 500 字**为目标,优先完整表达并避免冗长重复;
5) title、summary 也要短:标题约 818 字;摘要约 4080 字; 5) title、summary 也要短:标题约 818 字;摘要约 4080 字;
6) 正文每段需首行缩进(建议段首使用两个全角空格「  」),避免顶格 6) 关键观点需要加粗:请用 Markdown `**加粗**` 标出 2~4 个重点短语
7) 关键观点需要加粗:请用 Markdown `**加粗**` 标出 2~4 个重点短语; 7) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "
8) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "
""".strip() """.strip()
@@ -74,7 +73,6 @@ body_markdown 写法:
- 使用 **4~6 段**:每段若干完整句子,段之间 **\\n\\n**(空一行); - 使用 **4~6 段**:每段若干完整句子,段之间 **\\n\\n**(空一行);
- **禁止** markdown 标题(不要用 # - **禁止** markdown 标题(不要用 #
- 正文目标约 **500 字**(可上下浮动),以信息完整为先,避免冗长和重复; - 正文目标约 **500 字**(可上下浮动),以信息完整为先,避免冗长和重复;
- 每段段首请保留首行缩进(两个全角空格「  」);
- 请用 `**...**` 加粗 2~4 个关键观点词; - 请用 `**...**` 加粗 2~4 个关键观点词;
- 内容顺序建议:首段交代在说什么;中间段展开关键信息;末段收束或提醒(均须紧扣原文,勿乱发挥)。 - 内容顺序建议:首段交代在说什么;中间段展开关键信息;末段收束或提醒(均须紧扣原文,勿乱发挥)。
""".strip() """.strip()
@@ -319,6 +317,7 @@ class AIRewriter:
用户改写偏好: 用户改写偏好:
- 标题参考:{req.title_hint or '自动生成'} - 标题参考:{req.title_hint or '自动生成'}
- 写作风格:{req.writing_style}
- 语气风格:{req.tone} - 语气风格:{req.tone}
- 目标读者:{req.audience} - 目标读者:{req.audience}
- 必须保留观点:{req.keep_points or ''} - 必须保留观点:{req.keep_points or ''}
@@ -335,6 +334,7 @@ class AIRewriter:
用户改写偏好: 用户改写偏好:
- 标题参考:{req.title_hint or '自动生成'} - 标题参考:{req.title_hint or '自动生成'}
- 写作风格:{req.writing_style}
- 语气风格:{req.tone} - 语气风格:{req.tone}
- 目标读者:{req.audience} - 目标读者:{req.audience}
- 必须保留观点:{req.keep_points or ''} - 必须保留观点:{req.keep_points or ''}
@@ -827,12 +827,6 @@ class AIRewriter:
if "**" not in body: if "**" not in body:
issues.append("关键观点未加粗(建议 2~4 处)") issues.append("关键观点未加粗(建议 2~4 处)")
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()]
if paragraphs:
no_indent = sum(1 for p in paragraphs if not p.startswith("  "))
if no_indent >= max(2, len(paragraphs) // 2):
issues.append("正文缺少首行缩进(建议每段段首使用两个全角空格)")
if self._looks_like_raw_copy(source, body, lenient=lenient): if self._looks_like_raw_copy(source, body, lenient=lenient):
issues.append("改写与原文相似度过高,疑似未充分重写") issues.append("改写与原文相似度过高,疑似未充分重写")

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

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

View File

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

View File

@@ -16,11 +16,45 @@ const rewriteBtn = $("rewriteBtn");
const wechatBtn = $("wechatBtn"); const wechatBtn = $("wechatBtn");
const imBtn = $("imBtn"); const imBtn = $("imBtn");
const coverUploadBtn = $("coverUploadBtn"); const coverUploadBtn = $("coverUploadBtn");
const logoutBtn = $("logoutBtn");
function countText(v) { function countText(v) {
return (v || "").trim().length; 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() { function updateCounters() {
$("sourceCount").textContent = `${countText($("sourceText").value)}`; $("sourceCount").textContent = `${countText($("sourceText").value)}`;
$("summaryCount").textContent = `${countText($("summary").value)}`; $("summaryCount").textContent = `${countText($("summary").value)}`;
@@ -51,6 +85,104 @@ async function postJSON(url, body) {
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) {
@@ -61,11 +193,13 @@ $("rewriteBtn").addEventListener("click", async () => {
setStatus("正在改写..."); setStatus("正在改写...");
setLoading(rewriteBtn, true, "改写并排版", "改写中..."); 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,
}); });
@@ -104,6 +238,11 @@ $("wechatBtn").addEventListener("click", async () => {
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 { } finally {
setLoading(wechatBtn, false, "发布到公众号草稿箱", "发布中..."); setLoading(wechatBtn, false, "发布到公众号草稿箱", "发布中...");
} }
@@ -133,6 +272,11 @@ if (coverUploadBtn) {
} catch (e) { } catch (e) {
if (hint) hint.textContent = "封面上传失败,请看状态提示。"; if (hint) hint.textContent = "封面上传失败,请看状态提示。";
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 { } finally {
setLoading(coverUploadBtn, false, "上传封面并绑定", "上传中..."); setLoading(coverUploadBtn, false, "上传封面并绑定", "上传中...");
} }
@@ -161,3 +305,5 @@ $("imBtn").addEventListener("click", async () => {
}); });
updateCounters(); updateCounters();
initMultiDropdowns();
initWechatAccountSwitch();

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

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

View File

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

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

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

View File

@@ -22,6 +22,12 @@ body {
overflow: hidden; overflow: hidden;
} }
body.simple-page {
height: auto;
min-height: 100vh;
overflow: auto;
}
.topbar { .topbar {
max-width: 1240px; max-width: 1240px;
height: 72px; height: 72px;
@@ -52,6 +58,120 @@ body {
border-radius: 999px; 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: 1240px; max-width: 1240px;
height: calc(100vh - 72px); height: calc(100vh - 72px);
@@ -106,12 +226,114 @@ label {
align-items: baseline; 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 { .meta {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
} }
input, input,
select,
textarea, textarea,
button { button {
width: 100%; width: 100%;
@@ -128,6 +350,7 @@ textarea {
} }
input:focus, input:focus,
select:focus,
textarea:focus { textarea:focus {
outline: none; outline: none;
border-color: #93c5fd; border-color: #93c5fd;
@@ -289,4 +512,15 @@ button:disabled {
gap: 8px; gap: 8px;
flex-direction: column; flex-direction: column;
} }
.topbar-actions {
width: 100%;
justify-content: space-between;
}
.actions-inline {
justify-content: flex-start;
align-items: center;
margin-top: 8px;
}
} }

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

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

View File

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

View File

@@ -5,7 +5,7 @@
<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="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260406a" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260406a" />
<link rel="stylesheet" href="/static/style.css?v=20260406a" /> <link rel="stylesheet" href="/static/style.css?v=20260410d" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -13,7 +13,15 @@
<h1>{{ app_name }}</h1> <h1>{{ app_name }}</h1>
<p class="muted">从原文到公众号草稿,一页完成编辑、封面和发布。</p> <p class="muted">从原文到公众号草稿,一页完成编辑、封面和发布。</p>
</div> </div>
<div class="badge">编辑台</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> </header>
<main class="layout"> <main class="layout">
@@ -33,16 +41,49 @@
<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>
@@ -102,6 +143,6 @@
</main> </main>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="/static/app.js?v=20260406a"></script> <script src="/static/app.js?v=20260410d"></script>
</body> </body>
</html> </html>

View File

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

1
data/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

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

View File

@@ -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
```