fix:优化界面

This commit is contained in:
丹尼尔
2026-03-11 00:22:41 +08:00
parent 0655410134
commit 0e8639fde1
4268 changed files with 1224126 additions and 92 deletions

19
backend/Dockerfile Normal file
View 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
View File

@@ -0,0 +1 @@
# WeChat Admin Backend

View File

@@ -0,0 +1,15 @@
[
{
"id": "50ddd99c-f369-4baf-b875-c3ce80d851d8",
"key": "HBpEnbtj9BJZ",
"wxid": "zhang499142409",
"remark_name": "测试",
"region": "上海",
"age": "20",
"gender": "男",
"level": "A",
"tags": [
"准客户"
]
}
]

View 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"
}
]

View File

@@ -0,0 +1,7 @@
[
{
"id": "18d3c34a-816b-42e0-9b03-bb07cd76554a",
"key": "HBpEnbtj9BJZ",
"name": "外贸款"
}
]

File diff suppressed because one or more lines are too long

123
backend/llm_client.py Normal file
View 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 ARKResponses APIPOST /responsesinput 格式见示例。"""
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
View 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/SendTextMessagebody 为 SendMessageModelMsgItem 数组)
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:
"""WSGetSyncMsg连接状态供前端在掉线时跳转登录页。"""
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:
# 不调用滑块服务;返回自带预填表单的页面 pathiframe 加载后自动填充 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
View 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
View 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
View 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:
"""拿货等级 leveltags 为标签列表,用于分群与问候。"""
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
View 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)