zlwu 10 月之前
父節點
當前提交
355485c4e9

+ 33 - 0
sendintlmsg/send.conf

@@ -0,0 +1,33 @@
+# 国际短信发送服务配置文件(sendintlmsg)
+# 用于 sendintlmsg/send_international_sms.py
+# 说明:
+# 1. 如果配置项为空,脚本会尝试从环境变量读取(兼容 backend/.env)
+# 2. 敏感信息(如密码)建议使用环境变量,避免泄露
+
+[aliyun_sms]
+# 阿里云国际短信服务配置
+# access_key_id 和 access_key_secret 如果为空,会从环境变量 ALIBABA_CLOUD_ACCESS_KEY_ID 和 ALIBABA_CLOUD_ACCESS_KEY_SECRET 读取
+# 从 sendmsg/send.conf 复制的默认值(如需安全请改为环境变量)
+access_key_id = LTAI5tQ6iFj9XMSZygRhKL4F
+access_key_secret = eXEfRLenQfOCLWkj307jv48rBUWNHK
+# Sender ID(国际短信需要使用已注册的 Sender ID,示例: Lyrria)
+sender_id = Lyrria
+# 国际短信模板(完整文本,支持 {code} 占位符)
+template_message = [Lyrria] Your verification code is {code}. It expires in 5 minutes. Do not share this code with anyone.
+# API endpoint(新加坡 ap-southeast-1)
+endpoint = dysmsapi.ap-southeast-1.aliyuncs.com
+# 短信类型(OTP/NOTIFY/MKT)
+sms_type = OTP
+
+[database]
+# MySQL 数据库配置(为空时会尝试从环境变量 MYSQL_* 读取)
+mysql_host = 101.201.60.170
+mysql_port = 13306
+mysql_user = root
+mysql_password = Lyrria@Root@123456
+mysql_database = Lyrria
+
+[service]
+# 服务配置
+# 轮询间隔(秒),准实时发送的检查频率
+poll_interval = 1.0

+ 313 - 204
sendintlmsg/send_international_sms.py

@@ -1,224 +1,333 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*-
 """
-阿里云国际短信发送 Demo
+国际短信发送服务(准实时)
 
-功能说明:
-- 使用阿里云 SMS 服务发送国际短信(2018-05-01 版本)
-- 服务地址:新加坡(ap-southeast-1)
-- 支持发送到美国、加拿大、新加坡等国家/地区
-- 支持 OTP(验证码)、NOTIFY(通知)、MKT(推广)三种类型
+说明:
+- 从数据库扫描待发送的验证码(area_code != +86),使用阿里云国际短信 API 发送。
+- 使用同步 SDK 调用并通过 asyncio.to_thread 包装为异步以不阻塞事件循环。
+- 配置文件:send.conf(与脚本同目录),缺失配置会尝试从环境变量读取。
 
-使用前准备:
-1. 安装依赖: pip install alibabacloud-dysmsapi20180501
-2. 确保已注册 Sender ID(美国、加拿大需 10 DLC 注册,新加坡需 IMDA SSIR 注册)
-
-注意: AccessKey 已直接配置在脚本中
-
-参考文档:
-https://next.api.aliyun.com/api/Dysmsapi/2018-05-01/SendMessageToGlobe
+运行: python send_international_sms.py
 """
 
+import asyncio
+import configparser
+import os
 import sys
-import json
-from typing import Optional, Dict, Any
+import logging
+from datetime import datetime
+from pathlib import Path
+from typing import Dict, Optional, Tuple
+from sqlalchemy import select, update, or_, and_
 
 try:
     from alibabacloud_dysmsapi20180501.client import Client as Dysmsapi20180501Client
+    from alibabacloud_dysmsapi20180501 import models as dysms_models
     from alibabacloud_tea_openapi import models as open_api_models
-    from alibabacloud_dysmsapi20180501 import models as dysmsapi_20180501_models
     from alibabacloud_tea_util import models as util_models
-except ImportError:
+except Exception:
     print("❌ 缺少依赖包,请先安装:")
-    print("  pip install alibabacloud-dysmsapi20180501")
+    print("  pip install alibabacloud-dysmsapi20180501 alibabacloud_tea_openapi alibabacloud_tea_util")
     sys.exit(1)
 
+# 加载配置文件
+config_file = Path(__file__).parent / "send.conf"
+config = configparser.ConfigParser()
+if config_file.exists():
+    config.read(config_file, encoding='utf-8')
+else:
+    raise FileNotFoundError(f"配置文件不存在: {config_file}")
+
+# 导入数据库相关模块(需要从 backend 目录导入)
+backend_dir = Path(__file__).parent.parent / "backend"
+sys.path.insert(0, str(backend_dir))
+from database import DatabaseManager
+from models_db import VerificationCode
+
+logger = logging.getLogger(__name__)
+
+# ------- 从配置文件读取参数 -------
+# 阿里云短信配置
+ACCESS_KEY_ID = config.get("aliyun_sms", "access_key_id", fallback="").strip()
+ACCESS_KEY_SECRET = config.get("aliyun_sms", "access_key_secret", fallback="").strip()
+SENDER_ID = config.get("aliyun_sms", "sender_id", fallback="Lyrria")
+TEMPLATE_MESSAGE = config.get(
+    "aliyun_sms", "template_message",
+    fallback="[Lyrria] Your verification code is {code}. It expires in 5 minutes. Do not share this code with anyone."
+)
+ENDPOINT = config.get("aliyun_sms", "endpoint", fallback="dysmsapi.ap-southeast-1.aliyuncs.com")
+
+# 数据库配置
+MYSQL_HOST = config.get("database", "mysql_host", fallback="").strip()
+MYSQL_PORT = config.get("database", "mysql_port", fallback="").strip()
+MYSQL_USER = config.get("database", "mysql_user", fallback="").strip()
+MYSQL_PASSWORD = config.get("database", "mysql_password", fallback="").strip()
+MYSQL_DATABASE = config.get("database", "mysql_database", fallback="").strip()
+
+# 服务配置
+POLL_INTERVAL = float(config.get("service", "poll_interval", fallback="2.0"))
+SMS_TYPE = config.get("aliyun_sms", "sms_type", fallback="OTP")  # OTP/NOTIFY/MKT
 
-class InternationalSMSClient:
-    """阿里云国际短信发送客户端(2018-05-01 版本)"""
-    
-    # 固定配置(根据文档要求,不可修改)
-    REGION_ID = 'ap-southeast-1'  # 新加坡
-    ENDPOINT = 'dysmsapi.ap-southeast-1.aliyuncs.com'
-    
-    # 短信类型
-    TYPE_OTP = 'OTP'      # 验证码
-    TYPE_NOTIFY = 'NOTIFY'  # 短信通知
-    TYPE_MKT = 'MKT'      # 推广短信
-    
-    def __init__(self, access_key_id: str, access_key_secret: str):
-        """
-        初始化客户端
-        
-        Args:
-            access_key_id: 阿里云 AccessKey ID
-            access_key_secret: 阿里云 AccessKey Secret
-        """
-        # 配置客户端(直接使用 AccessKey)
-        config = open_api_models.Config(
-            access_key_id=access_key_id,
-            access_key_secret=access_key_secret
+
+def create_client() -> Dysmsapi20180501Client:
+    """初始化阿里云国际短信客户端(从配置文件或环境读取 AccessKey)"""
+    if not ACCESS_KEY_ID or not ACCESS_KEY_SECRET:
+        raise RuntimeError(
+            "Missing AccessKey. Please set access_key_id and access_key_secret in send.conf or environment."
         )
-        config.endpoint = self.ENDPOINT
-        
-        self.client = Dysmsapi20180501Client(config)
-    
-    def send_sms(
-        self,
-        to: str,
-        message: str,
-        sender_id: str,
-        sms_type: str = TYPE_OTP,
-        task_id: Optional[str] = None,
-        validity_period: Optional[int] = None,
-        channel_id: Optional[str] = None
-    ) -> Dict[str, Any]:
-        """
-        发送国际短信
-        
-        Args:
-            to: 接收短信号码,格式为国际区号+号码,例如: "8521245567****"
-            message: 短信的完整内容(美国、加拿大需要填写报备的内容)
-            sender_id: 发送方标识(Sender ID)
-                      - 支持数字+字母,含有字母标识最长11位,纯数字标识支持15位
-                      - 美国、加拿大:需填写 10 DLC 注册后运营商提供的 Sender ID
-                      - 新加坡:需填写在 IMDA 完成 SSIR 注册的 Sender ID
-            sms_type: 短信类型,可选值: OTP/NOTIFY/MKT,默认 OTP
-            task_id: 任务 ID(可选),长度不超过 255 个字符
-            validity_period: 短信有效时长,单位:秒(可选)
-            channel_id: 通道 ID(可选)
-        
-        Returns:
-            包含发送结果的字典,格式:
-            {
-                "success": bool,
-                "response_code": str,      # 短信提交状态码,如 "OK"
-                "message_id": str,        # 成功时返回消息ID
-                "request_id": str,         # 请求ID
-                "segments": str,           # 短信计费条数
-                "response_description": str,  # 状态码描述
-                "to": str,                # 接收号码
-                "from": str,              # 发送方标识
-                "number_detail": {         # 号码明细(可选)
-                    "carrier": str,       # 运营商
-                    "country": str,       # 国家
-                    "region": str         # 地区
-                },
-                "error": str              # 失败时返回错误信息
-            }
-        """
-        # 验证短信类型
-        if sms_type not in [self.TYPE_OTP, self.TYPE_NOTIFY, self.TYPE_MKT]:
-            return {
-                "success": False,
-                "error": f"无效的短信类型: {sms_type},支持的类型: OTP/NOTIFY/MKT"
-            }
-        
-        # 创建请求对象
-        request = dysmsapi_20180501_models.SendMessageToGlobeRequest()
-        request.to = to
-        request.from_ = sender_id
-        request.message = message
-        request.type = sms_type
-        
-        if task_id:
-            request.task_id = task_id
-        if validity_period is not None:
-            request.validity_period = validity_period
-        if channel_id:
-            request.channel_id = channel_id
-        
-        # 运行时选项
-        runtime = util_models.RuntimeOptions()
-        
-        try:
-            # 发送请求
-            response = self.client.send_message_to_globe_with_options(request, runtime)
-            
-            # 解析响应
-            body = response.body
-            
-            # 检查响应状态码
-            if body.response_code == 'OK':
-                # 成功
-                result = {
-                    "success": True,
-                    "response_code": body.response_code,
-                    "response_description": body.response_description or '',
-                    "message_id": body.message_id or '',
-                    "request_id": body.request_id or '',
-                    "segments": body.segments or '',
-                    "to": body.to or '',
-                    "from": body.from_ or '',
-                }
-                
-                # 号码明细(可选)
-                if body.number_detail:
-                    result["number_detail"] = {
-                        "carrier": body.number_detail.carrier or '',
-                        "country": body.number_detail.country or '',
-                        "region": body.number_detail.region or ''
-                    }
-                
-                return result
-            else:
-                # 失败
-                return {
-                    "success": False,
-                    "response_code": body.response_code or '',
-                    "response_description": body.response_description or '',
-                    "request_id": body.request_id or '',
-                    "error": body.response_description or '未知错误'
-                }
-        
-        except Exception as e:
-            return {
-                "success": False,
-                "error": f"发送失败: {str(e)}"
-            }
-
-
-def main():
-    """主函数 - 示例用法"""
-    
-    # AccessKey 配置(直接写死在脚本中)
-    ACCESS_KEY_ID = 'LTAI5tQ6iFj9XMSZygRhKL4F'
-    ACCESS_KEY_SECRET = 'eXEfRLenQfOCLWkj307jv48rBUWNHK'
-    
-    # 创建客户端
-    sms_client = InternationalSMSClient(ACCESS_KEY_ID, ACCESS_KEY_SECRET)
-    
-    # 发送验证码短信
-    print("📤 发送验证码短信")
-    print("-" * 50)
-    
-    result = sms_client.send_sms(
-        to="447900613287",  # 接收号码(国际区号+号码)
-        message="Lyrria,Your verification code is 123456. Valid for 5 minutes.",
-        sender_id="Lyrria",  # Sender ID
-        sms_type=InternationalSMSClient.TYPE_OTP
+
+    client_config = open_api_models.Config(
+        access_key_id=ACCESS_KEY_ID,
+        access_key_secret=ACCESS_KEY_SECRET,
+        # endpoint for international API is region-specific; keep as configured
     )
-    
-    if result["success"]:
-        print(f"✅ 发送成功!")
-        print(f"   状态码: {result.get('response_code', '')}")
-        print(f"   状态描述: {result.get('response_description', '')}")
-        print(f"   消息ID: {result.get('message_id', '')}")
-        print(f"   请求ID: {result.get('request_id', '')}")
-        print(f"   计费条数: {result.get('segments', '')}")
-        if 'number_detail' in result:
-            detail = result['number_detail']
-            print(f"   运营商: {detail.get('carrier', '')}")
-            print(f"   国家: {detail.get('country', '')}")
-            print(f"   地区: {detail.get('region', '')}")
-    else:
-        print(f"❌ 发送失败: {result.get('error', '未知错误')}")
-        if result.get('response_code'):
-            print(f"   状态码: {result.get('response_code')}")
-        if result.get('response_description'):
-            print(f"   状态描述: {result.get('response_description')}")
-
-
-if __name__ == '__main__':
-    main()
+    client_config.endpoint = ENDPOINT
+    return Dysmsapi20180501Client(client_config)
+
+
+async def send_sms_async(
+    client: Dysmsapi20180501Client,
+    to: str,
+    message: str,
+    sender_id: str,
+    sms_type: str = "OTP",
+) -> Tuple[bool, Optional[str]]:
+    """
+    发送国际短信(异步包装同步 SDK)
+    Returns: (success, error_message)
+    """
+    request = dysms_models.SendMessageToGlobeRequest()
+    # 阿里云国际接口要求不带前导 '+',去掉可能的 '+' 和空白
+    normalized_to = (to or "").lstrip("+").strip()
+    request.to = normalized_to
+    request.from_ = sender_id
+    request.message = message
+    request.type = sms_type
+
+    try:
+        resp = await asyncio.to_thread(
+            client.send_message_to_globe_with_options,
+            request,
+            util_models.RuntimeOptions()
+        )
+
+        body = getattr(resp, "body", None)
+        if body and getattr(body, "response_code", None) == "OK":
+            logger.info(f"✅ 国际短信发送成功: to={to}, message_id={getattr(body, 'message_id', '')}")
+            return True, None
+        else:
+            error_desc = getattr(body, "response_description", "") if body else "No response body"
+            error_code = getattr(body, "response_code", "") if body else ""
+            error_msg = f"Code: {error_code}, Desc: {error_desc}"
+            logger.error(f"❌ 国际短信发送失败: to={to}, {error_msg}")
+            return False, error_msg
+
+    except Exception as e:
+        error_msg = f"Unexpected error: {e}"
+        logger.error(f"❌ 国际短信发送异常: to={to}, {error_msg}", exc_info=True)
+        return False, error_msg
+
+
+async def process_international_sms_codes(client: Dysmsapi20180501Client, db_manager: DatabaseManager):
+    """扫描并发送国际短信验证码(area_code != +86)"""
+    async with db_manager.get_session() as session:
+        result = await session.execute(
+            select(VerificationCode)
+            .where(
+                or_(
+                    VerificationCode.send_type == "international_sms",
+                    and_(
+                        VerificationCode.send_type == "domestic_sms",
+                        VerificationCode.area_code != "+86",
+                    )
+                ),
+                VerificationCode.is_sent == False,
+                VerificationCode.is_valid == True,
+                VerificationCode.phone.isnot(None)
+            )
+            .order_by(VerificationCode.created_at.asc())
+            .limit(50)
+        )
+        codes = result.scalars().all()
+
+        if not codes:
+            return 0, 0
+
+        logger.info(f"📋 找到 {len(codes)} 条待发送的国际短信验证码")
+        success_count = 0
+        fail_count = 0
+
+        for code_record in codes:
+            try:
+                area_code = (code_record.area_code or "").strip()
+                phone = (code_record.phone or "").strip()
+                code_value = code_record.code
+                record_id = code_record.id
+
+                if not phone:
+                    raise ValueError("empty phone")
+                if area_code.startswith("+"):
+                    to = f"{area_code}{phone}"
+                elif area_code:
+                    to = f"+{area_code}{phone}"
+                else:
+                    to = phone
+
+                message = TEMPLATE_MESSAGE.format(code=code_value)
+
+                success, error_msg = await send_sms_async(
+                    client,
+                    to,
+                    message,
+                    SENDER_ID,
+                    sms_type=SMS_TYPE
+                )
+
+                now = datetime.now()
+                if success:
+                    await session.execute(
+                        update(VerificationCode)
+                        .where(VerificationCode.id == record_id)
+                        .values(
+                            is_sent=True,
+                            sent_at=now,
+                            metadata_json={
+                                **(code_record.metadata_json or {}),
+                                "send_status": "success",
+                                "send_at": now.isoformat(),
+                            },
+                        )
+                    )
+                    success_count += 1
+                else:
+                    await session.execute(
+                        update(VerificationCode)
+                        .where(VerificationCode.id == record_id)
+                        .values(
+                            is_valid=False,
+                            metadata_json={
+                                **(code_record.metadata_json or {}),
+                                "send_status": "failed",
+                                "error": error_msg,
+                                "send_at": now.isoformat(),
+                            },
+                        )
+                    )
+                    fail_count += 1
+
+                await session.commit()
+
+            except Exception as e:
+                logger.error(f"❌ 处理国际验证码失败: id={getattr(code_record, 'id', 'n/a')}, error={e}", exc_info=True)
+                try:
+                    now = datetime.now()
+                    await session.execute(
+                        update(VerificationCode)
+                        .where(VerificationCode.id == getattr(code_record, "id", None))
+                        .values(
+                            is_valid=False,
+                            metadata_json={
+                                **(getattr(code_record, "metadata_json", {}) or {}),
+                                "send_status": "error",
+                                "error": str(e),
+                                "send_at": now.isoformat(),
+                            },
+                        )
+                    )
+                    await session.commit()
+                except:
+                    pass
+                fail_count += 1
+
+        if success_count > 0 or fail_count > 0:
+            logger.info(f"📊 处理完成: 成功 {success_count} 条, 失败 {fail_count} 条")
+
+        return success_count, fail_count
+
+
+async def main_loop():
+    """主循环:准实时轮询并发送国际验证码"""
+    # 设置数据库配置到环境变量(DatabaseManager 从环境变量读取)
+    if MYSQL_HOST:
+        os.environ["MYSQL_HOST"] = MYSQL_HOST
+    if MYSQL_PORT:
+        os.environ["MYSQL_PORT"] = MYSQL_PORT
+    if MYSQL_USER:
+        os.environ["MYSQL_USER"] = MYSQL_USER
+    if MYSQL_PASSWORD:
+        os.environ["MYSQL_PASSWORD"] = MYSQL_PASSWORD
+    if MYSQL_DATABASE:
+        os.environ["MYSQL_DATABASE"] = MYSQL_DATABASE
+
+    db_manager = DatabaseManager()
+    client = None
+
+    try:
+        # 初始化数据库连接
+        await db_manager.connect()
+        logger.info("✅ 数据库连接成功")
+
+        # 初始化短信客户端
+        client = create_client()
+        logger.info("✅ 阿里云国际短信客户端初始化成功")
+
+        logger.info(f"🚀 开始准实时发送国际短信服务(轮询间隔: {POLL_INTERVAL}秒)...")
+
+        # 主循环:持续轮询
+        while True:
+            try:
+                await process_international_sms_codes(client, db_manager)
+            except Exception as e:
+                logger.error(f"❌ 处理国际验证码时出错: {e}", exc_info=True)
+
+            # 等待下次轮询
+            await asyncio.sleep(POLL_INTERVAL)
+
+    except KeyboardInterrupt:
+        logger.info("🛑 收到中断信号,正在关闭...")
+    except Exception as e:
+        logger.error(f"❌ 服务异常: {e}", exc_info=True)
+        sys.exit(1)
+    finally:
+        await db_manager.disconnect()
+        logger.info("✅ 服务已关闭")
+
+
+def setup_logging():
+    """配置日志:正常日志输出到 send_international_sms.log,错误日志输出到 send_international_sms_error.log"""
+    log_dir = Path(__file__).parent
+    log_file = log_dir / "send_international_sms.log"
+    error_log_file = log_dir / "send_international_sms_error.log"
+
+    formatter = logging.Formatter(
+        '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+        datefmt='%Y-%m-%d %H:%M:%S'
+    )
+
+    root_logger = logging.getLogger()
+    root_logger.setLevel(logging.INFO)
+    root_logger.handlers.clear()
+
+    console_handler = logging.StreamHandler(sys.stdout)
+    console_handler.setLevel(logging.INFO)
+    console_handler.setFormatter(formatter)
+    root_logger.addHandler(console_handler)
+
+    file_handler = logging.FileHandler(log_file, encoding='utf-8')
+    file_handler.setLevel(logging.INFO)
+    file_handler.setFormatter(formatter)
+    root_logger.addHandler(file_handler)
+
+    error_file_handler = logging.FileHandler(error_log_file, encoding='utf-8')
+    error_file_handler.setLevel(logging.ERROR)
+    error_file_handler.setFormatter(formatter)
+    root_logger.addHandler(error_file_handler)
+
+    logger.info(f"✅ 日志配置完成:正常日志 -> {log_file}, 错误日志 -> {error_log_file}")
+
+
+if __name__ == "__main__":
+    setup_logging()
+    asyncio.run(main_loop())
 

+ 19 - 0
sendintlmsg/start_prod.sh

@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+DIR="$(cd "$(dirname "$0")" && pwd)"
+PID_FILE="$DIR/send_international_sms.pid"
+LOG_FILE="$DIR/send_international_sms.log"
+
+if [ -f "$PID_FILE" ]; then
+  PID=$(cat "$PID_FILE")
+  if kill -0 "$PID" >/dev/null 2>&1; then
+    echo "Already running (pid $PID)"
+    exit 0
+  else
+    rm -f "$PID_FILE"
+  fi
+fi
+
+nohup python3 "$DIR/send_international_sms.py" >> "$LOG_FILE" 2>&1 &
+echo $! > "$PID_FILE"
+echo "Started send_international_sms (pid $(cat "$PID_FILE"))"
+

+ 19 - 0
sendintlmsg/status_prod.sh

@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+DIR="$(cd "$(dirname "$0")" && pwd)"
+PID_FILE="$DIR/send_international_sms.pid"
+
+if [ -f "$PID_FILE" ]; then
+  PID=$(cat "$PID_FILE")
+  if kill -0 "$PID" >/dev/null 2>&1; then
+    echo "Running (pid $PID)"
+    exit 0
+  else
+    echo "Not running but pid file exists"
+    exit 1
+  fi
+else
+  echo "Not running"
+  exit 1
+fi
+
+

+ 28 - 0
sendintlmsg/stop_prod.sh

@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+DIR="$(cd "$(dirname "$0")" && pwd)"
+PID_FILE="$DIR/send_international_sms.pid"
+
+if [ ! -f "$PID_FILE" ]; then
+  echo "Not running (no pid file)"
+  exit 0
+fi
+
+PID=$(cat "$PID_FILE")
+if kill -0 "$PID" >/dev/null 2>&1; then
+  kill "$PID"
+  sleep 1
+  if kill -0 "$PID" >/dev/null 2>&1; then
+    echo "Failed to stop process $PID"
+    exit 1
+  else
+    rm -f "$PID_FILE"
+    echo "Stopped send_international_sms (pid $PID)"
+    exit 0
+  fi
+else
+  rm -f "$PID_FILE"
+  echo "Removed stale pid file"
+  exit 0
+fi
+
+

+ 1 - 0
项目文档/小进度值异常阴影修复方案.md

@@ -161,3 +161,4 @@ if (progress > 0) {
 
 
 
+