first commit

This commit is contained in:
yjca 2026-07-07 14:28:24 +08:00
commit d19ec3e3a3
8 changed files with 561 additions and 0 deletions

11
.env Normal file
View File

@ -0,0 +1,11 @@
# Aliyun SMS config
ALIBABA_CLOUD_ACCESS_KEY_ID=LTAI5tCpEwwAbxbqEM5rQDA9
ALIBABA_CLOUD_ACCESS_KEY_SECRET=BUcLxYYmvsZhBnbqrq9tHjICWzDzrc
ALIBABA_CLOUD_REGION_ID=cn-hangzhou
ALIYUN_SMS_SIGN_NAME=浙江云览数字
ALIYUN_SMS_TEMPLATE_CODE=SMS_508915141
SMS_DB_PATH=data/sms_receivers.db
SMS_DB_TABLE=sms_receivers
# true: preview only, false: actually send SMS
LOG_ONLY_NO_SMS=false

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
pass

175
README.md Normal file
View File

@ -0,0 +1,175 @@
# 回调告警转短信服务
本项目是一个 Python 后端服务,用于接收 AI 视频分析平台的 HTTP 回调,并将关键告警信息转为阿里云短信发送给手机号列表。
## 启动指令
pip install -r requirements.txt
py -m uvicorn app.main:app --host 0.0.0.0 --port 2336
核心目标:
1. 接收并解析上游回调 JSON。
2. 生成短信模板变量 ACTION。
3. 查询 SQLite 中已启用手机号并群发。
4. 记录可追踪日志,返回标准化接口结果。
## 1. 功能概览
服务提供两个接口:
1. GET /healthz健康检查。
2. POST /callback接收告警回调并触发短信逻辑。
POST /callback 的处理流程:
1. 解析 JSON支持常见编码容错
2. 提取事件标识 event_id优先 snowflake_id其次 analysis_job_id
3. 脱敏写日志(图片 base64、密钥等敏感字段不落盘
4. 生成短信预览文本:发现有${ACTION}行为,请去摄像头查看。
5. 查询 SQLite 中 is_enabled=1 的手机号。
6. 根据 LOG_ONLY_NO_SMS 决定仅预览还是实际调用阿里云接口。
7. 返回统一结构code、message、data。
## 2. 环境准备
在项目根目录执行:
```bash
pip install -r requirements.txt
```
推荐 Python 3.11+。
## 3. 配置说明
项目默认通过 .env 读取配置,主要参数如下:
1. ALIBABA_CLOUD_ACCESS_KEY_ID阿里云 AK。
2. ALIBABA_CLOUD_ACCESS_KEY_SECRET阿里云 SK。
3. ALIBABA_CLOUD_REGION_ID区域默认 cn-hangzhou。
4. ALIYUN_SMS_SIGN_NAME已审核通过的短信签名。
5. ALIYUN_SMS_TEMPLATE_CODE短信模板编码。
6. SMS_DB_PATHSQLite 数据库路径,默认 data/sms_receivers.db。
7. SMS_DB_TABLE手机号表名默认 sms_receivers。
8. LOG_ONLY_NO_SMS是否仅预览不实发。
LOG_ONLY_NO_SMS 取值建议:
1. true联调阶段使用不调用阿里云发送。
2. false生产或实发测试使用会真实发送短信。
## 4. 手机号来源与优先级
服务启动时会自动创建手机号表(如果不存在):
```sql
CREATE TABLE IF NOT EXISTS sms_receivers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_number TEXT NOT NULL UNIQUE,
is_enabled INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
接收方选择规则:
1. 仅使用 SQLite 中 is_enabled=1 的手机号。
这意味着你在服务运行期间修改数据库,下一次 callback 会立即生效,无需重启。
常用维护 SQL
```sql
INSERT INTO sms_receivers (phone_number, is_enabled) VALUES ('17394641215', 1);
UPDATE sms_receivers SET is_enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE phone_number = '17394641215';
DELETE FROM sms_receivers WHERE phone_number = '17394641215';
```
## 5. 启动服务
在项目根目录执行:
```bash
py -m uvicorn app.main:app --host 0.0.0.0 --port 2336
```
启动成功后会看到:
1. sms_db_ready
2. Uvicorn running on http://0.0.0.0:2336
## 6. 如何测试
### 6.1 健康检查
```bash
curl http://127.0.0.1:2336/healthz
```
### 6.2 回调测试
方式一:使用示例文件 test_callback_payload.json。
```bash
curl -X POST "http://127.0.0.1:2336/callback" \
-H "Content-Type: application/json; charset=utf-8" \
--data-binary "@test_callback_payload.json"
```
方式二:直接发最小 JSON。
```bash
curl -X POST "http://127.0.0.1:2336/callback" \
-H "Content-Type: application/json" \
-d "{\"algorithm_name\":\"行人闯入\",\"snowflake_id\":\"demo-001\"}"
```
## 7. 返回结构说明
接口始终返回统一结构:
```json
{
"code": 0,
"message": "ok",
"data": {
"event_id": "1768287987212271616",
"sms_preview": "发现有行人闯入行为,请去摄像头查看。",
"sms_ok": true,
"sms_skipped": false,
"receiver_count": 1,
"sms": {
"code": "OK",
"message": "OK",
"request_id": "...",
"biz_id": "..."
},
"sms_error": ""
}
}
```
字段含义:
1. sms_preview短信文本语义预览。
2. sms_skipped是否因为 LOG_ONLY_NO_SMS=true 而跳过实发。
3. sms_ok阿里云返回码是否为 OK。
4. sms阿里云网关返回详情。
5. sms_error发送失败时的错误信息。
## 8. 日志与排障
日志位置logs/callback.log。
常见日志事件:
1. callback_received收到回调。
2. sms_preview短信预览与模板参数。
3. sms_receivers_loaded当前收件人数。
4. sms_sent已调用阿里云并返回结果。
5. sms_skipped当前为仅预览模式。
6. sms_config_missing缺少必要配置。
常见问题:
1. 看见中文乱码:多数是终端显示编码问题,先以接口 JSON 返回为准。
2. 未发送短信:检查 LOG_ONLY_NO_SMS 是否为 true。
3. receiver_count 为 0检查 SQLite 是否有 is_enabled=1 的号码。
4. 阿里云报模板错误:确认签名、模板编码、模板变量 ACTION 与控制台一致。
## 9. 安全建议
1. 不要把 AK/SK 提交到版本库。
2. 生产环境建议通过系统环境变量注入密钥。
3. 定期轮换 AK/SK并限制 RAM 权限最小化。

Binary file not shown.

368
app/main.py Normal file
View File

@ -0,0 +1,368 @@
import json
import logging
import os
import sqlite3
from logging.handlers import RotatingFileHandler
from typing import Any, Dict, List
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from dotenv import load_dotenv
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_dysmsapi20170525.client import Client as Dysmsapi20170525Client
from alibabacloud_dysmsapi20170525 import models as dysmsapi_20170525_models
load_dotenv(override=True)
# Aliyun SMS config (prefer environment variables for sensitive values)
ALIYUN_ACCESS_KEY_ID = os.getenv("ALIBABA_CLOUD_ACCESS_KEY_ID", "")
ALIYUN_ACCESS_KEY_SECRET = os.getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "")
ALIYUN_REGION = os.getenv("ALIBABA_CLOUD_REGION_ID", "cn-hangzhou")
ALIYUN_SMS_SIGN_NAME = os.getenv("ALIYUN_SMS_SIGN_NAME", "")
ALIYUN_SMS_TEMPLATE_CODE = os.getenv("ALIYUN_SMS_TEMPLATE_CODE", "SMS_508915141")
SMS_DB_PATH = os.getenv("SMS_DB_PATH", "data/sms_receivers.db")
SMS_DB_TABLE = os.getenv("SMS_DB_TABLE", "sms_receivers")
# True: only log preview text, do not call Aliyun SMS API.
LOG_ONLY_NO_SMS = os.getenv("LOG_ONLY_NO_SMS", "false").strip().lower() in {"1", "true", "yes", "on"}
SENSITIVE_FIELD_KEYS = {
"alarm_pic_data",
"src_pic_data",
"access_key_id",
"access_key_secret",
"phone",
"phone_numbers",
"mobile",
}
app = FastAPI(title="Callback to SMS")
def setup_logging() -> logging.Logger:
os.makedirs("logs", exist_ok=True)
logger = logging.getLogger("callback_service")
logger.setLevel(logging.INFO)
if logger.handlers:
return logger
file_handler = RotatingFileHandler(
filename="logs/callback.log",
maxBytes=5 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
formatter = logging.Formatter(
"%(asctime)s | %(levelname)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger
logger = setup_logging()
def parse_request_json(raw_body: bytes, content_type: str) -> Dict[str, Any]:
if not raw_body:
raise ValueError("empty body")
encodings: List[str] = []
content_type_lower = (content_type or "").lower()
if "charset=" in content_type_lower:
charset = content_type_lower.split("charset=", 1)[1].split(";", 1)[0].strip()
if charset:
encodings.append(charset)
encodings.extend(["utf-8", "utf-8-sig", "gb18030", "gbk"])
tried = set()
for encoding in encodings:
if encoding in tried:
continue
tried.add(encoding)
try:
decoded_text = raw_body.decode(encoding)
parsed = json.loads(decoded_text)
if isinstance(parsed, dict):
return parsed
raise ValueError("json root must be object")
except Exception:
continue
raise ValueError("invalid json or unsupported encoding")
def ensure_receivers_table() -> None:
os.makedirs(os.path.dirname(SMS_DB_PATH) or ".", exist_ok=True)
with sqlite3.connect(SMS_DB_PATH) as conn:
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS {SMS_DB_TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone_number TEXT NOT NULL UNIQUE,
is_enabled INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.commit()
def get_db_receivers() -> List[str]:
try:
with sqlite3.connect(SMS_DB_PATH) as conn:
rows = conn.execute(
f"SELECT phone_number FROM {SMS_DB_TABLE} WHERE is_enabled = 1 ORDER BY id"
).fetchall()
return [str(row[0]).strip() for row in rows if str(row[0]).strip()]
except Exception:
logger.exception("sms_db_read_failed | db_path=%s | table=%s", SMS_DB_PATH, SMS_DB_TABLE)
return []
def get_sms_receivers() -> List[str]:
db_receivers = get_db_receivers()
merged: List[str] = []
seen = set()
for phone in db_receivers:
if phone in seen:
continue
seen.add(phone)
merged.append(phone)
return merged
def create_sms_client() -> Dysmsapi20170525Client:
config = open_api_models.Config(
access_key_id=ALIYUN_ACCESS_KEY_ID,
access_key_secret=ALIYUN_ACCESS_KEY_SECRET,
)
# Dysmsapi endpoint is a fixed domain; region is carried by SDK config.
config.endpoint = "dysmsapi.aliyuncs.com"
config.region_id = ALIYUN_REGION
return Dysmsapi20170525Client(config)
def get_missing_sms_config(receivers: List[str]) -> List[str]:
missing: List[str] = []
if not ALIYUN_ACCESS_KEY_ID:
missing.append("ALIBABA_CLOUD_ACCESS_KEY_ID")
if not ALIYUN_ACCESS_KEY_SECRET:
missing.append("ALIBABA_CLOUD_ACCESS_KEY_SECRET")
if not ALIYUN_SMS_SIGN_NAME:
missing.append("ALIYUN_SMS_SIGN_NAME")
if not ALIYUN_SMS_TEMPLATE_CODE:
missing.append("ALIYUN_SMS_TEMPLATE_CODE")
if not receivers:
missing.append("SQLite receivers table (enabled phone_number)")
return missing
def sanitize_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
sanitized: Dict[str, Any] = {}
for key, value in payload.items():
key_lower = str(key).lower()
if key_lower in SENSITIVE_FIELD_KEYS:
sanitized[key] = "***"
continue
if isinstance(value, str) and len(value) > 256:
sanitized[key] = f"<omitted len={len(value)}>"
continue
sanitized[key] = value
return sanitized
def send_sms_hardcoded(template_params: Dict[str, Any], phone_numbers: str) -> Dict[str, Any]:
client = create_sms_client()
request = dysmsapi_20170525_models.SendSmsRequest(
phone_numbers=phone_numbers,
sign_name=ALIYUN_SMS_SIGN_NAME,
template_code=ALIYUN_SMS_TEMPLATE_CODE,
# Keep escaped unicode to avoid transport/terminal charset side effects.
template_param=json.dumps(template_params, ensure_ascii=True),
)
response = client.send_sms(request)
return {
"code": response.body.code,
"message": response.body.message,
"request_id": response.body.request_id,
"biz_id": response.body.biz_id,
}
def build_template_params(payload: Dict[str, Any]) -> Dict[str, Any]:
algorithm_name = str(payload.get("algorithm_name") or "").strip()
if not algorithm_name:
first_result = payload.get("result_data")
if isinstance(first_result, list) and first_result:
first_item = first_result[0]
if isinstance(first_item, dict):
algorithm_name = str(first_item.get("algorithm_name") or "").strip()
algorithm_name_en = str(payload.get("algorithm_name_en") or "Unknown").strip() or "Unknown"
action_text = pick_safe_action_text(algorithm_name, algorithm_name_en)
# These keys must match your approved SMS template variables.
return {
"ACTION": action_text,
}
def contains_suspicious_text(text: str) -> bool:
if not text:
return False
if "<EFBFBD>" in text:
return True
# Private-use glyphs usually indicate decode issues in this scenario.
if any("\ue000" <= ch <= "\uf8ff" for ch in text):
return True
# Typical mojibake markers from UTF-8/Latin-1 mis-decoding.
if any(marker in text for marker in ("Ã", "Â", "æ", "ç", "å", "ä", "ï")):
cjk_count = sum(1 for ch in text if "\u4e00" <= ch <= "\u9fff")
if cjk_count == 0:
return True
return False
def pick_safe_action_text(algorithm_name: str, algorithm_name_en: str) -> str:
primary = (algorithm_name or "").strip()
fallback = (algorithm_name_en or "Unknown").strip() or "Unknown"
if not primary:
return fallback
# Only attempt repair when text looks suspicious; avoid changing healthy input.
if contains_suspicious_text(primary):
repaired = repair_mojibake_text(primary)
if repaired and not contains_suspicious_text(repaired):
logger.info("action_text_repaired | from=%s | to=%s", primary, repaired)
return repaired
logger.warning("action_text_fallback | reason=suspicious_after_repair | original=%s | fallback=%s", primary, fallback)
return fallback
return primary
def repair_mojibake_text(text: str) -> str:
"""Repair common case: UTF-8 text incorrectly decoded as GBK."""
if not text:
return text
for source_encoding in ("gb18030", "gbk", "cp936"):
try:
repaired = text.encode(source_encoding).decode("utf-8")
except Exception:
continue
# Keep repaired value only when it differs and remains printable.
if repaired and repaired != text and all(ch.isprintable() for ch in repaired):
return repaired
return text
def build_sms_preview_text(template_params: Dict[str, Any]) -> str:
action = str(template_params.get("ACTION", "Unknown"))
return f"发现有{action}行为,请去摄像头查看。"
@app.on_event("startup")
async def startup_event() -> None:
ensure_receivers_table()
logger.info("sms_db_ready | path=%s | table=%s", SMS_DB_PATH, SMS_DB_TABLE)
@app.get("/healthz")
async def healthz() -> Dict[str, Any]:
receivers = get_sms_receivers()
return {
"status": "ok",
"receiver_count": len(receivers),
"receivers": receivers,
}
@app.post("/callback")
async def callback(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = parse_request_json(raw_body, request.headers.get("content-type", ""))
except Exception:
logger.exception("Invalid JSON received")
return JSONResponse(
status_code=400,
content={"code": 400, "message": "invalid json"},
)
event_id = str(payload.get("snowflake_id") or payload.get("analysis_job_id") or "unknown")
safe_payload = sanitize_payload(payload)
logger.info("callback_received | event_id=%s | payload=%s", event_id, json.dumps(safe_payload, ensure_ascii=False))
params = build_template_params(payload)
sms_preview_text = build_sms_preview_text(params)
logger.info(
"sms_preview | event_id=%s | text=%s | template_param=%s",
event_id,
sms_preview_text,
json.dumps(params, ensure_ascii=False),
)
sms_ok = False
sms_skipped = False
sms_result: Dict[str, Any] = {}
sms_error = ""
receivers = get_sms_receivers()
receiver_count = len(receivers)
logger.info("sms_receivers_loaded | event_id=%s | count=%s", event_id, receiver_count)
if LOG_ONLY_NO_SMS:
sms_skipped = True
logger.info("sms_skipped | event_id=%s | reason=log_only_mode", event_id)
else:
missing_config = get_missing_sms_config(receivers)
if missing_config:
sms_error = f"missing sms config: {', '.join(missing_config)}"
logger.error("sms_config_missing | event_id=%s | missing=%s", event_id, ",".join(missing_config))
else:
try:
sms_result = send_sms_hardcoded(params, ",".join(receivers))
sms_ok = str(sms_result.get("code", "")).upper() == "OK"
logger.info(
"sms_sent | event_id=%s | receiver_count=%s | sms_code=%s | sms_message=%s | biz_id=%s",
event_id,
receiver_count,
sms_result.get("code"),
sms_result.get("message"),
sms_result.get("biz_id"),
)
except Exception as exc:
sms_error = str(exc)
logger.exception("sms_send_failed | event_id=%s | error=%s", event_id, sms_error)
return JSONResponse(
status_code=200,
content={
"code": 0,
"message": "ok",
"data": {
"event_id": event_id,
"sms_preview": sms_preview_text,
"sms_ok": sms_ok,
"sms_skipped": sms_skipped,
"receiver_count": receiver_count,
"sms": sms_result,
"sms_error": sms_error,
},
},
)

BIN
data/sms_receivers.db Normal file

Binary file not shown.

1
logs/callback.log Normal file
View File

@ -0,0 +1 @@
2026-07-07 10:13:56 | INFO | sms_db_ready | path=data/sms_receivers.db | table=sms_receivers

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
fastapi==0.116.1
uvicorn==0.35.0
alibabacloud-dysmsapi20170525==3.1.1
alibabacloud-tea-openapi==0.4.1
python-dotenv==1.0.1