fix: 更新当前界面,支持多公帐号切换
This commit is contained in:
@@ -32,3 +32,12 @@ WECHAT_AUTHOR=AI 编辑部
|
||||
IM_WEBHOOK_URL=
|
||||
# 若 webhook 需要签名可填
|
||||
IM_SECRET=
|
||||
|
||||
# 账号数据 SQLite 文件(建议放在容器挂载目录,如 /app/data/app.db)
|
||||
AUTH_DB_PATH=./data/app.db
|
||||
# 普通登录有效期(秒),默认 1 天
|
||||
AUTH_SESSION_TTL_SEC=86400
|
||||
# 勾选“限时免登”后的有效期(秒),默认 7 天
|
||||
AUTH_REMEMBER_SESSION_TTL_SEC=604800
|
||||
# 忘记密码重置码(建议自定义;为空时将使用默认值 x2ws-reset-2026)
|
||||
AUTH_PASSWORD_RESET_KEY=x2ws-reset-2026
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
@@ -48,5 +48,11 @@ class Settings(BaseSettings):
|
||||
im_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL")
|
||||
im_secret: str | None = Field(default=None, alias="IM_SECRET")
|
||||
|
||||
auth_db_path: str = Field(default="./data/app.db", alias="AUTH_DB_PATH")
|
||||
auth_cookie_name: str = Field(default="x2ws_session", alias="AUTH_COOKIE_NAME")
|
||||
auth_session_ttl_sec: int = Field(default=86400, alias="AUTH_SESSION_TTL_SEC")
|
||||
auth_remember_session_ttl_sec: int = Field(default=604800, alias="AUTH_REMEMBER_SESSION_TTL_SEC")
|
||||
auth_password_reset_key: str | None = Field(default="x2ws-reset-2026", alias="AUTH_PASSWORD_RESET_KEY")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
260
app/main.py
260
app/main.py
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
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.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -11,9 +12,19 @@ from fastapi.templating import Jinja2Templates
|
||||
from app.config import settings
|
||||
from app.logging_setup import configure_logging
|
||||
from app.middleware import RequestContextMiddleware
|
||||
from app.schemas import IMPublishRequest, RewriteRequest, WechatPublishRequest
|
||||
from app.schemas import (
|
||||
AuthCredentialRequest,
|
||||
ChangePasswordRequest,
|
||||
ForgotPasswordResetRequest,
|
||||
IMPublishRequest,
|
||||
RewriteRequest,
|
||||
WechatBindingRequest,
|
||||
WechatPublishRequest,
|
||||
WechatSwitchRequest,
|
||||
)
|
||||
from app.services.ai_rewriter import AIRewriter
|
||||
from app.services.im import IMPublisher
|
||||
from app.services.user_store import UserStore
|
||||
from app.services.wechat import WechatPublisher
|
||||
|
||||
configure_logging()
|
||||
@@ -39,13 +50,52 @@ templates = Jinja2Templates(directory="app/templates")
|
||||
rewriter = AIRewriter()
|
||||
wechat = WechatPublisher()
|
||||
im = IMPublisher()
|
||||
users = UserStore(settings.auth_db_path)
|
||||
|
||||
|
||||
def _session_ttl(remember_me: bool) -> int:
|
||||
normal = max(600, int(settings.auth_session_ttl_sec))
|
||||
remembered = max(normal, int(settings.auth_remember_session_ttl_sec))
|
||||
return remembered if remember_me else normal
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict | None:
|
||||
token = request.cookies.get(settings.auth_cookie_name, "")
|
||||
return users.get_user_by_session(token) if token else None
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict | None:
|
||||
u = _current_user(request)
|
||||
if not u:
|
||||
return None
|
||||
return u
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
if not _current_user(request):
|
||||
return RedirectResponse(url="/auth?next=/", status_code=302)
|
||||
return templates.TemplateResponse("index.html", {"request": request, "app_name": settings.app_name})
|
||||
|
||||
|
||||
@app.get("/auth", response_class=HTMLResponse)
|
||||
async def auth_page(request: Request):
|
||||
nxt = (request.query_params.get("next") or "/").strip() or "/"
|
||||
if _current_user(request):
|
||||
return RedirectResponse(url=nxt, status_code=302)
|
||||
return templates.TemplateResponse(
|
||||
"auth.html",
|
||||
{"request": request, "app_name": settings.app_name, "next": nxt},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
if not _current_user(request):
|
||||
return RedirectResponse(url="/auth?next=/settings", status_code=302)
|
||||
return templates.TemplateResponse("settings.html", {"request": request, "app_name": settings.app_name})
|
||||
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon():
|
||||
# 浏览器通常请求 /favicon.ico,统一跳转到静态图标
|
||||
@@ -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")
|
||||
async def rewrite(req: RewriteRequest, request: Request):
|
||||
rid = getattr(request.state, "request_id", "")
|
||||
@@ -99,6 +317,12 @@ async def rewrite(req: RewriteRequest, request: Request):
|
||||
|
||||
@app.post("/api/publish/wechat")
|
||||
async def publish_wechat(req: WechatPublishRequest, request: Request):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
binding = users.get_active_wechat_binding(user["id"])
|
||||
if not binding:
|
||||
return {"ok": False, "detail": "当前账号未绑定公众号 token,请先在页面绑定"}
|
||||
rid = getattr(request.state, "request_id", "")
|
||||
logger.info(
|
||||
"api_wechat_in rid=%s title_chars=%d summary_chars=%d body_md_chars=%d author_set=%s",
|
||||
@@ -108,7 +332,7 @@ async def publish_wechat(req: WechatPublishRequest, request: Request):
|
||||
len(req.body_markdown or ""),
|
||||
bool((req.author or "").strip()),
|
||||
)
|
||||
out = await wechat.publish_draft(req, request_id=rid)
|
||||
out = await wechat.publish_draft(req, request_id=rid, account=binding)
|
||||
wcode = (out.data or {}).get("errcode") if isinstance(out.data, dict) else None
|
||||
logger.info(
|
||||
"api_wechat_out rid=%s ok=%s wechat_errcode=%s detail_preview=%s",
|
||||
@@ -122,11 +346,17 @@ async def publish_wechat(req: WechatPublishRequest, request: Request):
|
||||
|
||||
@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)
|
||||
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,
|
||||
@@ -136,6 +366,28 @@ async def upload_wechat_cover(request: Request, file: UploadFile = File(...)):
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/wechat/material/upload")
|
||||
async def upload_wechat_material(request: Request, file: UploadFile = File(...)):
|
||||
user = _require_user(request)
|
||||
if not user:
|
||||
return {"ok": False, "detail": "请先登录"}
|
||||
binding = users.get_active_wechat_binding(user["id"])
|
||||
if not binding:
|
||||
return {"ok": False, "detail": "当前账号未绑定公众号 token,请先在页面绑定"}
|
||||
rid = getattr(request.state, "request_id", "")
|
||||
fn = file.filename or "material.jpg"
|
||||
content = await file.read()
|
||||
logger.info("api_wechat_material_upload_in rid=%s filename=%s bytes=%d", rid, fn, len(content))
|
||||
out = await wechat.upload_body_material(fn, content, request_id=rid, account=binding)
|
||||
logger.info(
|
||||
"api_wechat_material_upload_out rid=%s ok=%s detail=%s",
|
||||
rid,
|
||||
out.ok,
|
||||
(out.detail or "")[:160],
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/publish/im")
|
||||
async def publish_im(req: IMPublishRequest, request: Request):
|
||||
rid = getattr(request.state, "request_id", "")
|
||||
|
||||
@@ -6,6 +6,7 @@ from pydantic import BaseModel, Field
|
||||
class RewriteRequest(BaseModel):
|
||||
source_text: str = Field(..., min_length=20)
|
||||
title_hint: str = ""
|
||||
writing_style: str = "科普解读"
|
||||
tone: str = "专业、可信、可读性强"
|
||||
audience: str = "公众号读者"
|
||||
keep_points: str = ""
|
||||
@@ -41,3 +42,33 @@ class PublishResponse(BaseModel):
|
||||
ok: bool
|
||||
detail: str
|
||||
data: dict | None = None
|
||||
|
||||
|
||||
class AuthCredentialRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
remember_me: bool = False
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class ForgotPasswordResetRequest(BaseModel):
|
||||
username: str
|
||||
reset_key: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class WechatBindingRequest(BaseModel):
|
||||
account_name: str = ""
|
||||
appid: str
|
||||
secret: str
|
||||
author: str = ""
|
||||
thumb_media_id: str = ""
|
||||
thumb_image_path: str = ""
|
||||
|
||||
|
||||
class WechatSwitchRequest(BaseModel):
|
||||
account_id: int
|
||||
|
||||
@@ -56,9 +56,8 @@ SYSTEM_PROMPT = """
|
||||
3) 只输出合法 JSON:title, summary, body_markdown;
|
||||
4) **body_markdown 约束**:按内容密度使用 **4~6 个自然段**;段与段之间用一个空行分隔;**不要**使用 # / ## 标题符号;正文以 **约 500 字**为目标,优先完整表达并避免冗长重复;
|
||||
5) title、summary 也要短:标题约 8~18 字;摘要约 40~80 字;
|
||||
6) 正文每段需首行缩进(建议段首使用两个全角空格「 」),避免顶格;
|
||||
7) 关键观点需要加粗:请用 Markdown `**加粗**` 标出 2~4 个重点短语;
|
||||
8) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "。
|
||||
6) 关键观点需要加粗:请用 Markdown `**加粗**` 标出 2~4 个重点短语;
|
||||
7) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "。
|
||||
""".strip()
|
||||
|
||||
|
||||
@@ -74,7 +73,6 @@ body_markdown 写法:
|
||||
- 使用 **4~6 段**:每段若干完整句子,段之间 **\\n\\n**(空一行);
|
||||
- **禁止** markdown 标题(不要用 #);
|
||||
- 正文目标约 **500 字**(可上下浮动),以信息完整为先,避免冗长和重复;
|
||||
- 每段段首请保留首行缩进(两个全角空格「 」);
|
||||
- 请用 `**...**` 加粗 2~4 个关键观点词;
|
||||
- 内容顺序建议:首段交代在说什么;中间段展开关键信息;末段收束或提醒(均须紧扣原文,勿乱发挥)。
|
||||
""".strip()
|
||||
@@ -319,6 +317,7 @@ class AIRewriter:
|
||||
|
||||
用户改写偏好:
|
||||
- 标题参考:{req.title_hint or '自动生成'}
|
||||
- 写作风格:{req.writing_style}
|
||||
- 语气风格:{req.tone}
|
||||
- 目标读者:{req.audience}
|
||||
- 必须保留观点:{req.keep_points or '无'}
|
||||
@@ -335,6 +334,7 @@ class AIRewriter:
|
||||
|
||||
用户改写偏好:
|
||||
- 标题参考:{req.title_hint or '自动生成'}
|
||||
- 写作风格:{req.writing_style}
|
||||
- 语气风格:{req.tone}
|
||||
- 目标读者:{req.audience}
|
||||
- 必须保留观点:{req.keep_points or '无'}
|
||||
@@ -827,12 +827,6 @@ class AIRewriter:
|
||||
if "**" not in body:
|
||||
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):
|
||||
issues.append("改写与原文相似度过高,疑似未充分重写")
|
||||
|
||||
|
||||
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),
|
||||
}
|
||||
@@ -64,17 +64,34 @@ def _detail_for_draft_error(data: dict) -> str:
|
||||
|
||||
class WechatPublisher:
|
||||
def __init__(self) -> None:
|
||||
self._access_token = None
|
||||
self._expires_at = 0
|
||||
self._token_cache: dict[str, dict[str, int | str]] = {}
|
||||
self._runtime_thumb_media_id: str | None = None
|
||||
|
||||
async def publish_draft(self, req: WechatPublishRequest, request_id: str = "") -> PublishResponse:
|
||||
def _resolve_account(self, account: dict | None = None) -> dict[str, str]:
|
||||
src = account or {}
|
||||
appid = (src.get("appid") or settings.wechat_appid or "").strip()
|
||||
secret = (src.get("secret") or settings.wechat_secret or "").strip()
|
||||
author = (src.get("author") or settings.wechat_author or "").strip()
|
||||
thumb_media_id = (src.get("thumb_media_id") or settings.wechat_thumb_media_id or "").strip()
|
||||
thumb_image_path = (src.get("thumb_image_path") or settings.wechat_thumb_image_path or "").strip()
|
||||
return {
|
||||
"appid": appid,
|
||||
"secret": secret,
|
||||
"author": author,
|
||||
"thumb_media_id": thumb_media_id,
|
||||
"thumb_image_path": thumb_image_path,
|
||||
}
|
||||
|
||||
async def publish_draft(
|
||||
self, req: WechatPublishRequest, request_id: str = "", account: dict | None = None
|
||||
) -> PublishResponse:
|
||||
rid = request_id or "-"
|
||||
if not settings.wechat_appid or not settings.wechat_secret:
|
||||
acct = self._resolve_account(account)
|
||||
if not acct["appid"] or not acct["secret"]:
|
||||
logger.warning("wechat skipped rid=%s reason=missing_appid_or_secret", rid)
|
||||
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
|
||||
|
||||
token, token_from_cache, token_err_body = await self._get_access_token()
|
||||
token, token_from_cache, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
|
||||
if not token:
|
||||
detail = _detail_for_token_error(token_err_body)
|
||||
logger.error("wechat access_token_unavailable rid=%s detail=%s", rid, detail[:200])
|
||||
@@ -91,7 +108,7 @@ class WechatPublisher:
|
||||
logger.info("wechat_thumb rid=%s source=request_media_id", rid)
|
||||
thumb_id = req_thumb
|
||||
else:
|
||||
thumb_id = await self._resolve_thumb_media_id(token, rid)
|
||||
thumb_id = await self._resolve_thumb_media_id(token, rid, account=acct)
|
||||
if not thumb_id:
|
||||
return PublishResponse(
|
||||
ok=False,
|
||||
@@ -115,7 +132,7 @@ class WechatPublisher:
|
||||
{
|
||||
"article_type": "news",
|
||||
"title": req.title[:32] if len(req.title) > 32 else req.title,
|
||||
"author": (req.author or settings.wechat_author)[:16],
|
||||
"author": (req.author or acct["author"] or settings.wechat_author)[:16],
|
||||
"digest": (req.summary or "")[:128],
|
||||
"content": html,
|
||||
"content_source_url": "",
|
||||
@@ -126,7 +143,7 @@ class WechatPublisher:
|
||||
]
|
||||
}
|
||||
|
||||
explicit_used = bool((settings.wechat_thumb_media_id or "").strip())
|
||||
explicit_used = bool((acct.get("thumb_media_id") or "").strip())
|
||||
draft_url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
|
||||
async with httpx.AsyncClient(timeout=25) as client:
|
||||
logger.info(
|
||||
@@ -142,7 +159,9 @@ class WechatPublisher:
|
||||
rid,
|
||||
)
|
||||
self._runtime_thumb_media_id = None
|
||||
thumb_alt = await self._resolve_thumb_media_id(token, rid, force_skip_explicit=True)
|
||||
thumb_alt = await self._resolve_thumb_media_id(
|
||||
token, rid, force_skip_explicit=True, account=acct
|
||||
)
|
||||
if thumb_alt:
|
||||
payload["articles"][0]["thumb_media_id"] = thumb_alt
|
||||
async with httpx.AsyncClient(timeout=25) as client:
|
||||
@@ -167,33 +186,69 @@ class WechatPublisher:
|
||||
)
|
||||
return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data)
|
||||
|
||||
async def upload_cover(self, filename: str, content: bytes, request_id: str = "") -> PublishResponse:
|
||||
async def upload_cover(
|
||||
self, filename: str, content: bytes, request_id: str = "", account: dict | None = None
|
||||
) -> PublishResponse:
|
||||
"""上传封面到微信永久素材,返回 thumb_media_id。"""
|
||||
rid = request_id or "-"
|
||||
if not settings.wechat_appid or not settings.wechat_secret:
|
||||
acct = self._resolve_account(account)
|
||||
if not acct["appid"] or not acct["secret"]:
|
||||
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
|
||||
if not content:
|
||||
return PublishResponse(ok=False, detail="封面文件为空")
|
||||
|
||||
token, _, token_err_body = await self._get_access_token()
|
||||
token, _, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
|
||||
if not token:
|
||||
return PublishResponse(ok=False, detail=_detail_for_token_error(token_err_body), data=token_err_body)
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
mid = await self._upload_permanent_image(client, token, content, filename)
|
||||
if not mid:
|
||||
material = await self._upload_permanent_image(client, token, content, filename)
|
||||
if not material:
|
||||
return PublishResponse(
|
||||
ok=False,
|
||||
detail="封面上传失败:请检查图片格式/大小,或查看日志中的 wechat_material_add_failed",
|
||||
)
|
||||
|
||||
mid = material["media_id"]
|
||||
self._runtime_thumb_media_id = mid
|
||||
logger.info("wechat_cover_upload_ok rid=%s filename=%s media_id=%s", rid, filename, mid)
|
||||
return PublishResponse(ok=True, detail="封面上传成功", data={"thumb_media_id": mid, "filename": filename})
|
||||
|
||||
async def upload_body_material(
|
||||
self, filename: str, content: bytes, request_id: str = "", account: dict | None = None
|
||||
) -> PublishResponse:
|
||||
"""上传正文图片到微信永久素材库,返回 media_id 与可插入正文的 URL。"""
|
||||
rid = request_id or "-"
|
||||
acct = self._resolve_account(account)
|
||||
if not acct["appid"] or not acct["secret"]:
|
||||
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
|
||||
if not content:
|
||||
return PublishResponse(ok=False, detail="素材文件为空")
|
||||
|
||||
token, _, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
|
||||
if not token:
|
||||
return PublishResponse(ok=False, detail=_detail_for_token_error(token_err_body), data=token_err_body)
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
material = await self._upload_permanent_image(client, token, content, filename)
|
||||
if not material:
|
||||
return PublishResponse(
|
||||
ok=False,
|
||||
detail="素材上传失败:请检查图片格式/大小,或查看日志中的 wechat_material_add_failed",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"wechat_body_material_upload_ok rid=%s filename=%s media_id=%s url=%s",
|
||||
rid,
|
||||
filename,
|
||||
material.get("media_id"),
|
||||
material.get("url"),
|
||||
)
|
||||
return PublishResponse(ok=True, detail="素材上传成功", data=material)
|
||||
|
||||
async def _upload_permanent_image(
|
||||
self, client: httpx.AsyncClient, token: str, content: bytes, filename: str
|
||||
) -> str | None:
|
||||
) -> dict[str, str] | None:
|
||||
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image"
|
||||
ctype = "image/png" if filename.lower().endswith(".png") else "image/jpeg"
|
||||
files = {"media": (filename, content, ctype)}
|
||||
@@ -206,11 +261,14 @@ class WechatPublisher:
|
||||
if not mid:
|
||||
logger.warning("wechat_material_add_no_media_id body=%s", data)
|
||||
return None
|
||||
return mid
|
||||
return {"media_id": mid, "url": data.get("url") or ""}
|
||||
|
||||
async def _resolve_thumb_media_id(self, token: str, rid: str, *, force_skip_explicit: bool = False) -> str | None:
|
||||
async def _resolve_thumb_media_id(
|
||||
self, token: str, rid: str, *, force_skip_explicit: bool = False, account: dict | None = None
|
||||
) -> str | None:
|
||||
"""draft/add 要求 thumb_media_id 为永久图片素材;优先用配置,否则上传文件或内置图。"""
|
||||
explicit = (settings.wechat_thumb_media_id or "").strip()
|
||||
acct = self._resolve_account(account)
|
||||
explicit = (acct.get("thumb_media_id") or "").strip()
|
||||
if explicit and not force_skip_explicit:
|
||||
logger.info("wechat_thumb rid=%s source=config_media_id", rid)
|
||||
return explicit
|
||||
@@ -219,35 +277,40 @@ class WechatPublisher:
|
||||
logger.info("wechat_thumb rid=%s source=runtime_cache", rid)
|
||||
return self._runtime_thumb_media_id
|
||||
|
||||
path = (settings.wechat_thumb_image_path or "").strip()
|
||||
path = (acct.get("thumb_image_path") or "").strip()
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
if path:
|
||||
p = Path(path)
|
||||
if p.is_file():
|
||||
content = p.read_bytes()
|
||||
mid = await self._upload_permanent_image(client, token, content, p.name)
|
||||
if mid:
|
||||
self._runtime_thumb_media_id = mid
|
||||
material = await self._upload_permanent_image(client, token, content, p.name)
|
||||
if material:
|
||||
self._runtime_thumb_media_id = material["media_id"]
|
||||
logger.info("wechat_thumb rid=%s source=path_upload ok=1", rid)
|
||||
return mid
|
||||
return material["media_id"]
|
||||
logger.warning("wechat_thumb rid=%s source=path_upload ok=0 path=%s", rid, path)
|
||||
else:
|
||||
logger.warning("wechat_thumb rid=%s path_not_found=%s", rid, path)
|
||||
|
||||
content, fname = _build_default_cover_jpeg()
|
||||
mid = await self._upload_permanent_image(client, token, content, fname)
|
||||
if mid:
|
||||
self._runtime_thumb_media_id = mid
|
||||
material = await self._upload_permanent_image(client, token, content, fname)
|
||||
if material:
|
||||
self._runtime_thumb_media_id = material["media_id"]
|
||||
logger.info("wechat_thumb rid=%s source=default_jpeg_upload ok=1", rid)
|
||||
return mid
|
||||
return material["media_id"]
|
||||
logger.error("wechat_thumb rid=%s source=default_jpeg_upload ok=0", rid)
|
||||
return None
|
||||
|
||||
async def _get_access_token(self) -> tuple[str | None, bool, dict | None]:
|
||||
async def _get_access_token(self, appid: str, secret: str) -> tuple[str | None, bool, dict | None]:
|
||||
"""成功时第三项为 None;失败时为微信返回的 JSON(含 errcode/errmsg)。"""
|
||||
now = int(time.time())
|
||||
if self._access_token and now < self._expires_at - 60:
|
||||
return self._access_token, True, None
|
||||
key = appid.strip()
|
||||
cached = self._token_cache.get(key)
|
||||
if cached:
|
||||
token = str(cached.get("token") or "")
|
||||
exp = int(cached.get("expires_at") or 0)
|
||||
if token and now < exp - 60:
|
||||
return token, True, None
|
||||
|
||||
logger.info("wechat_http_get endpoint=cgi-bin/token reason=refresh_access_token")
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
@@ -255,8 +318,8 @@ class WechatPublisher:
|
||||
"https://api.weixin.qq.com/cgi-bin/token",
|
||||
params={
|
||||
"grant_type": "client_credential",
|
||||
"appid": settings.wechat_appid,
|
||||
"secret": settings.wechat_secret,
|
||||
"appid": appid,
|
||||
"secret": secret,
|
||||
},
|
||||
)
|
||||
data = r.json() if r.content else {}
|
||||
@@ -270,6 +333,5 @@ class WechatPublisher:
|
||||
)
|
||||
return None, False, data if isinstance(data, dict) else None
|
||||
|
||||
self._access_token = token
|
||||
self._expires_at = now + int(data.get("expires_in", 7200))
|
||||
self._token_cache[key] = {"token": token, "expires_at": now + int(data.get("expires_in", 7200))}
|
||||
return token, False, None
|
||||
|
||||
@@ -16,11 +16,45 @@ 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)} 字`;
|
||||
@@ -51,6 +85,104 @@ async function postJSON(url, body) {
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchAuthMe() {
|
||||
const res = await fetch("/api/auth/me");
|
||||
const data = await res.json();
|
||||
if (!data.ok || !data.logged_in) {
|
||||
window.location.href = "/auth?next=/";
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderWechatAccountSelect(me) {
|
||||
const sel = $("wechatAccountSelect");
|
||||
const hint = $("wechatAccountStatus");
|
||||
if (!sel) return;
|
||||
const list = Array.isArray(me.wechat_accounts) ? me.wechat_accounts : [];
|
||||
const activeId =
|
||||
me.active_wechat_account && me.active_wechat_account.id
|
||||
? Number(me.active_wechat_account.id)
|
||||
: 0;
|
||||
sel.innerHTML = "";
|
||||
if (!list.length) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = "暂无公众号";
|
||||
sel.appendChild(opt);
|
||||
sel.disabled = true;
|
||||
if (hint) hint.textContent = "请先在「公众号设置」绑定";
|
||||
return;
|
||||
}
|
||||
sel.disabled = false;
|
||||
list.forEach((a) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(a.id);
|
||||
const name = (a.account_name || "未命名").trim();
|
||||
const appid = (a.appid || "").trim();
|
||||
opt.textContent = appid ? `${name} (${appid})` : name;
|
||||
if ((activeId && Number(a.id) === activeId) || a.active) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function flashWechatAccountHint(msg, clearMs = 2600) {
|
||||
const hint = $("wechatAccountStatus");
|
||||
if (!hint) return;
|
||||
hint.textContent = msg;
|
||||
if (clearMs > 0) {
|
||||
window.clearTimeout(flashWechatAccountHint._t);
|
||||
flashWechatAccountHint._t = window.setTimeout(() => {
|
||||
hint.textContent = "";
|
||||
}, clearMs);
|
||||
}
|
||||
}
|
||||
|
||||
const wechatAccountSelect = $("wechatAccountSelect");
|
||||
if (wechatAccountSelect) {
|
||||
wechatAccountSelect.addEventListener("change", async () => {
|
||||
const id = Number(wechatAccountSelect.value || 0);
|
||||
if (!id) return;
|
||||
try {
|
||||
const out = await postJSON("/api/auth/wechat/switch", { account_id: id });
|
||||
if (!out.ok) {
|
||||
flashWechatAccountHint(out.detail || "切换失败", 4000);
|
||||
const me = await fetchAuthMe();
|
||||
if (me) renderWechatAccountSelect(me);
|
||||
return;
|
||||
}
|
||||
const me = await fetchAuthMe();
|
||||
if (me) renderWechatAccountSelect(me);
|
||||
flashWechatAccountHint("已切换");
|
||||
} catch (e) {
|
||||
flashWechatAccountHint(e.message || "切换失败", 4000);
|
||||
const me = await fetchAuthMe();
|
||||
if (me) renderWechatAccountSelect(me);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initWechatAccountSwitch() {
|
||||
const me = await fetchAuthMe();
|
||||
if (me) renderWechatAccountSelect(me);
|
||||
}
|
||||
|
||||
async function logoutAndGoAuth() {
|
||||
try {
|
||||
await postJSON("/api/auth/logout", {});
|
||||
} catch {
|
||||
// 忽略退出接口异常,直接跳转认证页
|
||||
}
|
||||
window.location.href = "/auth?next=/";
|
||||
}
|
||||
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
setLoading(logoutBtn, true, "退出登录", "退出中...");
|
||||
await logoutAndGoAuth();
|
||||
});
|
||||
}
|
||||
|
||||
$("rewriteBtn").addEventListener("click", async () => {
|
||||
const sourceText = $("sourceText").value.trim();
|
||||
if (sourceText.length < 20) {
|
||||
@@ -61,11 +193,13 @@ $("rewriteBtn").addEventListener("click", async () => {
|
||||
setStatus("正在改写...");
|
||||
setLoading(rewriteBtn, true, "改写并排版", "改写中...");
|
||||
try {
|
||||
const audience = buildMultiPrompt("audienceChip", "audienceExtra") || "公众号读者";
|
||||
const tone = buildMultiPrompt("toneChip", "toneExtra") || "专业、可信、可读性强";
|
||||
const data = await postJSON("/api/rewrite", {
|
||||
source_text: sourceText,
|
||||
title_hint: $("titleHint").value,
|
||||
tone: $("tone").value,
|
||||
audience: $("audience").value,
|
||||
tone,
|
||||
audience,
|
||||
keep_points: $("keepPoints").value,
|
||||
avoid_words: $("avoidWords").value,
|
||||
});
|
||||
@@ -104,6 +238,11 @@ $("wechatBtn").addEventListener("click", async () => {
|
||||
setStatus("公众号草稿发布成功");
|
||||
} catch (e) {
|
||||
setStatus(`公众号发布失败: ${e.message}`, true);
|
||||
if ((e.message || "").includes("请先登录")) {
|
||||
window.location.href = "/auth?next=/";
|
||||
} else if ((e.message || "").includes("未绑定公众号")) {
|
||||
window.location.href = "/settings";
|
||||
}
|
||||
} finally {
|
||||
setLoading(wechatBtn, false, "发布到公众号草稿箱", "发布中...");
|
||||
}
|
||||
@@ -133,6 +272,11 @@ if (coverUploadBtn) {
|
||||
} 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, "上传封面并绑定", "上传中...");
|
||||
}
|
||||
@@ -161,3 +305,5 @@ $("imBtn").addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
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, "注册", "注册中...", "注册成功,正在跳转...");
|
||||
});
|
||||
}
|
||||
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();
|
||||
@@ -22,6 +22,12 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.simple-page {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
max-width: 1240px;
|
||||
height: 72px;
|
||||
@@ -52,6 +58,120 @@ body {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.wechat-account-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
max-width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.wechat-account-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-select {
|
||||
min-width: 160px;
|
||||
max-width: 260px;
|
||||
flex: 1 1 auto;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topbar-select:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wechat-account-status {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-btn {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtle-link {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
color: var(--accent-2);
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.simple-wrap {
|
||||
max-width: 760px;
|
||||
margin: 48px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.simple-panel {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 16px 0 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.actions-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.check-label {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.check-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1240px;
|
||||
height: calc(100vh - 72px);
|
||||
@@ -106,12 +226,114 @@ label {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.multi-field .field-head label {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.multi-field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.multi-dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.multi-dropdown > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.multi-dropdown > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multi-dropdown > summary::after {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid var(--muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.multi-dropdown[open] > summary::after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.multi-dropdown[open] > summary {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.multi-dropdown-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.multi-dropdown-body {
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 6px 4px;
|
||||
background: var(--panel);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.multi-dropdown-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.multi-dropdown-option:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.multi-dropdown-option input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.multi-extra {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
width: 100%;
|
||||
@@ -128,6 +350,7 @@ textarea {
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #93c5fd;
|
||||
@@ -289,4 +512,15 @@ button:disabled {
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.actions-inline {
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
47
app/templates/auth.html
Normal file
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>
|
||||
@@ -5,7 +5,7 @@
|
||||
<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=20260406a" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260410d" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -13,7 +13,15 @@
|
||||
<h1>{{ app_name }}</h1>
|
||||
<p class="muted">从原文到公众号草稿,一页完成编辑、封面和发布。</p>
|
||||
</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>
|
||||
|
||||
<main class="layout">
|
||||
@@ -33,16 +41,49 @@
|
||||
<label>标题提示</label>
|
||||
<input id="titleHint" type="text" placeholder="如:AI Agent 商业化路径" />
|
||||
</div>
|
||||
<div>
|
||||
<label>目标读者</label>
|
||||
<input id="audience" type="text" value="公众号运营者/产品经理" />
|
||||
<div class="multi-field">
|
||||
<div class="field-head">
|
||||
<label>目标读者</label>
|
||||
<span class="meta">下拉多选</span>
|
||||
</div>
|
||||
<details class="multi-dropdown" id="audienceDetails">
|
||||
<summary>
|
||||
<span class="multi-dropdown-text" id="audienceSummary"></span>
|
||||
</summary>
|
||||
<div class="multi-dropdown-body" role="group" aria-label="目标读者选项">
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="公众号运营者" checked />公众号运营者</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="产品经理" checked />产品经理</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="技术开发者" />技术开发者</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="创业者" />创业者</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="学生与研究者" />学生与研究者</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="普通读者" />普通读者</label>
|
||||
</div>
|
||||
</details>
|
||||
<input id="audienceExtra" type="text" class="multi-extra" placeholder="其他补充(可选)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label>语气风格</label>
|
||||
<input id="tone" type="text" value="专业、有观点、口语自然" />
|
||||
<div class="multi-field">
|
||||
<div class="field-head">
|
||||
<label>语气风格</label>
|
||||
<span class="meta">下拉多选</span>
|
||||
</div>
|
||||
<details class="multi-dropdown" id="toneDetails">
|
||||
<summary>
|
||||
<span class="multi-dropdown-text" id="toneSummary"></span>
|
||||
</summary>
|
||||
<div class="multi-dropdown-body" role="group" aria-label="语气风格选项">
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="专业严谨" checked />专业严谨</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="有观点" checked />有观点</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="口语自然" checked />口语自然</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="轻松幽默" />轻松幽默</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="故事化叙事" />故事化叙事</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="科普解读" />科普解读</label>
|
||||
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="理性克制" />理性克制</label>
|
||||
</div>
|
||||
</details>
|
||||
<input id="toneExtra" type="text" class="multi-extra" placeholder="其他补充(可选)" />
|
||||
</div>
|
||||
<div>
|
||||
<label>避免词汇</label>
|
||||
@@ -102,6 +143,6 @@
|
||||
</main>
|
||||
|
||||
<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>
|
||||
</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 @@
|
||||
|
||||
@@ -12,4 +12,8 @@ services:
|
||||
- "18000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
AUTH_DB_PATH: /app/data/app.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
25
readme.md
25
readme.md
@@ -11,7 +11,9 @@ cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
启动后访问:`http://localhost:8000`
|
||||
启动后访问:`http://localhost:18000`
|
||||
|
||||
容器默认将数据库挂载到宿主机目录 `./data`(`AUTH_DB_PATH=/app/data/app.db`),更新容器镜像不会清空历史账号和会话数据。
|
||||
|
||||
## 2. 使用流程
|
||||
|
||||
@@ -29,8 +31,29 @@ docker compose up --build
|
||||
- `WECHAT_AUTHOR`:草稿默认作者名。
|
||||
- `IM_WEBHOOK_URL`:IM 推送地址(飞书/Slack/企微等)。
|
||||
- `IM_SECRET`:可选签名。
|
||||
- `AUTH_DB_PATH`:账号数据库文件路径(SQLite)。
|
||||
- `AUTH_SESSION_TTL_SEC`:普通登录会话时长(秒)。
|
||||
- `AUTH_REMEMBER_SESSION_TTL_SEC`:勾选“限时免登”时的会话时长(秒)。
|
||||
- `AUTH_PASSWORD_RESET_KEY`:忘记密码重置码(用于“用户名+重置码”找回,默认 `x2ws-reset-2026`,建议改掉)。
|
||||
|
||||
## 4. 说明
|
||||
|
||||
- 未配置 `OPENAI_API_KEY` 时,系统会使用本地降级改写模板,便于你先跑通流程。
|
||||
- 建议发布前人工复核事实与引用,避免版权和失真风险。
|
||||
- 登录页支持“限时免登”,设置页支持修改密码;忘记密码页支持通过“用户名 + 重置码”重置密码。
|
||||
|
||||
## 5. 数据备份与恢复
|
||||
|
||||
数据库文件默认在 `./data/app.db`,可直接备份该文件:
|
||||
|
||||
```bash
|
||||
cp ./data/app.db ./data/app.db.bak.$(date +%Y%m%d_%H%M%S)
|
||||
```
|
||||
|
||||
恢复时停止服务后覆盖回去:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
cp ./data/app.db.bak.YYYYMMDD_HHMMSS ./data/app.db
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user