callbacktoSMS/app/main.py
2026-07-07 14:28:24 +08:00

369 lines
12 KiB
Python
Raw Blame History

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