From 846c65b155eda27c7f173b9d5a1536215bec6ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=B9=E5=B0=BC=E5=B0=94?= Date: Wed, 11 Mar 2026 09:44:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/data/ai_reply_config.json | 11 + backend/data/models.json | 11 + backend/data/sync_messages.json | 309 ++++++++++++ backend/data/wechat.db | Bin 0 -> 258048 bytes backend/db.py | 179 +++++++ backend/main.py | 329 ++++++++++++- backend/store.py | 747 +++++++++++++++++++----------- public/chat.html | 146 ++++-- public/index.html | 1 + public/manage.html | 294 ++++++++++-- public/models.html | 18 +- public/swagger.html | 243 ++++++++++ run-docker.sh | 9 +- 13 files changed, 1957 insertions(+), 340 deletions(-) create mode 100644 backend/data/ai_reply_config.json create mode 100644 backend/data/models.json create mode 100644 backend/data/wechat.db create mode 100644 backend/db.py create mode 100644 public/swagger.html diff --git a/backend/data/ai_reply_config.json b/backend/data/ai_reply_config.json new file mode 100644 index 0000000..f9f28a4 --- /dev/null +++ b/backend/data/ai_reply_config.json @@ -0,0 +1,11 @@ +[ + { + "key": "HBpEnbtj9BJZ", + "super_admin_wxids": [ + "wxid_f2q8xscgg31322" + ], + "whitelist_wxids": [ + "zhang499142409" + ] + } +] \ No newline at end of file diff --git a/backend/data/models.json b/backend/data/models.json new file mode 100644 index 0000000..67174f4 --- /dev/null +++ b/backend/data/models.json @@ -0,0 +1,11 @@ +[ + { + "id": "dee0443f-36f3-4d7c-9321-618d80c18a89", + "name": "千问", + "provider": "openai", + "api_key": "sk-85880595fc714d63bfd0b025e917bd26", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "model_name": "qwen3.5-plus", + "is_current": true + } +] \ No newline at end of file diff --git a/backend/data/sync_messages.json b/backend/data/sync_messages.json index d85b330..3faaacc 100644 --- a/backend/data/sync_messages.json +++ b/backend/data/sync_messages.json @@ -1174,5 +1174,314 @@ "new_msg_id": 4819003726112313030 }, "type": "message" + }, + { + "direction": "out", + "ToUserName": "zhang499142409", + "Content": "你好吗?", + "CreateTime": 1773162081, + "key": "HBpEnbtj9BJZ" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 1610150761, + "from_user_name": { + "str": "zhang499142409" + }, + "to_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "msg_type": 1, + "content": { + "str": "你是谁" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163138, + "msg_source": "\n\t0\n\t1\n\t1\n\tN0_V1_tvPQ/7y0|v1_SpiyYTgw\n\t\n\t\t\n\t\n\n", + "push_content": "Daniel : 你是谁", + "new_msg_id": 3512349988965098431 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 1826119229, + "from_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "to_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "msg_type": 51, + "content": { + "str": "\n\nwxid_f2q8xscgg31322\nlastMessage\n{\"messageSvrId\":\"9191983264673337867\",\"MsgCreateTime\":\"1773161823\"}\n\n" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163203, + "msg_source": "\n\tv1_eSSKf/rE\n\t\n\t\t\n\t\n\n", + "new_msg_id": 3552106780167326835 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 216882921, + "from_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "to_user_name": { + "str": "zhang499142409" + }, + "msg_type": 51, + "content": { + "str": "\n\nzhang499142409\nlastMessage\n{\"messageSvrId\":\"3512349988965098431\",\"MsgCreateTime\":\"1773163138\"}\n\n" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163205, + "msg_source": "\n\tv1_L2fvIOvF\n\t\n\t\t\n\t\n\n", + "new_msg_id": 8434304876441850640 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 2132745747, + "from_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "to_user_name": { + "str": "zhang499142409" + }, + "msg_type": 51, + "content": { + "str": "\n\nzhang499142409\nlastMessage\n{\"messageSvrId\":\"3512349988965098431\",\"MsgCreateTime\":\"1773163138\"}\n\n" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163205, + "msg_source": "\n\tv1_DG+ZFs7h\n\t\n\t\t\n\t\n\n", + "new_msg_id": 2869033315579360891 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 2110142296, + "from_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "to_user_name": { + "str": "zhang499142409" + }, + "msg_type": 51, + "content": { + "str": "\n\nzhang499142409\nlastMessage\n{\"messageSvrId\":\"3512349988965098431\",\"MsgCreateTime\":\"1773163138\"}\n\n" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163291, + "msg_source": "\n\tv1_XmjXzlCu\n\t\n\t\t\n\t\n\n", + "new_msg_id": 6554530052967632446 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 1891079631, + "from_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "to_user_name": { + "str": "zhang499142409" + }, + "msg_type": 51, + "content": { + "str": "\n\nzhang499142409\nlastMessage\n{\"messageSvrId\":\"3512349988965098431\",\"MsgCreateTime\":\"1773163138\"}\n\n" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163294, + "msg_source": "\n\tv1_iRwWbu7A\n\t\n\t\t\n\t\n\n", + "new_msg_id": 6757624217414248141 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 58087331, + "from_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "to_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "msg_type": 51, + "content": { + "str": "\n\nwxid_f2q8xscgg31322\nlastMessage\n{\"messageSvrId\":\"9191983264673337867\",\"MsgCreateTime\":\"1773161823\"}\n\n" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163296, + "msg_source": "\n\tv1_IFm3SM7Y\n\t\n\t\t\n\t\n\n", + "new_msg_id": 1302874624611387202 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 647268517, + "from_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "to_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "msg_type": 1, + "content": { + "str": "你用的什么模型" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163308, + "msg_source": "\n\t0\n\t1\n\t1\n\tN0_V1_/m2bkvRf|v1_1FCs6fvq\n\t\n\t\t\n\t\n\n", + "new_msg_id": 8354732942085133458 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 771150200, + "from_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "to_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "msg_type": 51, + "content": { + "str": "\n\nwxid_f2q8xscgg31322\nlastMessage\n{\"messageSvrId\":\"8354732942085133458\",\"MsgCreateTime\":\"1773163308\"}\n\n" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163310, + "msg_source": "\n\tv1_2hC615We\n\t\n\t\t\n\t\n\n", + "new_msg_id": 7243733440829071694 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 317539696, + "from_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "to_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "msg_type": 51, + "content": { + "str": "\n\nwxid_f2q8xscgg31322\nlastMessage\n{\"messageSvrId\":\"8354732942085133458\",\"MsgCreateTime\":\"1773163308\"}\n\n" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163312, + "msg_source": "\n\tv1_1PuNB2Q3\n\t\n\t\t\n\t\n\n", + "new_msg_id": 7265708667374985818 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 106384113, + "from_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "to_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "msg_type": 51, + "content": { + "str": "\n\nwxid_f2q8xscgg31322\nlastMessage\n{\"messageSvrId\":\"8354732942085133458\",\"MsgCreateTime\":\"1773163308\"}\n\n" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163320, + "msg_source": "\n\tv1_rWs/fWf2\n\t\n\t\t\n\t\n\n", + "new_msg_id": 1510294059264702492 + }, + "type": "message" + }, + { + "key": "HBpEnbtj9BJZ", + "message": { + "msg_id": 576932746, + "from_user_name": { + "str": "zhang499142409" + }, + "to_user_name": { + "str": "wxid_f2q8xscgg31322" + }, + "msg_type": 1, + "content": { + "str": "告诉我模型内容" + }, + "status": 3, + "img_status": 1, + "img_buf": { + "len": 0 + }, + "create_time": 1773163339, + "msg_source": "\n\t0\n\t1\n\t1\n\tN0_V1_8uaj8gCr|v1_Flh4iaN8\n\t\n\t\t\n\t\n\n", + "push_content": "Daniel : 告诉我模型内容", + "new_msg_id": 6612157681502055018 + }, + "type": "message" } ] \ No newline at end of file diff --git a/backend/data/wechat.db b/backend/data/wechat.db new file mode 100644 index 0000000000000000000000000000000000000000..25ba87f76fed7baeae260b183486a74b046aacad GIT binary patch literal 258048 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 HlGOhPruF7m literal 0 HcmV?d00001 diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..9dfd867 --- /dev/null +++ b/backend/db.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +"""SQLite 数据库:表结构初始化与连接,数据目录由 DATA_DIR 决定(可挂载到宿主机)。""" +import json +import os +import sqlite3 + +_DATA_DIR = os.getenv("DATA_DIR") or os.path.join(os.path.dirname(__file__), "data") +_DB_PATH = os.path.join(_DATA_DIR, "wechat.db") + +def get_db_path() -> str: + return _DB_PATH + +def get_conn() -> sqlite3.Connection: + os.makedirs(_DATA_DIR, exist_ok=True) + conn = sqlite3.connect(_DB_PATH, check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + +def init_schema(conn: sqlite3.Connection) -> None: + cur = conn.cursor() + # 客户档案 + cur.execute(""" + CREATE TABLE IF NOT EXISTS customers ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + wxid TEXT NOT NULL, + remark_name TEXT, + region TEXT, + age TEXT, + gender TEXT, + level TEXT, + tags TEXT + ) + """) + cur.execute("CREATE INDEX IF NOT EXISTS idx_customers_key ON customers(key)") + # 定时问候任务 + cur.execute(""" + CREATE TABLE IF NOT EXISTS greeting_tasks ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + name TEXT, + send_time TEXT, + customer_tags TEXT, + template TEXT, + use_qwen INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1, + executed_at TEXT + ) + """) + cur.execute("CREATE INDEX IF NOT EXISTS idx_greeting_tasks_key ON greeting_tasks(key)") + # 商品标签 + cur.execute(""" + CREATE TABLE IF NOT EXISTS product_tags ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + name TEXT + ) + """) + # 推送群组 + cur.execute(""" + CREATE TABLE IF NOT EXISTS push_groups ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + name TEXT, + customer_ids TEXT, + tag_ids TEXT + ) + """) + # 推送任务 + cur.execute(""" + CREATE TABLE IF NOT EXISTS push_tasks ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + product_tag_id TEXT, + group_id TEXT, + content TEXT, + send_at TEXT, + status TEXT, + created_at TEXT + ) + """) + # 同步消息(WS 拉取 + 发出记录) + cur.execute(""" + CREATE TABLE IF NOT EXISTS sync_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL, + create_time INTEGER DEFAULT 0, + payload TEXT + ) + """) + cur.execute("CREATE INDEX IF NOT EXISTS idx_sync_messages_key ON sync_messages(key)") + # 模型配置 + cur.execute(""" + CREATE TABLE IF NOT EXISTS models ( + id TEXT PRIMARY KEY, + name TEXT, + provider TEXT, + api_key TEXT, + base_url TEXT, + model_name TEXT, + is_current INTEGER DEFAULT 0 + ) + """) + # AI 回复配置(白名单 / 超级管理员) + cur.execute(""" + CREATE TABLE IF NOT EXISTS ai_reply_config ( + key TEXT PRIMARY KEY, + super_admin_wxids TEXT, + whitelist_wxids TEXT + ) + """) + conn.commit() + _migrate_json_if_needed(conn) + + +def _migrate_json_if_needed(conn: sqlite3.Connection) -> None: + """若表为空且存在同名 JSON 文件,则从 JSON 迁移一次。""" + cur = conn.cursor() + # (table, filename, columns, json_columns) + tables_files = [ + ("customers", "customers.json", ["id", "key", "wxid", "remark_name", "region", "age", "gender", "level", "tags"], ["tags"]), + ("greeting_tasks", "greeting_tasks.json", ["id", "key", "name", "send_time", "customer_tags", "template", "use_qwen", "enabled", "executed_at"], ["customer_tags"]), + ("product_tags", "product_tags.json", ["id", "key", "name"], []), + ("push_groups", "push_groups.json", ["id", "key", "name", "customer_ids", "tag_ids"], ["customer_ids", "tag_ids"]), + ("push_tasks", "push_tasks.json", ["id", "key", "product_tag_id", "group_id", "content", "send_at", "status", "created_at"], []), + ("models", "models.json", ["id", "name", "provider", "api_key", "base_url", "model_name", "is_current"], []), + ("ai_reply_config", "ai_reply_config.json", ["key", "super_admin_wxids", "whitelist_wxids"], ["super_admin_wxids", "whitelist_wxids"]), + ] + for table, filename, columns, json_cols in tables_files: + json_cols_set = set(json_cols) + cur.execute(f"SELECT COUNT(*) FROM {table}") + if cur.fetchone()[0] > 0: + continue + path = os.path.join(_DATA_DIR, filename) + if not os.path.isfile(path): + continue + try: + with open(path, "r", encoding="utf-8") as f: + rows = json.load(f) + except Exception: + continue + if not rows: + continue + for r in rows: + if not isinstance(r, dict): + continue + vals = [] + for c in columns: + v = r.get(c) + if c in json_cols_set and isinstance(v, (list, dict)): + v = json.dumps(v, ensure_ascii=False) + elif isinstance(v, bool): + v = 1 if v else 0 + vals.append(v) + placeholders = ",".join("?" * len(columns)) + cur.execute(f"INSERT OR IGNORE INTO {table} ({','.join(columns)}) VALUES ({placeholders})", vals) + # sync_messages: 按 key + payload 迁移 + cur.execute("SELECT COUNT(*) FROM sync_messages") + if cur.fetchone()[0] == 0: + path = os.path.join(_DATA_DIR, "sync_messages.json") + if os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8") as f: + rows = json.load(f) + for r in rows: + if not isinstance(r, dict): + continue + key = r.get("key", "") + ct = int(r.get("CreateTime") or 0) if isinstance(r.get("CreateTime"), (int, float)) else 0 + cur.execute("INSERT INTO sync_messages (key, create_time, payload) VALUES (?,?,?)", (key, ct, json.dumps(r, ensure_ascii=False))) + except Exception: + pass + conn.commit() + + +def _conn(): + c = get_conn() + init_schema(c) + return c diff --git a/backend/main.py b/backend/main.py index 1f53b3f..964671a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -28,6 +28,13 @@ SLIDER_VERIFY_BASE_URL = os.getenv("SLIDER_VERIFY_BASE_URL", "http://113.44.162. SLIDER_VERIFY_KEY = os.getenv("SLIDER_VERIFY_KEY", os.getenv("KEY", "408449830")) # 发送文本消息:swagger 中为 POST /message/SendTextMessage,body 为 SendMessageModel(MsgItem 数组) SEND_MSG_PATH = (os.getenv("SEND_MSG_PATH") or "/message/SendTextMessage").strip() +# 发送图片消息:部分上游为独立接口,或与文本同 path 仅 MsgType 不同(如 3=图片) +SEND_IMAGE_PATH = (os.getenv("SEND_IMAGE_PATH") or "").strip() or SEND_MSG_PATH +# 联系人列表:7006 为 POST /friend/GetContactList,body 传 CurrentChatRoomContactSeq/CurrentWxcontactSeq=0 +CONTACT_LIST_PATH = (os.getenv("CONTACT_LIST_PATH") or os.getenv("FRIEND_LIST_PATH") or "/friend/GetContactList").strip() +FRIEND_LIST_PATH = (os.getenv("FRIEND_LIST_PATH") or CONTACT_LIST_PATH).strip() +# 图片消息 MsgType:部分上游为 0,常见为 3 +IMAGE_MSG_TYPE = int(os.getenv("IMAGE_MSG_TYPE", "3")) # 按 key 缓存取码结果与 Data62,供后续步骤使用 qrcode_store: dict = {} @@ -39,15 +46,103 @@ logging.basicConfig( logger = logging.getLogger("wechat-backend") +def _is_self_sent(msg: dict) -> bool: + """判断是否为当前账号自己发出的消息(则不由 AI 回复)。""" + if msg.get("direction") == "out": + return True + if msg.get("IsSelf") in (1, True, "1"): + return True + return False + + +def _allowed_ai_reply(key: str, from_user: str) -> bool: + """分级处理:仅超级管理员或白名单内的联系人可获得 AI 回复,其他一律不回复。""" + if not from_user or not from_user.strip(): + return False + cfg = store.get_ai_reply_config(key) + if not cfg: + return False + super_admins = set(cfg.get("super_admin_wxids") or []) + whitelist = set(cfg.get("whitelist_wxids") or []) + return from_user.strip() in super_admins or from_user.strip() in whitelist + + +async def _ai_takeover_reply(key: str, from_user: str, content: str) -> None: + """收到他人消息时由 AI 接管:生成回复并发送。""" + if not from_user or not content or not content.strip(): + return + try: + recent = store.list_sync_messages(key, limit=10) + # 仅取与该用户的最近几条作为上下文(简化:只取最后几条) + context = [] + for m in reversed(recent): + c = (m.get("Content") or m.get("content") or "").strip() + if not c: + continue + if m.get("direction") == "out" and (m.get("ToUserName") or "").strip() == from_user: + context.append({"role": "assistant", "content": c}) + elif (m.get("FromUserName") or m.get("from") or "").strip() == from_user and not _is_self_sent(m): + context.append({"role": "user", "content": c}) + if len(context) >= 6: + break + if not context or context[-1].get("role") != "user": + context.append({"role": "user", "content": content}) + text = await llm_chat(context) + if text and text.strip(): + await _send_message_upstream(key, from_user, text.strip()) + logger.info("AI takeover replied to %s: %s", from_user[:20], text.strip()[:50]) + except Exception as e: + logger.exception("AI takeover reply error (from=%s): %s", from_user, e) + + def _on_ws_message(key: str, data: dict) -> None: - """GetSyncMsg 收到数据时:写入 store,便于前端拉取与自动回复逻辑使用。""" + """GetSyncMsg 收到数据时:写入 store;若为他人消息则 AI 接管对话。""" msg_list = data.get("MsgList") or data.get("List") or data.get("msgList") if isinstance(msg_list, list) and msg_list: store.append_sync_messages(key, msg_list) + for m in msg_list: + if _is_self_sent(m): + continue + from_user = (m.get("FromUserName") or m.get("from") or "").strip() + content = (m.get("Content") or m.get("content") or "").strip() + msg_type = m.get("MsgType") or m.get("msgType") + if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"): # 仅文本触发 AI + if not _allowed_ai_reply(key, from_user): + continue + try: + asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content)) + except RuntimeError: + pass elif isinstance(data, list): store.append_sync_messages(key, data) + for m in data: + if not isinstance(m, dict) or _is_self_sent(m): + continue + from_user = (m.get("FromUserName") or m.get("from") or "").strip() + content = (m.get("Content") or m.get("content") or "").strip() + msg_type = m.get("MsgType") or m.get("msgType") + if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"): + if not _allowed_ai_reply(key, from_user): + continue + try: + asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content)) + except RuntimeError: + pass else: store.append_sync_messages(key, [data]) + m = data if isinstance(data, dict) else {} + if not _is_self_sent(m): + from_user = (m.get("FromUserName") or m.get("from") or "").strip() + content = (m.get("Content") or m.get("content") or "").strip() + msg_type = m.get("MsgType") or m.get("msgType") + if from_user and content and (msg_type in (1, None) or str(msg_type) == "1"): + if not _allowed_ai_reply(key, from_user): + pass + else: + try: + asyncio.get_running_loop().create_task(_ai_takeover_reply(key, from_user, content)) + except RuntimeError: + pass async def _run_greeting_scheduler() -> None: @@ -409,6 +504,24 @@ class SendMessageBody(BaseModel): content: str +class BatchSendItem(BaseModel): + to_user_name: str + content: str + + +class BatchSendBody(BaseModel): + key: str + items: List[BatchSendItem] + + +class SendImageBody(BaseModel): + key: str + to_user_name: str + image_content: str # 图片 base64 或 URL,依上游约定 + text_content: Optional[str] = "" + at_wxid_list: Optional[List[str]] = None + + class QwenGenerateBody(BaseModel): prompt: str system: Optional[str] = None @@ -588,6 +701,65 @@ async def _send_message_upstream(key: str, to_user_name: str, content: str) -> d return {"ok": True, "raw": resp.text[:500]} +async def _send_batch_upstream(key: str, items: List[dict]) -> dict: + """批量发送:一次请求多个 MsgItem,快速分发。""" + url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_MSG_PATH}" + msg_items = [] + for it in items: + to_user = (it.get("to_user_name") or it.get("ToUserName") or "").strip() + content = (it.get("content") or it.get("TextContent") or "").strip() + if not to_user: + continue + msg_items.append({"ToUserName": to_user, "MsgType": 1, "TextContent": content}) + if not msg_items: + raise HTTPException(status_code=400, detail="items 中至少需要一条有效 to_user_name 与 content") + payload = {"MsgItem": msg_items} + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, params={"key": key}, json=payload) + if resp.status_code >= 400: + body_preview = resp.text[:400] if resp.text else "" + logger.warning("Batch send upstream %s: %s", resp.status_code, body_preview) + raise HTTPException( + status_code=502, + detail=f"upstream_returned_{resp.status_code}: {body_preview}", + ) + for it in msg_items: + store.append_sent_message(key, it["ToUserName"], it.get("TextContent", "")) + try: + return resp.json() + except Exception: + return {"ok": True, "sent": len(msg_items), "raw": resp.text[:500]} + + +async def _send_image_upstream(key: str, to_user_name: str, image_content: str, + text_content: Optional[str] = "", + at_wxid_list: Optional[List[str]] = None) -> dict: + """发送图片消息:MsgItem 含 ImageContent、MsgType=3(或 0,依上游),可选 TextContent、AtWxIDList。""" + url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_IMAGE_PATH}" + item = { + "ToUserName": to_user_name, + "MsgType": IMAGE_MSG_TYPE, + "ImageContent": image_content or "", + "TextContent": text_content or "", + "AtWxIDList": at_wxid_list or [], + } + payload = {"MsgItem": [item]} + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, params={"key": key}, json=payload) + if resp.status_code >= 400: + body_preview = resp.text[:400] if resp.text else "" + logger.warning("Send image upstream %s: %s", resp.status_code, body_preview) + raise HTTPException( + status_code=502, + detail=f"upstream_returned_{resp.status_code}: {body_preview}", + ) + store.append_sent_message(key, to_user_name, "[图片]" + ((" " + text_content) if text_content else "")) + try: + return resp.json() + except Exception: + return {"ok": True, "raw": resp.text[:500]} + + @app.post("/api/send-message") async def api_send_message(body: SendMessageBody): try: @@ -599,6 +771,161 @@ async def api_send_message(body: SendMessageBody): raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc +@app.post("/api/send-batch") +async def api_send_batch(body: BatchSendBody): + """快速群发:一次请求批量发送给多人,支持从好友/客户列表选择后调用。""" + items = [{"to_user_name": it.to_user_name, "content": it.content} for it in body.items] + try: + return await _send_batch_upstream(body.key, items) + except HTTPException: + raise + except Exception as exc: + logger.exception("Batch send error: %s", exc) + raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc + + +@app.post("/api/send-image") +async def api_send_image(body: SendImageBody): + """发送图片消息快捷方式,参数对应 MsgItem:ImageContent、TextContent、ToUserName、AtWxIDList。""" + try: + return await _send_image_upstream( + body.key, + body.to_user_name, + body.image_content, + text_content=body.text_content or "", + at_wxid_list=body.at_wxid_list, + ) + except HTTPException: + raise + except Exception as exc: + logger.exception("Send image error: %s", exc) + raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc + + +def _normalize_contact_list(raw: Any) -> List[dict]: + """将上游 GetContactList 多种返回格式统一为 [ { wxid, remark_name, ... } ]。""" + items = [] + if isinstance(raw, list): + items = raw + elif isinstance(raw, dict): + data = raw.get("Data") or raw.get("data") or raw + if isinstance(data, list): + items = data + elif isinstance(data, dict): + items = ( + data.get("ContactList") + or data.get("contactList") + or data.get("WxcontactList") + or data.get("wxcontactList") + or data.get("CachedContactList") + or data.get("List") + or data.get("list") + or data.get("items") + or [] + ) + items = items or raw.get("items") or raw.get("list") or raw.get("List") or [] + result = [] + for x in items: + if not isinstance(x, dict): + continue + wxid = ( + x.get("wxid") + or x.get("Wxid") + or x.get("UserName") + or x.get("userName") + or x.get("Alias") + or "" + ) + remark = ( + x.get("remark_name") + or x.get("RemarkName") + or x.get("NickName") + or x.get("nickName") + or x.get("DisplayName") + or wxid + ) + result.append({"wxid": wxid, "remark_name": remark, **{k: v for k, v in x.items() if k not in ("wxid", "Wxid", "remark_name", "RemarkName")}}) + return result + + +# 上游 GetContactList 请求体:CurrentChatRoomContactSeq、CurrentWxcontactSeq 传 0 表示拉取全量 +GET_CONTACT_LIST_BODY = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0} + + +@app.get("/api/contact-list") +async def api_contact_list(key: str = Query(..., description="账号 key")): + """获取全部联系人:POST 上游,body 为 CurrentChatRoomContactSeq/CurrentWxcontactSeq=0,key 走 query。""" + base = WECHAT_UPSTREAM_BASE_URL.rstrip("/") + path = CONTACT_LIST_PATH if CONTACT_LIST_PATH.startswith("/") else f"/{CONTACT_LIST_PATH}" + url = f"{base}{path}" + try: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post( + url, + params={"key": key}, + json=GET_CONTACT_LIST_BODY, + ) + if resp.status_code >= 400: + logger.warning("GetContactList %s: %s", resp.status_code, resp.text[:200]) + return {"items": [], "error": resp.text[:200]} + raw = resp.json() + # 日志便于确认 7006 返回结构(不打印完整列表) + if isinstance(raw, dict): + data = raw.get("Data") or raw.get("data") + data_keys = list(data.keys()) if isinstance(data, dict) else getattr(data, "__name__", type(data).__name__) + logger.info("GetContactList response keys: raw=%s, Data=%s", list(raw.keys()), data_keys) + items = _normalize_contact_list(raw) + if not items and isinstance(raw, dict): + items = _normalize_contact_list(raw.get("Data") or raw.get("data") or raw) + logger.info("GetContactList normalized items count: %s", len(items)) + return {"items": items} + except Exception as e: + logger.warning("GetContactList error: %s", e) + return {"items": [], "error": str(e)} + + +@app.get("/api/friends") +async def api_list_friends(key: str = Query(..., description="账号 key")): + """好友列表:代理上游联系人接口,与 /api/contact-list 同源;否则返回客户档案。""" + return await api_contact_list(key) + + +def _friends_fallback(key: str) -> List[dict]: + """用客户档案作为可选联系人,便于在管理页选择群发对象。""" + customers = store.list_customers(key) + return [ + {"wxid": c.get("wxid"), "remark_name": c.get("remark_name") or c.get("wxid"), "id": c.get("id")} + for c in customers + if c.get("wxid") + ] + + +# ---------- AI 接管回复配置(白名单 + 超级管理员) ---------- +class AIReplyConfigUpdate(BaseModel): + key: str + super_admin_wxids: Optional[List[str]] = None + whitelist_wxids: Optional[List[str]] = None + + +@app.get("/api/ai-reply-config") +async def api_get_ai_reply_config(key: str = Query(..., description="账号 key")): + """获取当前账号的 AI 回复配置:超级管理员与白名单 wxid 列表。""" + cfg = store.get_ai_reply_config(key) + if not cfg: + return {"key": key, "super_admin_wxids": [], "whitelist_wxids": []} + return cfg + + +@app.patch("/api/ai-reply-config") +async def api_update_ai_reply_config(body: AIReplyConfigUpdate): + """设置 AI 回复白名单与超级管理员:仅列表内联系人会收到 AI 自动回复。""" + return store.update_ai_reply_config( + body.key, + super_admin_wxids=body.super_admin_wxids, + whitelist_wxids=body.whitelist_wxids, + ) + + # ---------- 模型管理(多模型切换,API Key 按模型配置) ---------- class ModelCreate(BaseModel): name: str diff --git a/backend/store.py b/backend/store.py index 484e326..b003633 100644 --- a/backend/store.py +++ b/backend/store.py @@ -1,305 +1,437 @@ # -*- coding: utf-8 -*- -"""JSON 文件存储:客户档案、定时问候任务、商品标签、推送群组、推送任务、同步消息。""" +"""数据库存储:客户档案、定时问候、商品标签、推送群组/任务、同步消息、模型、AI 回复配置。使用 SQLite,便于增删改查。""" import json -import os import threading +import time import uuid from typing import Any, Dict, List, Optional -_DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +try: + from backend import db +except ImportError: + import db + _LOCK = threading.Lock() -def _path(name: str) -> str: - os.makedirs(_DATA_DIR, exist_ok=True) - return os.path.join(_DATA_DIR, f"{name}.json") +def _conn(): + conn = db.get_conn() + db.init_schema(conn) + return conn + +def _row_to_dict(row) -> dict: + if row is None: + return {} + d = dict(row) + out = {} + for k, v in d.items(): + if k in ("tags", "customer_tags", "customer_ids", "tag_ids", "super_admin_wxids", "whitelist_wxids") and isinstance(v, str): + try: + out[k] = json.loads(v) if v else [] + except Exception: + out[k] = [] + elif k in ("use_qwen", "enabled", "is_current") and v is not None: + out[k] = bool(v) + else: + out[k] = v + return out -def _load(name: str) -> list: - with _LOCK: - p = _path(name) - if not os.path.exists(p): - return [] - try: - with open(p, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return [] - - -def _save(name: str, data: list) -> None: - with _LOCK: - p = _path(name) - with open(p, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -# ---------- 客户档案 R1-2 ---------- +# ---------- 客户档案 ---------- def list_customers(key: Optional[str] = None) -> List[Dict]: - """key: 微信 key,若传则只返回该 key 下的客户。""" - rows = _load("customers") - if key: - rows = [r for r in rows if r.get("key") == key] - return sorted(rows, key=lambda x: (x.get("remark_name") or x.get("wxid") or "")) - + with _LOCK: + conn = _conn() + try: + if key: + cur = conn.execute("SELECT * FROM customers WHERE key = ? ORDER BY remark_name, wxid", (key,)) + else: + cur = conn.execute("SELECT * FROM customers ORDER BY remark_name, wxid") + return [_row_to_dict(r) for r in cur.fetchall()] + finally: + conn.close() def get_customer(customer_id: str) -> Optional[Dict]: - rows = _load("customers") - for r in rows: - if r.get("id") == customer_id: - return r - return None - + with _LOCK: + conn = _conn() + try: + cur = conn.execute("SELECT * FROM customers WHERE id = ?", (customer_id,)) + row = cur.fetchone() + return _row_to_dict(row) if row else None + finally: + conn.close() def upsert_customer(key: str, wxid: str, remark_name: str = "", region: str = "", age: str = "", gender: str = "", level: str = "", tags: Optional[List[str]] = None, customer_id: Optional[str] = None) -> Dict: - """拿货等级 level;tags 为标签列表,用于分群与问候。""" - rows = _load("customers") - if tags is None: - tags = [] + tags = tags or [] rid = customer_id or str(uuid.uuid4()) - for r in rows: - if r.get("id") == rid or (r.get("key") == key and r.get("wxid") == wxid and not customer_id): - r.update({ - "key": key, "wxid": wxid, "remark_name": remark_name, "region": region, - "age": age, "gender": gender, "level": level, "tags": tags, - }) - _save("customers", rows) - return r - new_row = { - "id": rid, "key": key, "wxid": wxid, "remark_name": remark_name, - "region": region, "age": age, "gender": gender, "level": level, "tags": tags, - } - rows.append(new_row) - _save("customers", rows) - return new_row - + tags_json = json.dumps(tags, ensure_ascii=False) + with _LOCK: + conn = _conn() + try: + if customer_id: + conn.execute( + "UPDATE customers SET key=?, wxid=?, remark_name=?, region=?, age=?, gender=?, level=?, tags=? WHERE id=?", + (key, wxid, remark_name, region, age, gender, level, tags_json, customer_id) + ) + conn.commit() + cur = conn.execute("SELECT * FROM customers WHERE id = ?", (customer_id,)) + return _row_to_dict(cur.fetchone()) + cur = conn.execute("SELECT id FROM customers WHERE key = ? AND wxid = ?", (key, wxid)) + row = cur.fetchone() + if row: + conn.execute( + "UPDATE customers SET remark_name=?, region=?, age=?, gender=?, level=?, tags=? WHERE id=?", + (remark_name, region, age, gender, level, tags_json, row["id"]) + ) + conn.commit() + cur = conn.execute("SELECT * FROM customers WHERE id = ?", (row["id"],)) + return _row_to_dict(cur.fetchone()) + conn.execute( + "INSERT INTO customers (id, key, wxid, remark_name, region, age, gender, level, tags) VALUES (?,?,?,?,?,?,?,?,?)", + (rid, key, wxid, remark_name, region, age, gender, level, tags_json) + ) + conn.commit() + cur = conn.execute("SELECT * FROM customers WHERE id = ?", (rid,)) + return _row_to_dict(cur.fetchone()) + finally: + conn.close() def delete_customer(customer_id: str) -> bool: - rows = _load("customers") - for i, r in enumerate(rows): - if r.get("id") == customer_id: - rows.pop(i) - _save("customers", rows) - return True - return False - + with _LOCK: + conn = _conn() + try: + cur = conn.execute("DELETE FROM customers WHERE id = ?", (customer_id,)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() def list_customer_tags(key: str) -> List[str]: - """返回该 key 下客户档案中出现过的所有标签(去重、排序)。""" - rows = [r for r in _load("customers") if r.get("key") == key] + rows = [r for r in list_customers(key=key) if r] tags_set = set() for r in rows: - for t in r.get("tags") or []: + for t in (r.get("tags") or []): if t and str(t).strip(): tags_set.add(str(t).strip()) return sorted(tags_set) -# ---------- 定时问候任务 R1-3 ---------- +# ---------- 定时问候任务 ---------- def list_greeting_tasks(key: Optional[str] = None) -> List[Dict]: - rows = _load("greeting_tasks") - if key: - rows = [r for r in rows if r.get("key") == key] - return sorted(rows, key=lambda x: x.get("send_time", "") or x.get("cron", "")) - + with _LOCK: + conn = _conn() + try: + if key: + cur = conn.execute("SELECT * FROM greeting_tasks WHERE key = ? ORDER BY send_time", (key,)) + else: + cur = conn.execute("SELECT * FROM greeting_tasks ORDER BY send_time") + return [_row_to_dict(r) for r in cur.fetchall()] + finally: + conn.close() def get_greeting_task(task_id: str) -> Optional[Dict]: - for r in _load("greeting_tasks"): - if r.get("id") == task_id: - return r - return None - + with _LOCK: + conn = _conn() + try: + cur = conn.execute("SELECT * FROM greeting_tasks WHERE id = ?", (task_id,)) + row = cur.fetchone() + return _row_to_dict(row) if row else None + finally: + conn.close() def create_greeting_task(key: str, name: str, send_time: str, customer_tags: List[str], - template: str, use_qwen: bool = False) -> Dict: + template: str, use_qwen: bool = False) -> Dict: rid = str(uuid.uuid4()) row = { "id": rid, "key": key, "name": name, "send_time": send_time, "customer_tags": customer_tags or [], "template": template, "use_qwen": use_qwen, "enabled": True, "executed_at": None, } - rows = _load("greeting_tasks") - rows.append(row) - _save("greeting_tasks", rows) - return row - + with _LOCK: + conn = _conn() + try: + conn.execute( + "INSERT INTO greeting_tasks (id, key, name, send_time, customer_tags, template, use_qwen, enabled, executed_at) VALUES (?,?,?,?,?,?,?,?,?)", + (rid, key, name, send_time, json.dumps(customer_tags or [], ensure_ascii=False), template, 1 if use_qwen else 0, 1, None) + ) + conn.commit() + return row + finally: + conn.close() def update_greeting_task(task_id: str, **kwargs) -> Optional[Dict]: - rows = _load("greeting_tasks") - for r in rows: - if r.get("id") == task_id: - for k, v in kwargs.items(): - if k in ("name", "send_time", "cron", "customer_tags", "template", "use_qwen", "enabled", "executed_at"): - r[k] = v - _save("greeting_tasks", rows) - return r - return None - + allowed = ("name", "send_time", "cron", "customer_tags", "template", "use_qwen", "enabled", "executed_at") + with _LOCK: + conn = _conn() + try: + cur = conn.execute("SELECT * FROM greeting_tasks WHERE id = ?", (task_id,)) + row = cur.fetchone() + if not row: + return None + updates = [] + params = [] + if "send_time" in kwargs: + updates.append("send_time = ?") + params.append(kwargs["send_time"]) + if "cron" in kwargs: + updates.append("send_time = ?") + params.append(kwargs["cron"]) + for k in ("name", "customer_tags", "template", "use_qwen", "enabled", "executed_at"): + if k in kwargs: + v = kwargs[k] + if k == "customer_tags": + updates.append("customer_tags = ?") + params.append(json.dumps(v, ensure_ascii=False)) + elif k == "use_qwen": + updates.append("use_qwen = ?") + params.append(1 if v else 0) + elif k == "enabled": + updates.append("enabled = ?") + params.append(1 if v else 0) + else: + updates.append(f"{k} = ?") + params.append(v) + if not updates: + return _row_to_dict(row) + params.append(task_id) + conn.execute(f"UPDATE greeting_tasks SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + cur = conn.execute("SELECT * FROM greeting_tasks WHERE id = ?", (task_id,)) + return _row_to_dict(cur.fetchone()) + finally: + conn.close() def delete_greeting_task(task_id: str) -> bool: - rows = _load("greeting_tasks") - for i, r in enumerate(rows): - if r.get("id") == task_id: - rows.pop(i) - _save("greeting_tasks", rows) - return True - return False + with _LOCK: + conn = _conn() + try: + cur = conn.execute("DELETE FROM greeting_tasks WHERE id = ?", (task_id,)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() -# ---------- 商品标签 R1-4 ---------- +# ---------- 商品标签 ---------- def list_product_tags(key: Optional[str] = None) -> List[Dict]: - rows = _load("product_tags") - if key: - rows = [r for r in rows if r.get("key") == key] - return rows - + with _LOCK: + conn = _conn() + try: + if key: + cur = conn.execute("SELECT * FROM product_tags WHERE key = ?", (key,)) + else: + cur = conn.execute("SELECT * FROM product_tags") + return [_row_to_dict(r) for r in cur.fetchall()] + finally: + conn.close() def create_product_tag(key: str, name: str) -> Dict: rid = str(uuid.uuid4()) - row = {"id": rid, "key": key, "name": name} - rows = _load("product_tags") - rows.append(row) - _save("product_tags", rows) - return row - + with _LOCK: + conn = _conn() + try: + conn.execute("INSERT INTO product_tags (id, key, name) VALUES (?,?,?)", (rid, key, name)) + conn.commit() + cur = conn.execute("SELECT * FROM product_tags WHERE id = ?", (rid,)) + return _row_to_dict(cur.fetchone()) + finally: + conn.close() def delete_product_tag(tag_id: str) -> bool: - rows = _load("product_tags") - for i, r in enumerate(rows): - if r.get("id") == tag_id: - rows.pop(i) - _save("product_tags", rows) - return True - return False + with _LOCK: + conn = _conn() + try: + cur = conn.execute("DELETE FROM product_tags WHERE id = ?", (tag_id,)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() -# ---------- 推送群组(客户群组) ---------- +# ---------- 推送群组 ---------- def list_push_groups(key: Optional[str] = None) -> List[Dict]: - rows = _load("push_groups") - if key: - rows = [r for r in rows if r.get("key") == key] - return rows - + with _LOCK: + conn = _conn() + try: + if key: + cur = conn.execute("SELECT * FROM push_groups WHERE key = ?", (key,)) + else: + cur = conn.execute("SELECT * FROM push_groups") + return [_row_to_dict(r) for r in cur.fetchall()] + finally: + conn.close() def create_push_group(key: str, name: str, customer_ids: List[str], tag_ids: List[str]) -> Dict: rid = str(uuid.uuid4()) - row = {"id": rid, "key": key, "name": name, "customer_ids": customer_ids or [], "tag_ids": tag_ids or []} - rows = _load("push_groups") - rows.append(row) - _save("push_groups", rows) - return row - + with _LOCK: + conn = _conn() + try: + conn.execute( + "INSERT INTO push_groups (id, key, name, customer_ids, tag_ids) VALUES (?,?,?,?,?)", + (rid, key, name, json.dumps(customer_ids or [], ensure_ascii=False), json.dumps(tag_ids or [], ensure_ascii=False)) + ) + conn.commit() + cur = conn.execute("SELECT * FROM push_groups WHERE id = ?", (rid,)) + return _row_to_dict(cur.fetchone()) + finally: + conn.close() def update_push_group(group_id: str, name: Optional[str] = None, customer_ids: Optional[List[str]] = None, tag_ids: Optional[List[str]] = None) -> Optional[Dict]: - rows = _load("push_groups") - for r in rows: - if r.get("id") == group_id: + with _LOCK: + conn = _conn() + try: + cur = conn.execute("SELECT * FROM push_groups WHERE id = ?", (group_id,)) + if cur.fetchone() is None: + return None + updates, params = [], [] if name is not None: - r["name"] = name + updates.append("name = ?") + params.append(name) if customer_ids is not None: - r["customer_ids"] = customer_ids + updates.append("customer_ids = ?") + params.append(json.dumps(customer_ids, ensure_ascii=False)) if tag_ids is not None: - r["tag_ids"] = tag_ids - _save("push_groups", rows) - return r - return None - + updates.append("tag_ids = ?") + params.append(json.dumps(tag_ids, ensure_ascii=False)) + if not updates: + cur = conn.execute("SELECT * FROM push_groups WHERE id = ?", (group_id,)) + return _row_to_dict(cur.fetchone()) + params.append(group_id) + conn.execute(f"UPDATE push_groups SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + cur = conn.execute("SELECT * FROM push_groups WHERE id = ?", (group_id,)) + return _row_to_dict(cur.fetchone()) + finally: + conn.close() def delete_push_group(group_id: str) -> bool: - rows = _load("push_groups") - for i, r in enumerate(rows): - if r.get("id") == group_id: - rows.pop(i) - _save("push_groups", rows) - return True - return False + with _LOCK: + conn = _conn() + try: + cur = conn.execute("DELETE FROM push_groups WHERE id = ?", (group_id,)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() -# ---------- 推送任务(一键/定时发送) ---------- +# ---------- 推送任务 ---------- def list_push_tasks(key: Optional[str] = None, limit: int = 200) -> List[Dict]: - rows = _load("push_tasks") - if key: - rows = [r for r in rows if r.get("key") == key] - rows = sorted(rows, key=lambda x: x.get("created_at", ""), reverse=True) - return rows[:limit] - + with _LOCK: + conn = _conn() + try: + if key: + cur = conn.execute("SELECT * FROM push_tasks WHERE key = ? ORDER BY created_at DESC LIMIT ?", (key, limit)) + else: + cur = conn.execute("SELECT * FROM push_tasks ORDER BY created_at DESC LIMIT ?", (limit,)) + return [_row_to_dict(r) for r in cur.fetchall()] + finally: + conn.close() def create_push_task(key: str, product_tag_id: str, group_id: str, content: str, send_at: Optional[str] = None) -> Dict: rid = str(uuid.uuid4()) - import time - row = { - "id": rid, "key": key, "product_tag_id": product_tag_id, "group_id": group_id, - "content": content, "send_at": send_at, "status": "pending", - "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"), - } - rows = _load("push_tasks") - rows.append(row) - _save("push_tasks", rows) - return row - + created = time.strftime("%Y-%m-%dT%H:%M:%S") + with _LOCK: + conn = _conn() + try: + conn.execute( + "INSERT INTO push_tasks (id, key, product_tag_id, group_id, content, send_at, status, created_at) VALUES (?,?,?,?,?,?,?,?)", + (rid, key, product_tag_id, group_id, content, send_at, "pending", created) + ) + conn.commit() + cur = conn.execute("SELECT * FROM push_tasks WHERE id = ?", (rid,)) + return _row_to_dict(cur.fetchone()) + finally: + conn.close() def update_push_task_status(task_id: str, status: str) -> Optional[Dict]: - rows = _load("push_tasks") - for r in rows: - if r.get("id") == task_id: - r["status"] = status - _save("push_tasks", rows) - return r - return None + with _LOCK: + conn = _conn() + try: + conn.execute("UPDATE push_tasks SET status = ? WHERE id = ?", (status, task_id)) + conn.commit() + cur = conn.execute("SELECT * FROM push_tasks WHERE id = ?", (task_id,)) + row = cur.fetchone() + return _row_to_dict(row) if row else None + finally: + conn.close() -# ---------- WS 同步消息(GetSyncMsg 结果) ---------- +# ---------- 同步消息 ---------- def append_sync_messages(key: str, messages: List[Dict], max_per_key: int = 500) -> None: - rows = _load("sync_messages") - for m in messages: - m["key"] = key - rows.append(m) - by_key: Dict[str, List[Dict]] = {} - for m in rows: - k = m.get("key", "") - by_key.setdefault(k, []).append(m) - new_rows = [] - for lst in by_key.values(): - new_rows.extend(lst[-max_per_key:]) - _save("sync_messages", new_rows) - + with _LOCK: + conn = _conn() + try: + for m in messages: + create_time = int(m.get("CreateTime") or 0) if isinstance(m.get("CreateTime"), (int, float)) else 0 + conn.execute("INSERT INTO sync_messages (key, create_time, payload) VALUES (?,?,?)", + (key, create_time, json.dumps(m, ensure_ascii=False))) + conn.commit() + # 每个 key 只保留最近 max_per_key 条 + cur = conn.execute("SELECT id FROM sync_messages WHERE key = ? ORDER BY create_time DESC", (key,)) + rows = cur.fetchall() + if len(rows) > max_per_key: + to_del = [r["id"] for r in rows[max_per_key:]] + placeholders = ",".join("?" * len(to_del)) + conn.execute(f"DELETE FROM sync_messages WHERE id IN ({placeholders})", to_del) + conn.commit() + finally: + conn.close() def list_sync_messages(key: str, limit: int = 100) -> List[Dict]: - rows = _load("sync_messages") - rows = [r for r in rows if r.get("key") == key] - # 统一按 CreateTime 排序(支持 int 时间戳与其它格式),新消息在前 - rows = sorted(rows, key=lambda x: int(x.get("CreateTime") or 0) if isinstance(x.get("CreateTime"), (int, float)) else 0, reverse=True) - return rows[:limit] - + with _LOCK: + conn = _conn() + try: + cur = conn.execute( + "SELECT payload FROM sync_messages WHERE key = ? ORDER BY create_time DESC LIMIT ?", + (key, limit) + ) + rows = cur.fetchall() + out = [] + for r in rows: + try: + out.append(json.loads(r["payload"])) + except Exception: + pass + return out + finally: + conn.close() def append_sent_message(key: str, to_user_name: str, content: str) -> None: - """发送消息成功后写入一条「发出」记录,便于在实时消息页展示完整对话。""" - import time append_sync_messages(key, [{"direction": "out", "ToUserName": to_user_name, "Content": content, "CreateTime": int(time.time())}]) -# ---------- 模型管理(多模型切换,API Key 按模型配置) ---------- +# ---------- 模型 ---------- def list_models() -> List[Dict]: - rows = _load("models") - return sorted(rows, key=lambda x: (not x.get("is_current"), x.get("name") or "")) - + with _LOCK: + conn = _conn() + try: + cur = conn.execute("SELECT * FROM models ORDER BY is_current DESC, name") + return [_row_to_dict(r) for r in cur.fetchall()] + finally: + conn.close() def get_model(model_id: str) -> Optional[Dict]: - for r in _load("models"): - if r.get("id") == model_id: - return r - return None - + with _LOCK: + conn = _conn() + try: + cur = conn.execute("SELECT * FROM models WHERE id = ?", (model_id,)) + row = cur.fetchone() + return _row_to_dict(row) if row else None + finally: + conn.close() def get_current_model() -> Optional[Dict]: - for r in _load("models"): - if r.get("is_current"): - return r - return None - + with _LOCK: + conn = _conn() + try: + cur = conn.execute("SELECT * FROM models WHERE is_current = 1 LIMIT 1") + row = cur.fetchone() + return _row_to_dict(row) if row else None + finally: + conn.close() def create_model( name: str, @@ -309,36 +441,35 @@ def create_model( model_name: str = "", is_current: bool = False, ) -> Dict: - rows = _load("models") - if is_current: - for r in rows: - r["is_current"] = False - rid = str(uuid.uuid4()) - if provider == "qwen": - default_base = "https://dashscope.aliyuncs.com/compatible-mode/v1" - default_model = "qwen-turbo" - elif provider == "doubao": - default_base = "https://ark.cn-beijing.volces.com/api/v3" - default_model = "doubao-seed-2-0-pro-260215" - else: - default_base = "https://api.openai.com/v1" - default_model = "gpt-3.5-turbo" - row = { - "id": rid, - "name": name, - "provider": provider, - "api_key": api_key, - "base_url": (base_url or default_base).strip(), - "model_name": (model_name or default_model).strip(), - "is_current": is_current or len(rows) == 0, - } - if row["is_current"]: - for r in rows: - r["is_current"] = False - rows.append(row) - _save("models", rows) - return row - + with _LOCK: + conn = _conn() + try: + if is_current: + conn.execute("UPDATE models SET is_current = 0") + rid = str(uuid.uuid4()) + default_base = "https://api.openai.com/v1" + default_model = "gpt-3.5-turbo" + if provider == "qwen": + default_base = "https://dashscope.aliyuncs.com/compatible-mode/v1" + default_model = "qwen-turbo" + elif provider == "doubao": + default_base = "https://ark.cn-beijing.volces.com/api/v3" + default_model = "doubao-seed-2-0-pro-260215" + base_url = (base_url or default_base).strip() + model_name = (model_name or default_model).strip() + cur = conn.execute("SELECT COUNT(*) as c FROM models") + is_current = is_current or cur.fetchone()["c"] == 0 + if is_current: + conn.execute("UPDATE models SET is_current = 0") + conn.execute( + "INSERT INTO models (id, name, provider, api_key, base_url, model_name, is_current) VALUES (?,?,?,?,?,?,?)", + (rid, name, provider, api_key, base_url, model_name, 1 if is_current else 0) + ) + conn.commit() + cur = conn.execute("SELECT * FROM models WHERE id = ?", (rid,)) + return _row_to_dict(cur.fetchone()) + finally: + conn.close() def update_model( model_id: str, @@ -347,44 +478,104 @@ def update_model( base_url: Optional[str] = None, model_name: Optional[str] = None, ) -> Optional[Dict]: - rows = _load("models") - for r in rows: - if r.get("id") == model_id: - if name is not None: - r["name"] = name - if api_key is not None: - r["api_key"] = api_key - if base_url is not None: - r["base_url"] = base_url - if model_name is not None: - r["model_name"] = model_name - _save("models", rows) - return r - return None - + with _LOCK: + conn = _conn() + try: + updates, params = [], [] + for k, v in (("name", name), ("api_key", api_key), ("base_url", base_url), ("model_name", model_name)): + if v is not None: + updates.append(f"{k} = ?") + params.append(v) + if not updates: + cur = conn.execute("SELECT * FROM models WHERE id = ?", (model_id,)) + row = cur.fetchone() + return _row_to_dict(row) if row else None + params.append(model_id) + conn.execute(f"UPDATE models SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + cur = conn.execute("SELECT * FROM models WHERE id = ?", (model_id,)) + row = cur.fetchone() + return _row_to_dict(row) if row else None + finally: + conn.close() def set_current_model(model_id: str) -> Optional[Dict]: - rows = _load("models") - found = None - for r in rows: - if r.get("id") == model_id: - r["is_current"] = True - found = r - else: - r["is_current"] = False - if found: - _save("models", rows) - return found - + with _LOCK: + conn = _conn() + try: + conn.execute("UPDATE models SET is_current = 0") + conn.execute("UPDATE models SET is_current = 1 WHERE id = ?", (model_id,)) + conn.commit() + cur = conn.execute("SELECT * FROM models WHERE id = ?", (model_id,)) + row = cur.fetchone() + return _row_to_dict(row) if row else None + finally: + conn.close() def delete_model(model_id: str) -> bool: - rows = _load("models") - for i, r in enumerate(rows): - if r.get("id") == model_id: - was_current = r.get("is_current") - rows.pop(i) - if was_current and rows: - rows[0]["is_current"] = True - _save("models", rows) + with _LOCK: + conn = _conn() + try: + cur = conn.execute("SELECT is_current FROM models WHERE id = ?", (model_id,)) + row = cur.fetchone() + if not row: + return False + was_current = row["is_current"] + conn.execute("DELETE FROM models WHERE id = ?", (model_id,)) + if was_current: + cur = conn.execute("SELECT id FROM models LIMIT 1") + first = cur.fetchone() + if first: + conn.execute("UPDATE models SET is_current = 1 WHERE id = ?", (first["id"],)) + conn.commit() return True - return False + finally: + conn.close() + + +# ---------- AI 回复配置 ---------- +def get_ai_reply_config(key: str) -> Optional[Dict]: + with _LOCK: + conn = _conn() + try: + cur = conn.execute("SELECT * FROM ai_reply_config WHERE key = ?", (key,)) + row = cur.fetchone() + return _row_to_dict(row) if row else None + finally: + conn.close() + +def update_ai_reply_config( + key: str, + super_admin_wxids: Optional[List[str]] = None, + whitelist_wxids: Optional[List[str]] = None, +) -> Dict: + with _LOCK: + conn = _conn() + try: + cur = conn.execute("SELECT * FROM ai_reply_config WHERE key = ?", (key,)) + row = cur.fetchone() + if row: + updates, params = [], [] + if super_admin_wxids is not None: + updates.append("super_admin_wxids = ?") + params.append(json.dumps([str(x).strip() for x in super_admin_wxids if str(x).strip()], ensure_ascii=False)) + if whitelist_wxids is not None: + updates.append("whitelist_wxids = ?") + params.append(json.dumps([str(x).strip() for x in whitelist_wxids if str(x).strip()], ensure_ascii=False)) + if updates: + params.append(key) + conn.execute(f"UPDATE ai_reply_config SET {', '.join(updates)} WHERE key = ?", params) + conn.commit() + cur = conn.execute("SELECT * FROM ai_reply_config WHERE key = ?", (key,)) + return _row_to_dict(cur.fetchone()) + super_admin_wxids = [str(x).strip() for x in (super_admin_wxids or []) if str(x).strip()] + whitelist_wxids = [str(x).strip() for x in (whitelist_wxids or []) if str(x).strip()] + conn.execute( + "INSERT INTO ai_reply_config (key, super_admin_wxids, whitelist_wxids) VALUES (?,?,?)", + (key, json.dumps(super_admin_wxids, ensure_ascii=False), json.dumps(whitelist_wxids, ensure_ascii=False)) + ) + conn.commit() + cur = conn.execute("SELECT * FROM ai_reply_config WHERE key = ?", (key,)) + return _row_to_dict(cur.fetchone()) + finally: + conn.close() diff --git a/public/chat.html b/public/chat.html index f0ede80..1b5b40e 100644 --- a/public/chat.html +++ b/public/chat.html @@ -27,11 +27,14 @@ .nav { display: flex; align-items: center; - gap: 16px; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: rgba(2, 6, 23, 0.95); } + .nav-links { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .nav a { color: var(--muted); text-decoration: none; @@ -39,6 +42,8 @@ } .nav a:hover { color: var(--accent); } .nav a.current { color: var(--accent); font-weight: 500; } + .nav-banner { font-size: 18px; font-weight: 700; letter-spacing: 0.04em; color: var(--text); text-shadow: 0 0 20px rgba(34, 197, 94, 0.2); } + .nav-banner span { color: var(--accent); } .container { max-width: 720px; margin: 0 auto; @@ -64,12 +69,17 @@ padding: 8px; margin-bottom: 16px; } - .msg-item { font-size: 12px; padding: 8px 10px; border-bottom: 1px solid var(--border); } + .msg-item { font-size: 12px; padding: 8px 10px; border-bottom: 1px solid var(--border); display:flex; flex-direction:column; gap:4px; } .msg-item:last-child { border-bottom: none; } .msg-item .from { color: var(--accent); margin-right: 8px; } .msg-item.out { opacity: 0.9; } .msg-item.out .from { color: #94a3b8; } .msg-item .time { font-size: 11px; color: var(--muted); margin-left: 8px; } + .msg-item .meta { display:flex; align-items:center; flex-wrap:wrap; } + .msg-item .content { margin-left: 0; font-size: 12px; word-break: break-all; } + .msg-item .content img { max-width: 100%; border-radius: 6px; margin-top: 4px; } + .msg-item .content audio, + .msg-item .content video { max-width: 100%; margin-top: 4px; } .form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 10px; } .form-row label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; } .form-row input { padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: rgba(15,23,42,0.9); color: var(--text); font-size: 13px; } @@ -81,10 +91,13 @@
@@ -117,42 +130,119 @@ const API_BASE = 'http://localhost:8000'; const KEY_STORAGE = 'wechat_key'; - function getKey() { - const k = $('key').value.trim(); - if (!k) { alert('请先填写账号 key'); return null; } - return k; + function getToken() { + try { + return localStorage.getItem('auth_token') || ''; + } catch (_) { + return ''; + } + } + + function redirectToLogin(msg) { + if (msg) alert(msg); + window.location.href = 'index.html'; } async function callApi(path, options = {}) { const url = API_BASE + path; - const res = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } }); + const token = getToken(); + const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; + if (token) headers['Authorization'] = 'Bearer ' + token; + + const res = await fetch(url, { ...options, headers }); let body = null; try { body = await res.json(); } catch (_) {} + if (res.status === 401) { + redirectToLogin('登录已失效,请重新扫码登录'); + throw new Error('unauthorized'); + } if (!res.ok) throw new Error((body && (body.detail || body.message)) || res.statusText || '请求失败'); return body; } + function renderMessageContent(m) { + const msgType = m.MsgType ?? m.msgType; + const rawContent = m.Content || m.content || ''; + const imageContent = m.ImageContent || m.imageContent || ''; + const from = m.FromUserName || m.from || m.MsgId || '-'; + + // 链接检测 + const isUrl = (s) => typeof s === 'string' && /^https?:\/\//i.test(s.trim()); + const isBase64Like = (s) => typeof s === 'string' && /^[A-Za-z0-9+/=\s]+$/.test(s) && s.replace(/\s+/g, '').length > 60; + + // 图片:上游通常提供 ImageContent(base64)或 Content 为图片链接 + if (imageContent || (msgType === 3) || (msgType === 0 && imageContent)) { + const src = isUrl(imageContent) ? imageContent : + (imageContent ? ('data:image/png;base64,' + imageContent.replace(/\s+/g, '')) : + (isUrl(rawContent) ? rawContent : '')); + if (src) { + return `
${rawContent ? String(rawContent) : ''}
图片消息
`; + } + } + + // 视频 / 音频:简单通过扩展名或 MsgType 约定判断 + if (msgType === 43 || msgType === 'video') { + const src = isUrl(rawContent) ? rawContent : ''; + if (src) return `
`; + } + if (msgType === 34 || msgType === 'audio') { + const src = isUrl(rawContent) ? rawContent : ''; + if (src) return `
`; + } + + // 若内容是 URL,则渲染为可点击链接 + if (isUrl(rawContent)) { + const safe = String(rawContent); + return ``; + } + + // 若内容看起来是图片 base64,则按图片渲染 + if (isBase64Like(rawContent) && (msgType === 3 || msgType === 0)) { + const src = 'data:image/png;base64,' + String(rawContent).replace(/\s+/g, ''); + return `
图片消息
`; + } + + // 兜底为纯文本(含 MsgType 提示) + const text = rawContent ? String(rawContent) : (msgType != null ? `MsgType=${msgType}` : ''); + return `
${text}
`; + } + async function loadMessages() { - const key = $('key').value.trim(); - if (!key) { $('message-list').innerHTML = '

请先填写账号 key。

'; return; } + const key = (function() { + try { + return localStorage.getItem(KEY_STORAGE) || ''; + } catch (_) { + return ''; + } + })(); + if (!key) { + $('message-list').innerHTML = '

请先在登录页扫码登录。

'; + return; + } try { + // 后端已按时间倒序(最新在前)返回,这里保持顺序即可 const data = await callApi('/api/messages?key=' + encodeURIComponent(key) + '&limit=80'); const list = data.items || []; - // 按时间正序排列,最新在底部,便于看完整对话 - const sorted = [...list].sort((a, b) => (a.CreateTime || 0) - (b.CreateTime || 0)); - $('message-list').innerHTML = sorted.length ? sorted.map(m => { + $('message-list').innerHTML = list.length ? list.map(m => { const isOut = m.direction === 'out'; - const from = isOut ? ('我 → ' + (m.ToUserName || '')) : (m.FromUserName || m.from || m.MsgId || '-').toString().slice(0, 32); - const content = (m.Content || m.content || m.MsgType || '').toString().slice(0, 200); - const time = m.CreateTime ? (typeof m.CreateTime === 'number' ? new Date(m.CreateTime * 1000).toLocaleTimeString('zh-CN', { hour12: false }) : m.CreateTime) : ''; - return '
' + from + '' + content + (time ? ' ' + time + '' : '') + '
'; - }).join('') : '

暂无对话。请确保已登录且后端 WS 已连接 GetSyncMsg;发送的消息也会在此展示。

'; - } catch (e) { $('message-list').innerHTML = '

加载失败: ' + e.message + '

'; } + const fromLabel = isOut ? ('我 → ' + (m.ToUserName || '')) : (m.FromUserName || m.from || m.MsgId || '-').toString().slice(0, 32); + const time = m.CreateTime ? (typeof m.CreateTime === 'number' + ? new Date(m.CreateTime * 1000).toLocaleTimeString('zh-CN', { hour12: false }) + : m.CreateTime) : ''; + const meta = '
' + fromLabel + '' + (time ? '' + time + '' : '') + '
'; + const body = renderMessageContent(m); + return '
' + meta + body + '
'; + }).join('') : '

暂无对话。请确保已登录且后端 WS 已连接 GetSyncMsg;发送的消息(含图片、音视频等)也会在此展示。

'; + } catch (e) { + $('message-list').innerHTML = '

加载失败: ' + e.message + '

'; + } } async function sendMessage() { - const key = getKey(); - if (!key) return; + const key = (function() { + try { return localStorage.getItem(KEY_STORAGE) || ''; } catch (_) { return ''; } + })(); + if (!key) { alert('请先在登录页扫码登录'); return; } const to = $('send-to').value.trim(); const content = $('send-content').value.trim(); if (!to || !content) { alert('请填写对方用户名和内容'); return; } @@ -163,10 +253,14 @@ } catch (e) { alert('发送失败: ' + e.message); } } + // 隐藏 key 行,仅内部使用 localStorage 中的 key + (function hideKeyRow() { + const row = document.querySelector('.key-row'); + if (row) row.style.display = 'none'; + })(); + $('btn-refresh-msg').addEventListener('click', loadMessages); $('btn-send-msg').addEventListener('click', sendMessage); - $('key').addEventListener('change', function() { try { localStorage.setItem(KEY_STORAGE, this.value.trim()); } catch (_) {} }); - if (typeof localStorage !== 'undefined' && localStorage.getItem(KEY_STORAGE)) $('key').value = localStorage.getItem(KEY_STORAGE); loadMessages(); (function wsStatusCheck() { diff --git a/public/index.html b/public/index.html index 91ed3e4..62711ac 100644 --- a/public/index.html +++ b/public/index.html @@ -510,6 +510,7 @@

Wechat 智能托管服务

+

API 文档 (Swagger)

diff --git a/public/manage.html b/public/manage.html index 41e388e..22f402c 100644 --- a/public/manage.html +++ b/public/manage.html @@ -27,11 +27,14 @@ .nav { display: flex; align-items: center; - gap: 16px; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; padding: 12px 24px; border-bottom: 1px solid var(--border); background: rgba(2, 6, 23, 0.95); } + .nav-links { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .nav a { color: var(--muted); text-decoration: none; @@ -39,6 +42,14 @@ } .nav a:hover { color: var(--accent); } .nav a.current { color: var(--accent); font-weight: 500; } + .nav-banner { + font-size: 18px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text); + text-shadow: 0 0 20px rgba(34, 197, 94, 0.2); + } + .nav-banner span { color: var(--accent); } .container { max-width: 960px; margin: 0 auto; @@ -77,8 +88,6 @@ .tag { display: inline-block; padding: 4px 10px; border-radius: 999px; background: rgba(30,41,59,0.9); border: 1px solid var(--border); font-size: 12px; margin-right: 6px; margin-bottom: 6px; } .primary { padding: 10px 20px; border-radius: 8px; background: var(--accent); color: #000; border: none; cursor: pointer; font-weight: 500; } .secondary { padding: 8px 16px; border-radius: 8px; background: rgba(30,41,59,0.9); border: 1px solid var(--border); color: var(--text); cursor: pointer; font-size: 13px; } - .key-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } - .key-row input { max-width: 280px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: rgba(15,23,42,0.9); color: var(--text); } .error-msg { color: #f87171; font-size: 12px; } .tags-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; } .tags-chips .chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; background: var(--accent-soft); border: 1px solid var(--accent); font-size: 12px; color: var(--accent); } @@ -88,26 +97,35 @@
-
客户与消息管理(R1-2 / R1-3 / R1-4)
-
- - -
+
客户与消息管理
+
+
-
+
+ + +
从联系人选择(多选可批量填入)
+
+ + +
+
@@ -127,7 +145,7 @@
- +
@@ -140,6 +158,27 @@
+
快速群发(选好友/客户后一键分发)
+
+
+ +
+ + 已选 0 人 +
+
+
+
+ +
+
发送图片消息(快捷方式)
+
+
+
+
+ +
+
商品标签
@@ -157,6 +196,21 @@
+
+

分级处理:仅超级管理员白名单中的联系人会收到 AI 自动回复,其他消息一律不回复。

+
+
+ + +
+
+ + +
+ +
+

保存后,仅上述列表中的发信人会被 AI 接管回复;未在列表中的联系人发来的消息将不会触发自动回复。

+
+ + + diff --git a/run-docker.sh b/run-docker.sh index 3acb5db..54c5df9 100755 --- a/run-docker.sh +++ b/run-docker.sh @@ -5,6 +5,8 @@ set -e IMAGE_NAME="wechat-admin-backend" CONTAINER_NAME="wechat-admin-backend" PORT="${PORT:-3000}" +# 数据目录挂载到宿主机,防止容器删除后丢失(SQLite 库 wechat.db 及表数据) +HOST_DATA_DIR="${HOST_DATA_DIR:-$(pwd)/data}" echo "Building Docker image: ${IMAGE_NAME}..." docker build -t "${IMAGE_NAME}" . @@ -14,6 +16,9 @@ if [ "$(docker ps -aq -f name=${CONTAINER_NAME})" ]; then docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true fi +mkdir -p "${HOST_DATA_DIR}" +echo "Data dir (host): ${HOST_DATA_DIR} -> container /app/backend/data" + ENV_FILE=".env" if [ ! -f "${ENV_FILE}" ]; then echo "Env file ${ENV_FILE} not found, copying from .env.example ..." @@ -26,7 +31,9 @@ docker run -d \ --env-file "${ENV_FILE}" \ -p "${PORT}:3000" \ -p "8000:8000" \ + -v "${HOST_DATA_DIR}:/app/backend/data" \ "${IMAGE_NAME}" -echo "Container started. Health check: curl http://localhost:${PORT}/health" +echo "Container started. Data persisted on host: ${HOST_DATA_DIR}" +echo "Health check: curl http://localhost:${PORT}/health"