From d19ec3e3a3e1cd384672f71907253a634776b6b8 Mon Sep 17 00:00:00 2001 From: yjca Date: Tue, 7 Jul 2026 14:28:24 +0800 Subject: [PATCH] first commit --- .env | 11 + .gitignore | 1 + README.md | 175 +++++++++++++ app/__pycache__/main.cpython-311.pyc | Bin 0 -> 19663 bytes app/main.py | 368 +++++++++++++++++++++++++++ data/sms_receivers.db | Bin 0 -> 16384 bytes logs/callback.log | 1 + requirements.txt | 5 + 8 files changed, 561 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__pycache__/main.cpython-311.pyc create mode 100644 app/main.py create mode 100644 data/sms_receivers.db create mode 100644 logs/callback.log create mode 100644 requirements.txt diff --git a/.env b/.env new file mode 100644 index 0000000..220649f --- /dev/null +++ b/.env @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ae2839 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +pass diff --git a/README.md b/README.md new file mode 100644 index 0000000..f562336 --- /dev/null +++ b/README.md @@ -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_PATH:SQLite 数据库路径,默认 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 权限最小化。 diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9767708395c34aa83d160056072b2955ab7eb0e3 GIT binary patch literal 19663 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 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"" + 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 "�" 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, + }, + }, + ) diff --git a/data/sms_receivers.db b/data/sms_receivers.db new file mode 100644 index 0000000000000000000000000000000000000000..ad798cfadbcabbf5c3c0f3c82c0423f6f2854bbc GIT binary patch literal 16384 zcmeI(L2KJE6bEoAPP(R$sypQ>2*WLHhn-|elheFT8)1%{x-u*$lQt?dG)@~QW#=&l zJNC=$w(rpUF8cz59e144HEW!(9S0r%M}#8uo-BU&B7u(%-Ds@%>2NgYjd_J^5K75Y z&IuuT-LksPg6cnu31 zTX=G3sp|aAFjiqa862w-m*TbL-hpm`>+-;B9R^~#BuYXRPc`ly_`Bx0c;N)D{#*-gtX8G5BvowWCtq&4