fix: 更新当前界面,支持多公帐号切换
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user