fix:优化界面
This commit is contained in:
19
backend/Dockerfile
Normal file
19
backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM docker.m.daocloud.io/library/python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend ./backend
|
||||
|
||||
ENV WECHAT_UPSTREAM_BASE_URL=http://your-wechat-server-host:port
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# WeChat Admin Backend
|
||||
15
backend/data/customers.json
Normal file
15
backend/data/customers.json
Normal file
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"id": "50ddd99c-f369-4baf-b875-c3ce80d851d8",
|
||||
"key": "HBpEnbtj9BJZ",
|
||||
"wxid": "zhang499142409",
|
||||
"remark_name": "测试",
|
||||
"region": "上海",
|
||||
"age": "20",
|
||||
"gender": "男",
|
||||
"level": "A",
|
||||
"tags": [
|
||||
"准客户"
|
||||
]
|
||||
}
|
||||
]
|
||||
26
backend/data/greeting_tasks.json
Normal file
26
backend/data/greeting_tasks.json
Normal file
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": "b95661cc-3f4a-4a0c-a924-dd2042996596",
|
||||
"key": "HBpEnbtj9BJZ",
|
||||
"name": "问好",
|
||||
"send_time": "2026-03-10T23:15:30",
|
||||
"customer_tags": [
|
||||
"准客户"
|
||||
],
|
||||
"template": "你好,这是一个测试",
|
||||
"use_qwen": true,
|
||||
"enabled": false,
|
||||
"executed_at": "2026-03-10T23:29:53.900871"
|
||||
},
|
||||
{
|
||||
"id": "3aa6f313-9cf9-467e-8957-dd80cf814e64",
|
||||
"key": "HBpEnbtj9BJZ",
|
||||
"name": "问好",
|
||||
"send_time": "2026-03-10T23:30:45",
|
||||
"customer_tags": [],
|
||||
"template": "你好,上新了",
|
||||
"use_qwen": true,
|
||||
"enabled": false,
|
||||
"executed_at": "2026-03-10T23:30:54.329333"
|
||||
}
|
||||
]
|
||||
7
backend/data/product_tags.json
Normal file
7
backend/data/product_tags.json
Normal file
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"id": "18d3c34a-816b-42e0-9b03-bb07cd76554a",
|
||||
"key": "HBpEnbtj9BJZ",
|
||||
"name": "外贸款"
|
||||
}
|
||||
]
|
||||
1178
backend/data/sync_messages.json
Normal file
1178
backend/data/sync_messages.json
Normal file
File diff suppressed because one or more lines are too long
123
backend/llm_client.py
Normal file
123
backend/llm_client.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""多模型统一调用:从 store 读取当前模型配置(API Key、base_url、model_name),按 OpenAI 兼容或豆包 Responses API 请求。"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger("wechat-backend.llm")
|
||||
|
||||
try:
|
||||
from backend import store
|
||||
except ImportError:
|
||||
import store
|
||||
|
||||
|
||||
def _doubao_input_from_messages(messages: List[dict]) -> list:
|
||||
"""将 [{"role":"user","content":"..."}] 转为豆包 Responses API 的 input 格式。"""
|
||||
result = []
|
||||
for m in messages:
|
||||
role = (m.get("role") or "user").lower()
|
||||
if role == "system":
|
||||
role = "user"
|
||||
content = m.get("content")
|
||||
if isinstance(content, str):
|
||||
content = [{"type": "input_text", "text": content}]
|
||||
elif isinstance(content, list):
|
||||
# 已是多模态格式,或需转为 input_text
|
||||
out = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
if item.get("type") == "input_text":
|
||||
out.append(item)
|
||||
elif "text" in item:
|
||||
out.append({"type": "input_text", "text": item["text"]})
|
||||
else:
|
||||
out.append({"type": "input_text", "text": str(item)})
|
||||
content = out if out else [{"type": "input_text", "text": ""}]
|
||||
else:
|
||||
content = [{"type": "input_text", "text": str(content or "")}]
|
||||
result.append({"role": role, "content": content})
|
||||
return result
|
||||
|
||||
|
||||
async def _chat_doubao(api_key: str, base_url: str, model_name: str, messages: List[dict]) -> Optional[str]:
|
||||
"""豆包(Volcengine ARK)Responses API:POST /responses,input 格式见示例。"""
|
||||
import httpx
|
||||
url = base_url.rstrip("/") + "/responses"
|
||||
payload = {"model": model_name, "input": _doubao_input_from_messages(messages)}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
r = await client.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.warning("doubao API status=%s body=%s", r.status_code, r.text[:500])
|
||||
return None
|
||||
data = r.json()
|
||||
# 常见返回结构:output.output_text / output.text / choices[0].message.content
|
||||
output = data.get("output") or data
|
||||
if isinstance(output, dict):
|
||||
text = output.get("output_text") or output.get("text")
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
choices = output.get("choices")
|
||||
if choices and len(choices) > 0:
|
||||
msg = choices[0].get("message") or choices[0]
|
||||
if isinstance(msg, dict) and msg.get("content"):
|
||||
return msg["content"]
|
||||
if "choices" in data and data["choices"]:
|
||||
c = data["choices"][0]
|
||||
msg = c.get("message", c)
|
||||
if isinstance(msg, dict) and msg.get("content"):
|
||||
return msg["content"]
|
||||
logger.warning("doubao unknown response shape: %s", list(data.keys()))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception("doubao chat error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def chat(messages: List[dict], model_id: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
使用已配置的模型进行对话。
|
||||
model_id: 若指定则用该模型,否则用当前选中的模型。
|
||||
返回 assistant 回复文本。
|
||||
"""
|
||||
if model_id:
|
||||
model_config = store.get_model(model_id)
|
||||
else:
|
||||
model_config = store.get_current_model()
|
||||
if not model_config or not model_config.get("api_key"):
|
||||
logger.warning("No model configured or no api_key")
|
||||
return None
|
||||
api_key = model_config["api_key"]
|
||||
base_url = (model_config.get("base_url") or "").strip()
|
||||
model_name = (model_config.get("model_name") or "qwen-turbo").strip()
|
||||
provider = (model_config.get("provider") or "openai").lower()
|
||||
if not base_url and provider == "qwen":
|
||||
base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
if not base_url and provider == "doubao":
|
||||
base_url = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
if not base_url:
|
||||
base_url = "https://api.openai.com/v1"
|
||||
# 豆包:火山方舟支持 chat/completions 兼容接口,优先走统一 OpenAI 客户端;若需 Responses API 可走 _chat_doubao
|
||||
if provider == "doubao":
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
r = await client.chat.completions.create(model=model_name, messages=messages)
|
||||
if r.choices and r.choices[0].message.content:
|
||||
return r.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.info("doubao chat/completions failed, try responses API: %s", e)
|
||||
return await _chat_doubao(api_key, base_url, model_name, messages)
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
r = await client.chat.completions.create(model=model_name, messages=messages)
|
||||
text = r.choices[0].message.content if r.choices else None
|
||||
return text
|
||||
except Exception as e:
|
||||
logger.exception("llm chat error: %s", e)
|
||||
return None
|
||||
744
backend/main.py
Normal file
744
backend/main.py
Normal file
@@ -0,0 +1,744 @@
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
try:
|
||||
from backend import store
|
||||
from backend.llm_client import chat as llm_chat
|
||||
from backend.ws_sync import is_ws_connected, set_message_callback, start_ws_sync
|
||||
except ImportError:
|
||||
import store
|
||||
from llm_client import chat as llm_chat
|
||||
from ws_sync import is_ws_connected, set_message_callback, start_ws_sync
|
||||
|
||||
WECHAT_UPSTREAM_BASE_URL = os.getenv("WECHAT_UPSTREAM_BASE_URL", "http://localhost:8080").rstrip("/")
|
||||
CHECK_STATUS_BASE_URL = os.getenv("CHECK_STATUS_BASE_URL", "http://113.44.162.180:7006").rstrip("/")
|
||||
SLIDER_VERIFY_BASE_URL = os.getenv("SLIDER_VERIFY_BASE_URL", "http://113.44.162.180:7765").rstrip("/")
|
||||
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()
|
||||
|
||||
# 按 key 缓存取码结果与 Data62,供后续步骤使用
|
||||
qrcode_store: dict = {}
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("wechat-backend")
|
||||
|
||||
|
||||
def _on_ws_message(key: str, data: dict) -> None:
|
||||
"""GetSyncMsg 收到数据时:写入 store,便于前端拉取与自动回复逻辑使用。"""
|
||||
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)
|
||||
elif isinstance(data, list):
|
||||
store.append_sync_messages(key, data)
|
||||
else:
|
||||
store.append_sync_messages(key, [data])
|
||||
|
||||
|
||||
async def _run_greeting_scheduler() -> None:
|
||||
"""定时检查到期问候任务,通过发送消息接口向匹配客户发送,并标记已执行。"""
|
||||
check_interval = 30
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(check_interval)
|
||||
now = datetime.now()
|
||||
all_tasks = store.list_greeting_tasks(key=None)
|
||||
for task in all_tasks:
|
||||
if not task.get("enabled"):
|
||||
continue
|
||||
if task.get("executed_at"):
|
||||
continue
|
||||
send_time = task.get("send_time") or task.get("cron")
|
||||
if not send_time:
|
||||
continue
|
||||
dt = _parse_send_time(send_time)
|
||||
if not dt or dt > now:
|
||||
continue
|
||||
task_id = task.get("id")
|
||||
key = task.get("key")
|
||||
customer_tags = set(task.get("customer_tags") or [])
|
||||
template = (task.get("template") or "").strip() or "{{name}},您好!"
|
||||
use_qwen = bool(task.get("use_qwen"))
|
||||
customers = store.list_customers(key)
|
||||
if customer_tags:
|
||||
customers = [c for c in customers if set(c.get("tags") or []) & customer_tags]
|
||||
for c in customers:
|
||||
wxid = c.get("wxid")
|
||||
if not wxid:
|
||||
continue
|
||||
remark_name = (c.get("remark_name") or "").strip() or wxid
|
||||
if use_qwen:
|
||||
user = f"请生成一句简短的微信问候语(1-2句话),客户备注名:{remark_name}"
|
||||
region = (c.get("region") or "").strip()
|
||||
if region:
|
||||
user += f",地区:{region}"
|
||||
tags = c.get("tags") or []
|
||||
if tags:
|
||||
user += f",标签:{','.join(tags)}"
|
||||
user += "。不要解释,只输出问候语本身。"
|
||||
try:
|
||||
content = await llm_chat([{"role": "user", "content": user}])
|
||||
except Exception as e:
|
||||
logger.warning("Greeting task %s llm_chat error: %s", task_id, e)
|
||||
content = template.replace("{{name}}", remark_name)
|
||||
if not content or not content.strip():
|
||||
content = template.replace("{{name}}", remark_name)
|
||||
else:
|
||||
content = template.replace("{{name}}", remark_name)
|
||||
try:
|
||||
await _send_message_upstream(key, wxid, content)
|
||||
logger.info("Greeting task %s sent to %s", task_id, wxid)
|
||||
except Exception as e:
|
||||
logger.warning("Greeting task %s send to %s failed: %s", task_id, wxid, e)
|
||||
store.update_greeting_task(task_id, executed_at=now.isoformat(), enabled=False)
|
||||
logger.info("Greeting task %s executed_at set", task_id)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception("Greeting scheduler error: %s", e)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
set_message_callback(_on_ws_message)
|
||||
asyncio.create_task(start_ws_sync())
|
||||
scheduler = asyncio.create_task(_run_greeting_scheduler())
|
||||
yield
|
||||
scheduler.cancel()
|
||||
try:
|
||||
await scheduler
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="WeChat Admin Backend (FastAPI)", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class QrCodeRequest(BaseModel):
|
||||
key: str
|
||||
Proxy: Optional[str] = ""
|
||||
IpadOrmac: Optional[str] = ""
|
||||
Check: Optional[bool] = False
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
logger.info("HTTP %s %s from %s", request.method, request.url.path, request.client.host if request.client else "-")
|
||||
response = await call_next(request)
|
||||
logger.info("HTTP %s %s -> %s", request.method, request.url.path, response.status_code)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
logger.info("Health check")
|
||||
return {"status": "ok", "backend": "fastapi", "upstream": WECHAT_UPSTREAM_BASE_URL}
|
||||
|
||||
|
||||
@app.get("/api/ws-status")
|
||||
async def api_ws_status() -> dict:
|
||||
"""WS(GetSyncMsg)连接状态,供前端在掉线时跳转登录页。"""
|
||||
return {"connected": is_ws_connected()}
|
||||
|
||||
|
||||
@app.post("/auth/qrcode")
|
||||
async def get_login_qrcode(body: QrCodeRequest):
|
||||
key = body.key
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="key is required")
|
||||
|
||||
payload = body.dict(exclude={"key"})
|
||||
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/GetLoginQrCodeNewDirect"
|
||||
logger.info("GetLoginQrCodeNewDirect: key=%s, payload=%s, url=%s", key, payload, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
resp = await client.post(url, params={"key": key}, json=payload)
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream GetLoginQrCodeNewDirect: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"error": "upstream_connect_error", "detail": str(exc)},
|
||||
) from exc
|
||||
|
||||
body_text = resp.text[:500]
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"Upstream GetLoginQrCodeNewDirect bad response: status=%s, body=%s",
|
||||
resp.status_code,
|
||||
body_text,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "upstream_bad_response",
|
||||
"status_code": resp.status_code,
|
||||
"body": body_text,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Upstream GetLoginQrCodeNewDirect success: status=%s, body=%s",
|
||||
resp.status_code,
|
||||
body_text,
|
||||
)
|
||||
data = resp.json()
|
||||
# 第一步:记录完整返回并保存 Data62,供第二步滑块自动填充参数
|
||||
try:
|
||||
data62 = data.get("Data62") or (data.get("Data") or {}).get("data62") or ""
|
||||
qrcode_store[key] = {"data62": data62, "response": data}
|
||||
# 在返回中拼接已存储标记,便于后续步骤使用同一 key 取 data62
|
||||
data["_data62_stored"] = True
|
||||
data["_data62_length"] = len(data62)
|
||||
logger.info("Stored Data62 for key=%s (len=%s)", key, len(data62))
|
||||
except Exception as e:
|
||||
logger.warning("Store qrcode data for key=%s failed: %s", key, e)
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/auth/status")
|
||||
async def get_online_status(
|
||||
key: str = Query(..., description="账号唯一标识"),
|
||||
):
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="key is required")
|
||||
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/GetLoginStatus"
|
||||
logger.info("GetLoginStatus: key=%s, url=%s", key, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(url, params={"key": key})
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream GetLoginStatus: %s", exc)
|
||||
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
|
||||
|
||||
body_text = resp.text[:500]
|
||||
logger.info(
|
||||
"Upstream GetLoginStatus response: status=%s, body=%s",
|
||||
resp.status_code,
|
||||
body_text,
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _extract_clean_ticket(obj: dict) -> Optional[str]:
|
||||
"""从扫码状态返回中提取 ticket,去掉乱码(只保留可见 ASCII 到第一个非法字符前)。"""
|
||||
if not obj or not isinstance(obj, dict):
|
||||
return None
|
||||
d = obj.get("Data") if isinstance(obj.get("Data"), dict) else obj
|
||||
raw = (
|
||||
(d.get("ticket") if d else None)
|
||||
or obj.get("ticket")
|
||||
or obj.get("Ticket")
|
||||
)
|
||||
if not raw:
|
||||
wvu = obj.get("wechat_verify_url") or ""
|
||||
if isinstance(wvu, str) and "ticket=" in wvu:
|
||||
raw = wvu.split("ticket=", 1)[1].split("&")[0]
|
||||
if not raw or not isinstance(raw, str):
|
||||
return None
|
||||
clean = []
|
||||
for ch in raw:
|
||||
code = ord(ch)
|
||||
if code == 0xFFFD or code < 32 or code > 126:
|
||||
break
|
||||
clean.append(ch)
|
||||
return "".join(clean) if clean else None
|
||||
|
||||
|
||||
@app.get("/auth/scan-status")
|
||||
async def check_scan_status(
|
||||
key: str = Query(..., description="账号唯一标识"),
|
||||
):
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="key is required")
|
||||
|
||||
url = f"{CHECK_STATUS_BASE_URL}/login/CheckLoginStatus"
|
||||
logger.info("CheckLoginStatus: key=%s, url=%s", key, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(url, params={"key": key})
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream CheckLoginStatus: %s", exc)
|
||||
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
|
||||
|
||||
body_full = resp.text
|
||||
logger.info(
|
||||
"Upstream CheckLoginStatus response: status=%s, body=%s",
|
||||
resp.status_code,
|
||||
body_full[:2000] if len(body_full) > 2000 else body_full,
|
||||
)
|
||||
data = resp.json()
|
||||
ticket = _extract_clean_ticket(data)
|
||||
if ticket:
|
||||
# 不调用滑块服务;返回自带预填表单的页面 path,iframe 加载后自动填充 Key/Data62/Original Ticket,用户点「开始验证」提交到第三方 7765
|
||||
stored = qrcode_store.get(key) or {}
|
||||
data62 = stored.get("data62") or ""
|
||||
params = {"key": SLIDER_VERIFY_KEY, "ticket": ticket}
|
||||
if data62:
|
||||
params["data62"] = data62
|
||||
data["slider_url"] = f"/auth/slider-form?{urlencode(params)}"
|
||||
logger.info(
|
||||
"Attached slider_url (slider-form) for key=%s (ticket len=%s, data62 len=%s)",
|
||||
key,
|
||||
len(ticket),
|
||||
len(data62),
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _slider_form_html(key_val: str, data62_val: str, ticket_val: str) -> str:
|
||||
"""生成滑块表单页:Key、Data62、Original Ticket 已预填,提交到第三方 7765。"""
|
||||
k = html.escape(key_val, quote=True)
|
||||
d = html.escape(data62_val, quote=True)
|
||||
t = html.escape(ticket_val, quote=True)
|
||||
action = html.escape(SLIDER_VERIFY_BASE_URL, quote=True)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>滑块验证</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; background: #f0f0f0; margin: 20px; }}
|
||||
.card {{ background: #fff; border-radius: 8px; padding: 20px; max-width: 480px; margin: 0 auto; box-shadow: 0 1px 3px rgba(0,0,0,.1); }}
|
||||
h2 {{ margin-top: 0; }}
|
||||
label {{ display: block; margin: 10px 0 4px; color: #333; }}
|
||||
input {{ width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }}
|
||||
button {{ margin-top: 16px; padding: 10px 20px; background: #07c160; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }}
|
||||
button:hover {{ background: #06ad56; }}
|
||||
.hint {{ font-size: 12px; color: #888; margin-top: 12px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>滑块验证</h2>
|
||||
<form id="f" action="{action}" method="get" target="_top">
|
||||
<label>Key:</label>
|
||||
<input type="text" name="key" value="{k}" placeholder="请输入key" />
|
||||
<label>Data62:</label>
|
||||
<input type="text" name="data62" value="{d}" placeholder="请输入data62" />
|
||||
<label>Original Ticket:</label>
|
||||
<input type="text" name="original_ticket" value="{t}" placeholder="请输入original_ticket" />
|
||||
<button type="submit">开始验证</button>
|
||||
</form>
|
||||
<p class="hint">参数已自动填充,点击「开始验证」将提交到第三方滑块服务。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.get("/auth/slider-form", response_class=HTMLResponse)
|
||||
async def slider_form(
|
||||
key: str = Query(..., description="Key(提交到第三方滑块)"),
|
||||
data62: str = Query("", description="Data62"),
|
||||
ticket: str = Query(..., description="Original Ticket"),
|
||||
):
|
||||
"""返回带 Key/Data62/Original Ticket 预填的表单页,提交到第三方 7765,供 iframe 加载并自动填充。"""
|
||||
return HTMLResponse(content=_slider_form_html(key, data62, ticket))
|
||||
|
||||
|
||||
# ---------- R1-2 客户画像 / R1-3 定时问候 / R1-4 分群推送 / 消息与发送 ----------
|
||||
|
||||
class CustomerCreate(BaseModel):
|
||||
key: str
|
||||
wxid: str
|
||||
remark_name: Optional[str] = ""
|
||||
region: Optional[str] = ""
|
||||
age: Optional[str] = ""
|
||||
gender: Optional[str] = ""
|
||||
level: Optional[str] = "" # 拿货等级
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class GreetingTaskCreate(BaseModel):
|
||||
key: str
|
||||
name: str
|
||||
send_time: str # ISO 格式触发时间,如 2026-03-11T14:30:00,必须为未来时间
|
||||
customer_tags: Optional[List[str]] = None
|
||||
template: str
|
||||
use_qwen: Optional[bool] = False
|
||||
|
||||
|
||||
class ProductTagCreate(BaseModel):
|
||||
key: str
|
||||
name: str
|
||||
|
||||
|
||||
class PushGroupCreate(BaseModel):
|
||||
key: str
|
||||
name: str
|
||||
customer_ids: Optional[List[str]] = None
|
||||
tag_ids: Optional[List[str]] = None
|
||||
|
||||
|
||||
class PushTaskCreate(BaseModel):
|
||||
key: str
|
||||
product_tag_id: str
|
||||
group_id: str
|
||||
content: str
|
||||
send_at: Optional[str] = None
|
||||
|
||||
|
||||
class SendMessageBody(BaseModel):
|
||||
key: str
|
||||
to_user_name: str
|
||||
content: str
|
||||
|
||||
|
||||
class QwenGenerateBody(BaseModel):
|
||||
prompt: str
|
||||
system: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/api/customers")
|
||||
async def api_list_customers(key: str = Query(..., description="账号 key")):
|
||||
return {"items": store.list_customers(key)}
|
||||
|
||||
|
||||
@app.post("/api/customers")
|
||||
async def api_upsert_customer(body: CustomerCreate):
|
||||
row = store.upsert_customer(
|
||||
body.key, body.wxid,
|
||||
remark_name=body.remark_name or "",
|
||||
region=body.region or "",
|
||||
age=body.age or "",
|
||||
gender=body.gender or "",
|
||||
level=body.level or "",
|
||||
tags=body.tags,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
@app.get("/api/customers/{customer_id}")
|
||||
async def api_get_customer(customer_id: str):
|
||||
row = store.get_customer(customer_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="customer not found")
|
||||
return row
|
||||
|
||||
|
||||
@app.get("/api/customer-tags")
|
||||
async def api_list_customer_tags(key: str = Query(..., description="账号 key")):
|
||||
"""返回该 key 下客户档案中出现的所有标签,供定时任务等下拉选择。"""
|
||||
return {"tags": store.list_customer_tags(key)}
|
||||
|
||||
|
||||
@app.delete("/api/customers/{customer_id}")
|
||||
async def api_delete_customer(customer_id: str):
|
||||
if not store.delete_customer(customer_id):
|
||||
raise HTTPException(status_code=404, detail="customer not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/greeting-tasks")
|
||||
async def api_list_greeting_tasks(key: str = Query(..., description="账号 key")):
|
||||
return {"items": store.list_greeting_tasks(key)}
|
||||
|
||||
|
||||
def _parse_send_time(s: str) -> Optional[datetime]:
|
||||
"""解析 ISO 时间字符串,返回 datetime(无时区)。"""
|
||||
try:
|
||||
if "T" in s:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")[:19])
|
||||
return datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@app.post("/api/greeting-tasks")
|
||||
async def api_create_greeting_task(body: GreetingTaskCreate):
|
||||
dt = _parse_send_time(body.send_time)
|
||||
if not dt:
|
||||
raise HTTPException(status_code=400, detail="触发时间格式无效,请使用 日期+时分秒 选择器")
|
||||
if dt <= datetime.now():
|
||||
raise HTTPException(status_code=400, detail="触发时间必须是未来时间,请重新选择")
|
||||
row = store.create_greeting_task(
|
||||
body.key, body.name, body.send_time,
|
||||
customer_tags=body.customer_tags or [],
|
||||
template=body.template,
|
||||
use_qwen=body.use_qwen or False,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
@app.patch("/api/greeting-tasks/{task_id}")
|
||||
async def api_update_greeting_task(task_id: str, body: dict):
|
||||
if "send_time" in body:
|
||||
dt = _parse_send_time(body["send_time"])
|
||||
if not dt:
|
||||
raise HTTPException(status_code=400, detail="触发时间格式无效")
|
||||
if dt <= datetime.now():
|
||||
raise HTTPException(status_code=400, detail="触发时间必须是未来时间")
|
||||
row = store.update_greeting_task(task_id, **{k: v for k, v in body.items() if k in ("name", "send_time", "customer_tags", "template", "use_qwen", "enabled")})
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
return row
|
||||
|
||||
|
||||
@app.delete("/api/greeting-tasks/{task_id}")
|
||||
async def api_delete_greeting_task(task_id: str):
|
||||
if not store.delete_greeting_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/product-tags")
|
||||
async def api_list_product_tags(key: str = Query(..., description="账号 key")):
|
||||
return {"items": store.list_product_tags(key)}
|
||||
|
||||
|
||||
@app.post("/api/product-tags")
|
||||
async def api_create_product_tag(body: ProductTagCreate):
|
||||
return store.create_product_tag(body.key, body.name)
|
||||
|
||||
|
||||
@app.delete("/api/product-tags/{tag_id}")
|
||||
async def api_delete_product_tag(tag_id: str):
|
||||
if not store.delete_product_tag(tag_id):
|
||||
raise HTTPException(status_code=404, detail="tag not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/push-groups")
|
||||
async def api_list_push_groups(key: str = Query(..., description="账号 key")):
|
||||
return {"items": store.list_push_groups(key)}
|
||||
|
||||
|
||||
@app.post("/api/push-groups")
|
||||
async def api_create_push_group(body: PushGroupCreate):
|
||||
return store.create_push_group(body.key, body.name, body.customer_ids or [], body.tag_ids or [])
|
||||
|
||||
|
||||
@app.patch("/api/push-groups/{group_id}")
|
||||
async def api_update_push_group(group_id: str, body: dict):
|
||||
row = store.update_push_group(
|
||||
group_id,
|
||||
name=body.get("name"),
|
||||
customer_ids=body.get("customer_ids"),
|
||||
tag_ids=body.get("tag_ids"),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="group not found")
|
||||
return row
|
||||
|
||||
|
||||
@app.delete("/api/push-groups/{group_id}")
|
||||
async def api_delete_push_group(group_id: str):
|
||||
if not store.delete_push_group(group_id):
|
||||
raise HTTPException(status_code=404, detail="group not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/push-tasks")
|
||||
async def api_list_push_tasks(key: str = Query(..., description="账号 key"), limit: int = Query(100, le=500)):
|
||||
return {"items": store.list_push_tasks(key, limit=limit)}
|
||||
|
||||
|
||||
@app.post("/api/push-tasks")
|
||||
async def api_create_push_task(body: PushTaskCreate):
|
||||
return store.create_push_task(body.key, body.product_tag_id, body.group_id, body.content, body.send_at)
|
||||
|
||||
|
||||
@app.get("/api/messages")
|
||||
async def api_list_messages(key: str = Query(..., description="账号 key"), limit: int = Query(100, le=500)):
|
||||
return {"items": store.list_sync_messages(key, limit=limit)}
|
||||
|
||||
|
||||
async def _send_message_upstream(key: str, to_user_name: str, content: str) -> dict:
|
||||
"""调用上游发送文本消息;成功时写入发出记录并返回响应,失败抛 HTTPException。"""
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL.rstrip('/')}{SEND_MSG_PATH}"
|
||||
payload = {"MsgItem": [{"ToUserName": to_user_name, "MsgType": 1, "TextContent": content}]}
|
||||
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 message 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, content)
|
||||
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:
|
||||
return await _send_message_upstream(body.key, body.to_user_name, body.content)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Send message upstream error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
|
||||
|
||||
|
||||
# ---------- 模型管理(多模型切换,API Key 按模型配置) ----------
|
||||
class ModelCreate(BaseModel):
|
||||
name: str
|
||||
provider: str # qwen | openai
|
||||
api_key: str
|
||||
base_url: Optional[str] = ""
|
||||
model_name: Optional[str] = ""
|
||||
is_current: Optional[bool] = False
|
||||
|
||||
|
||||
class ModelUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
|
||||
|
||||
def _mask_api_key(m: dict) -> dict:
|
||||
if not m or not isinstance(m, dict):
|
||||
return m
|
||||
out = dict(m)
|
||||
if out.get("api_key"):
|
||||
out["api_key"] = "***"
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/models")
|
||||
async def api_list_models():
|
||||
return {"items": [_mask_api_key(m) for m in store.list_models()]}
|
||||
|
||||
|
||||
@app.get("/api/models/current")
|
||||
async def api_get_current_model():
|
||||
m = store.get_current_model()
|
||||
if not m:
|
||||
return {"current": None}
|
||||
return {"current": _mask_api_key(m)}
|
||||
|
||||
|
||||
@app.post("/api/models")
|
||||
async def api_create_model(body: ModelCreate):
|
||||
if body.provider not in ("qwen", "openai", "doubao"):
|
||||
raise HTTPException(status_code=400, detail="provider must be qwen, openai or doubao")
|
||||
row = store.create_model(
|
||||
name=body.name,
|
||||
provider=body.provider,
|
||||
api_key=body.api_key,
|
||||
base_url=body.base_url or "",
|
||||
model_name=body.model_name or "",
|
||||
is_current=body.is_current or False,
|
||||
)
|
||||
return _mask_api_key(row)
|
||||
|
||||
|
||||
@app.patch("/api/models/{model_id}")
|
||||
async def api_update_model(model_id: str, body: ModelUpdate):
|
||||
row = store.update_model(
|
||||
model_id,
|
||||
name=body.name,
|
||||
api_key=body.api_key,
|
||||
base_url=body.base_url,
|
||||
model_name=body.model_name,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="model not found")
|
||||
return _mask_api_key(row)
|
||||
|
||||
|
||||
@app.post("/api/models/{model_id}/set-current")
|
||||
async def api_set_current_model(model_id: str):
|
||||
row = store.set_current_model(model_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="model not found")
|
||||
return _mask_api_key(row)
|
||||
|
||||
|
||||
@app.delete("/api/models/{model_id}")
|
||||
async def api_delete_model(model_id: str):
|
||||
if not store.delete_model(model_id):
|
||||
raise HTTPException(status_code=404, detail="model not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/qwen/generate")
|
||||
async def api_qwen_generate(body: QwenGenerateBody):
|
||||
"""所有对话生成由当前选中的模型接管,不再使用环境变量兜底。"""
|
||||
messages = []
|
||||
if body.system:
|
||||
messages.append({"role": "system", "content": body.system})
|
||||
messages.append({"role": "user", "content": body.prompt})
|
||||
text = await llm_chat(messages)
|
||||
if text is None:
|
||||
raise HTTPException(status_code=503, detail="请在「模型管理」页添加并选中模型、填写 API Key")
|
||||
return {"text": text}
|
||||
|
||||
|
||||
@app.post("/api/qwen/generate-greeting")
|
||||
async def api_qwen_generate_greeting(
|
||||
remark_name: str = Query(...),
|
||||
region: str = Query(""),
|
||||
tags: Optional[str] = Query(None),
|
||||
):
|
||||
"""问候语生成由当前选中的模型接管。"""
|
||||
tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()]
|
||||
user = f"请生成一句简短的微信问候语(1-2句话),客户备注名:{remark_name}"
|
||||
if region:
|
||||
user += f",地区:{region}"
|
||||
if tag_list:
|
||||
user += f",标签:{','.join(tag_list)}"
|
||||
user += "。不要解释,只输出问候语本身。"
|
||||
text = await llm_chat([{"role": "user", "content": user}])
|
||||
if text is None:
|
||||
raise HTTPException(status_code=503, detail="请在「模型管理」页添加并选中模型、填写 API Key")
|
||||
return {"text": text}
|
||||
|
||||
|
||||
class LogoutBody(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
@app.post("/auth/logout")
|
||||
async def logout(body: LogoutBody):
|
||||
key = body.key
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="key is required")
|
||||
|
||||
url = f"{WECHAT_UPSTREAM_BASE_URL}/login/LogOut"
|
||||
logger.info("LogOut: key=%s, url=%s", key, url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(url, params={"key": key})
|
||||
except Exception as exc:
|
||||
logger.exception("Error calling upstream LogOut: %s", exc)
|
||||
raise HTTPException(status_code=502, detail=f"upstream_error: {exc}") from exc
|
||||
|
||||
body_text = resp.text[:500]
|
||||
logger.info(
|
||||
"Upstream LogOut response: status=%s, body=%s",
|
||||
resp.status_code,
|
||||
body_text,
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
41
backend/qwen_client.py
Normal file
41
backend/qwen_client.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""千问(Qwen)模型调用:读取配置文件中的 API Key,使用 OpenAI 兼容接口。"""
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger("wechat-backend.qwen")
|
||||
|
||||
# 优先 QWEN_API_KEY,其次 DASHSCOPE_API_KEY,最后 APIKEY(.env 中可配置千问 key)
|
||||
QWEN_API_KEY = os.getenv("QWEN_API_KEY") or os.getenv("DASHSCOPE_API_KEY") or os.getenv("APIKEY")
|
||||
QWEN_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
QWEN_MODEL = os.getenv("QWEN_MODEL", "qwen-turbo")
|
||||
|
||||
|
||||
async def qwen_chat(messages: List[dict], api_key: Optional[str] = None) -> Optional[str]:
|
||||
"""调用千问对话,返回 assistant 回复文本。messages 格式 [{"role":"user","content":"..."}] 或含 system。"""
|
||||
key = api_key or QWEN_API_KEY
|
||||
if not key:
|
||||
logger.warning("No QWEN_API_KEY / APIKEY in env, skip qwen call")
|
||||
return None
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=key, base_url=QWEN_BASE_URL)
|
||||
r = await client.chat.completions.create(model=QWEN_MODEL, messages=messages)
|
||||
text = r.choices[0].message.content if r.choices else None
|
||||
return text
|
||||
except Exception as e:
|
||||
logger.exception("qwen_chat error: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def generate_greeting(remark_name: str, region: str = "", tags: List[str] = None) -> Optional[str]:
|
||||
"""根据客户备注、地区、标签生成一句简短个性化问候语。"""
|
||||
tags = tags or []
|
||||
user = f"请生成一句简短的微信问候语(1-2句话),客户备注名:{remark_name}"
|
||||
if region:
|
||||
user += f",地区:{region}"
|
||||
if tags:
|
||||
user += f",标签:{','.join(tags)}"
|
||||
user += "。不要解释,只输出问候语本身。"
|
||||
return await qwen_chat([{"role": "user", "content": user}])
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
httpx==0.27.0
|
||||
websockets>=12.0
|
||||
openai>=1.0.0
|
||||
|
||||
390
backend/store.py
Normal file
390
backend/store.py
Normal file
@@ -0,0 +1,390 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""JSON 文件存储:客户档案、定时问候任务、商品标签、推送群组、推送任务、同步消息。"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
_DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||
_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 _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 ""))
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 = []
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def list_customer_tags(key: str) -> List[str]:
|
||||
"""返回该 key 下客户档案中出现过的所有标签(去重、排序)。"""
|
||||
rows = [r for r in _load("customers") if r.get("key") == key]
|
||||
tags_set = set()
|
||||
for r in rows:
|
||||
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", ""))
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def create_greeting_task(key: str, name: str, send_time: str, customer_tags: List[str],
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------- 商品标签 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------- 推送群组(客户群组) ----------
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
if name is not None:
|
||||
r["name"] = name
|
||||
if customer_ids is not None:
|
||||
r["customer_ids"] = customer_ids
|
||||
if tag_ids is not None:
|
||||
r["tag_ids"] = tag_ids
|
||||
_save("push_groups", rows)
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------- 推送任务(一键/定时发送) ----------
|
||||
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]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------- 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)
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
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 ""))
|
||||
|
||||
|
||||
def get_model(model_id: str) -> Optional[Dict]:
|
||||
for r in _load("models"):
|
||||
if r.get("id") == model_id:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def get_current_model() -> Optional[Dict]:
|
||||
for r in _load("models"):
|
||||
if r.get("is_current"):
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def create_model(
|
||||
name: str,
|
||||
provider: str,
|
||||
api_key: str,
|
||||
base_url: str = "",
|
||||
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
|
||||
|
||||
|
||||
def update_model(
|
||||
model_id: str,
|
||||
name: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
return True
|
||||
return False
|
||||
80
backend/ws_sync.py
Normal file
80
backend/ws_sync.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""后台 WebSocket 客户端:连接 7006 GetSyncMsg,接收消息并写入 store。"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
logger = logging.getLogger("wechat-backend.ws_sync")
|
||||
|
||||
WS_BASE_URL = os.getenv("WECHAT_WS_BASE_URL", "").rstrip("/") or os.getenv("CHECK_STATUS_BASE_URL", "http://113.44.162.180:7006").rstrip("/").replace("http://", "ws://").replace("https://", "wss://")
|
||||
# 与 7006 GetSyncMsg 建立连接时使用的 key,必须与登录页使用的账号 key 一致,否则收不到该账号的消息
|
||||
# 优先读取 WECHAT_WS_KEY,未设置时使用 KEY(与登录参数一致)
|
||||
DEFAULT_KEY = (os.getenv("WECHAT_WS_KEY") or os.getenv("KEY") or "").strip() or "HBpEnbtj9BJZ"
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
websockets = None # type: ignore
|
||||
|
||||
_message_callback: Optional[Callable[[str, Dict], Any]] = None
|
||||
_ws_connected: bool = False
|
||||
|
||||
|
||||
def set_message_callback(cb: Callable[[str, Dict], Any]) -> None:
|
||||
global _message_callback
|
||||
_message_callback = cb
|
||||
|
||||
|
||||
async def _run_ws(key: str) -> None:
|
||||
if not websockets:
|
||||
logger.warning("websockets not installed, skip GetSyncMsg")
|
||||
return
|
||||
url = f"{WS_BASE_URL}/ws/GetSyncMsg?key={key}"
|
||||
logger.info("WS connecting to %s", url)
|
||||
global _ws_connected
|
||||
while True:
|
||||
try:
|
||||
_ws_connected = False
|
||||
async with websockets.connect(url, ping_interval=20, ping_timeout=10, close_timeout=5) as ws:
|
||||
_ws_connected = True
|
||||
logger.info("WS connected for key=%s", key)
|
||||
while True:
|
||||
raw = await ws.recv()
|
||||
try:
|
||||
data = json.loads(raw) if isinstance(raw, str) else json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
data = {"raw": str(raw)[:500]}
|
||||
if _message_callback:
|
||||
try:
|
||||
_message_callback(key, data)
|
||||
except Exception as e:
|
||||
logger.exception("message_callback error: %s", e)
|
||||
except asyncio.CancelledError:
|
||||
_ws_connected = False
|
||||
raise
|
||||
except Exception as e:
|
||||
_ws_connected = False
|
||||
logger.warning("WS disconnected for key=%s: %s, reconnect in 5s", key, e)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
def is_ws_connected() -> bool:
|
||||
return _ws_connected
|
||||
|
||||
|
||||
def _mask_key(key: str) -> str:
|
||||
"""仅显示末尾 4 位,便于核对 key 是否配置正确。"""
|
||||
if not key or len(key) < 5:
|
||||
return "****"
|
||||
return "***" + key[-4:]
|
||||
|
||||
|
||||
async def start_ws_sync(key: Optional[str] = None) -> None:
|
||||
k = (key or DEFAULT_KEY).strip()
|
||||
if not k:
|
||||
logger.warning("No KEY for WS GetSyncMsg, skip. 请设置 WECHAT_WS_KEY 或 KEY,且与登录页账号 key 一致")
|
||||
return
|
||||
logger.info("WS GetSyncMsg 使用 key=%s(与登录页 key 一致时才能收到该账号消息)", _mask_key(k))
|
||||
await _run_ws(k)
|
||||
Reference in New Issue
Block a user