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"" 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, }, }, )