| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548 |
- import asyncio
- import json
- from datetime import datetime
- from typing import Dict, Any
- from redis.asyncio import Redis
- from sqlalchemy.ext.asyncio import AsyncSession
- from app.services.wechat_service import WechatService
- from app.services.email_authorizations_service import EmailAuthorizationService
- from app.utils.redis_utils import redis_qpop
- THROTTLE_EXPIRY = 1800
- async def notification_consumer(session_factory, redis_client: Redis):
- """
- 异步消费 Redis 队列 vas_notification_queue
- """
- queue_name = "vas_notification_queue"
- while True:
- try:
- # 阻塞获取队列消息
- message: Dict[str, Any] = await redis_qpop(redis_client, queue_name, timeout=5)
- if not message:
- await asyncio.sleep(1) # 队列为空,休眠
- continue
- channel = message.get("channel", "")
- template_id = message.get("template_id")
- payload = message.get("payload", {})
-
- # 按渠道发送
- if "email" == channel:
- content = None
- sender = None
- subject = None
- receiver = message.get("receiver", "")
- if "email_verification_for_bind" == template_id:
- sender = "donotreply@visafly.top"
- subject = "Email Verification"
- content = template_for_bind_email(payload)
- if "email_verification_for_reset" == template_id:
- sender = "donotreply@visafly.top"
- subject = "Reset Password"
- content = template_for_reset_pwd(payload)
- if "login_credentials" == template_id:
- sender = "donotreply@visafly.top"
- subject = "Your Account Details"
- content = template_for_login_credentials(payload)
- if "ticket_created" == template_id:
- sender = "donotreply@visafly.top"
- subject = "Ticket Created"
- content = template_ticket_open(payload)
- if "appointment_confirmation" == template_id:
- sender = "donotreply@visafly.top"
- subject = "Appointment Confirmation"
- content = template_appointment_confirmation(payload)
- if content:
- async with session_factory() as db: # type: AsyncSession
- auth = await EmailAuthorizationService.get_by_email(db, sender)
- send_result = await EmailAuthorizationService.send_email(auth, receiver, subject, "html", content)
- print(f"Email send result: {send_result}")
- if "wechat" == channel:
- api_token = "a8f79817-e18b-4739-8459-adb2ed5e2e32"
-
- if "payment_user_confirmed" == template_id:
- status = await WechatService.push_payment_template(api_token, payload)
- print(f"Wechat send status: {status}")
-
- if "slot_snapshot" == template_id:
- # 1. 提取标识字段
- country = payload.get("country", "unknown")
- city = payload.get("city", "unknown")
- visa_type = payload.get("visa_type", "unknown")
- earliest_date = payload.get("earliest_date", "N/A")
-
- # 2. 生成 Redis 频率限制 Key
- # 格式: throttle:slot_snapshot:USA:Beijing:B1
- throttle_key = f"throttle:slot_snapshot:{country}:{city}:{visa_type}"
-
- # 3. 检查是否存在记录(即是否在冷却期内)
- last_sent_val = await redis_client.get(throttle_key)
-
- # 4. 判断是否需要跳过
- # 如果记录存在,且 earliest_date 没有变化,则跳过推送
- if last_sent_val and last_sent_val == earliest_date:
- print(f"⏭️ Skipped redundant Wechat notification for {country}-{city} (In Cooling Period)")
- continue
-
- # 5. 执行发送
- status = await WechatService.push_slot_snapshot(api_token, payload)
- print(f"Wechat send status: {status}")
-
- # 6. 发送成功后更新 Redis 记录并设置过期时间
- # 存储当前的最早日期,下次如果日期变了,即便没过 30 分钟也会再次推送
- await redis_client.set(throttle_key, str(earliest_date), ex=THROTTLE_EXPIRY)
- print(f"✅ Notification sent: {message.get('notification_id')}")
- except Exception as e:
- print(f"⚠️ Notification consumer error: {e}")
- await asyncio.sleep(1) # 避免异常循环过快
- def template_for_bind_email(payload):
- """
- 生成绑定邮箱验证码邮件
-
- Args:
- payload (dict): 包含以下字段:
- - app_name: 应用名称 (默认 Visafly)
- - code: 验证码
- - expiration_time: 过期时间描述 (如 "10 minutes")
- """
-
- # 1. 定义 HTML 模板
- template = '''
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="UTF-8">
- <title>Email Verification</title>
- <style>
- body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 0; }
- .container { max-width: 600px; margin: 20px auto; background-color: #ffffff; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
- .code { font-size: 24px; font-weight: bold; letter-spacing: 5px; color: #333; background-color: #f0f0f0; padding: 15px; text-align: center; border-radius: 4px; margin: 20px 0; }
- .footer { font-size: 12px; color: #888; margin-top: 30px; text-align: center; }
- </style>
- </head>
- <body>
- <div class="container">
- <h2>Verify your email address</h2>
- <p>Hello,</p>
- <p>You requested to bind this email address to your <strong>{{app_name}}</strong> account. Please use the verification code below to proceed:</p>
-
- <div class="code">{{code}}</div>
-
- <p>This code will expire in <strong>{{expiration_time}}</strong>.</p>
- <p>If you did not request this change, please ignore this email.</p>
-
- <br>
- <p>Best regards,<br>The {{app_name}} Team</p>
-
- <div class="footer">
- © 2026 {{app_name}}. All rights reserved.
- </div>
- </div>
- </body>
- </html>
- '''
- # 2. 执行数据替换
- # 使用 .get() 提供默认值,防止缺少字段导致报错
- # 使用 str() 确保数据是字符串类型
- app_name = str(payload.get('app_name', 'Visafly'))
- code = str(payload.get('code', ''))
- expiration_time = str(payload.get('expiration_time', '10 minutes'))
- # 链式替换所有占位符
- html_content = template.replace('{{app_name}}', app_name) \
- .replace('{{code}}', code) \
- .replace('{{expiration_time}}', expiration_time)
- return html_content
- def template_for_reset_pwd(payload):
- """
- 生成重置密码验证码邮件
-
- Args:
- payload (dict): 包含以下字段:
- - app_name: 应用名称 (默认 Visafly)
- - code: 验证码
- - expiration_time: 过期时间描述 (如 "10 minutes")
- """
-
- # 1. 定义 HTML 模板
- template = '''
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="UTF-8">
- <title>Reset Password</title>
- <style>
- body { font-family: 'Helvetica Neue', Arial, sans-serif; background-color: #f9f9f9; margin: 0; padding: 0; color: #333; }
- .container { max-width: 600px; margin: 40px auto; background-color: #ffffff; padding: 40px; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.05); }
- .header { border-bottom: 1px solid #eee; padding-bottom: 20px; margin-bottom: 30px; }
- .header h2 { margin: 0; color: #333; }
- .code { font-size: 32px; font-weight: bold; letter-spacing: 5px; color: #2563eb; background-color: #eff6ff; padding: 20px; text-align: center; border-radius: 8px; margin: 30px 0; border: 1px solid #dbeafe; }
- .warning { background-color: #fff7ed; border-left: 4px solid #f97316; padding: 15px; font-size: 14px; color: #c2410c; margin-top: 30px; }
- .footer { font-size: 12px; color: #999; margin-top: 40px; text-align: center; border-top: 1px solid #eee; padding-top: 20px; }
- </style>
- </head>
- <body>
- <div class="container">
- <div class="header">
- <h2>Password Reset Request</h2>
- </div>
-
- <p>Hello,</p>
- <p>We received a request to reset the password for your <strong>{{app_name}}</strong> account. Please use the following code to verify your identity:</p>
-
- <div class="code">{{code}}</div>
-
- <p>This code is valid for <strong>{{expiration_time}}</strong>.</p>
-
- <div class="warning">
- <strong>Security Tip:</strong> If you did not request a password reset, please ignore this email. No changes will be made to your account.
- </div>
-
- <br>
- <p>Best regards,<br>The {{app_name}} Team</p>
-
- <div class="footer">
- © 2026 {{app_name}}. All rights reserved.<br>
- This is an automated message, please do not reply.
- </div>
- </div>
- </body>
- </html>
- '''
- # 2. 执行数据替换
- # 使用 .get() 提供默认值,防止缺少字段导致报错
- # 使用 str() 确保数据是字符串类型,防止 replace 报错
- app_name = str(payload.get('app_name', 'Visafly'))
- code = str(payload.get('code', ''))
- expiration_time = str(payload.get('expiration_time', '10 minutes'))
- html_content = template.replace('{{app_name}}', app_name) \
- .replace('{{code}}', code) \
- .replace('{{expiration_time}}', expiration_time)
- return html_content
-
- def template_for_login_credentials(payload):
- """
- 生成用户登录凭证邮件 (账号 + 临时密码)
-
- Args:
- payload (dict): 包含以下字段:
- - app_name: 应用名称
- - username: 登录账号
- - password: 临时密码
- - login_url: 登录链接
- """
-
- # 1. 定义 HTML 模板
- template = '''
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="UTF-8">
- <title>Your Account Details</title>
- <style>
- body { font-family: 'Helvetica Neue', Arial, sans-serif; background-color: #f4f6f8; margin: 0; padding: 0; color: #333; }
- .container { max-width: 600px; margin: 40px auto; background-color: #ffffff; padding: 40px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
- .header { text-align: center; border-bottom: 1px solid #eee; padding-bottom: 20px; margin-bottom: 30px; }
- .header h1 { font-size: 24px; color: #1a1a1a; margin: 0; }
- .creds-box { background-color: #f0f7ff; border: 1px solid #dbeafe; border-radius: 8px; padding: 20px; margin: 25px 0; }
- .creds-item { margin-bottom: 10px; font-size: 16px; }
- .creds-label { font-weight: bold; color: #555; width: 100px; display: inline-block; }
- .creds-value { font-family: 'Courier New', Courier, monospace; font-weight: bold; color: #2563eb; }
- .btn { display: block; width: 200px; margin: 30px auto; background-color: #2563eb; color: #ffffff !important; text-align: center; padding: 12px 0; border-radius: 6px; text-decoration: none; font-weight: bold; }
- .note { font-size: 13px; color: #666; background-color: #fff4e5; padding: 10px; border-radius: 4px; border-left: 4px solid #f97316; }
- .footer { font-size: 12px; color: #999; margin-top: 40px; text-align: center; }
- </style>
- </head>
- <body>
- <div class="container">
- <div class="header">
- <h1>Welcome to {{app_name}}</h1>
- </div>
-
- <p>Dear User,</p>
- <p>Your account has been successfully set up. Below are your temporary login credentials.</p>
-
- <div class="creds-box">
- <div class="creds-item">
- <span class="creds-label">Username:</span>
- <span class="creds-value">{{username}}</span>
- </div>
- <div class="creds-item">
- <span class="creds-label">Password:</span>
- <span class="creds-value">{{password}}</span>
- </div>
- </div>
- <div class="note">
- <strong>Important:</strong> For your security, please change your password immediately after logging in.
- </div>
- <a href="{{login_url}}" class="btn">Log In Now</a>
-
- <p style="text-align: center; font-size: 14px;">
- Or copy this link: <a href="{{login_url}}">{{login_url}}</a>
- </p>
- <div class="footer">
- © 2026 {{app_name}}. All rights reserved.<br>
- If you did not request this account, please contact support.
- </div>
- </div>
- </body>
- </html>
- '''
- # 2. 执行数据替换
- # 使用 payload.get() 提供默认值,防止缺少字段导致报错
- app_name = str(payload.get('app_name', 'Visafly'))
- username = str(payload.get('username', ''))
- password = str(payload.get('password', ''))
- login_url = str(payload.get('login_url', '#'))
- # 链式替换所有占位符
- html_content = template.replace('{{app_name}}', app_name) \
- .replace('{{username}}', username) \
- .replace('{{password}}', password) \
- .replace('{{login_url}}', login_url)
- return html_content
-
- def template_ticket_open(payload):
- """
- 生成工单创建通知邮件
- payload 需包含: username, ticket_id, ticket_type, created_at, ticket_url, app_name
- """
-
- # --- 1. 处理时间格式化逻辑 ---
- raw_time = payload.get('created_at')
- formatted_time = ""
- if isinstance(raw_time, datetime):
- # 如果传入的是 datetime 对象
- formatted_time = raw_time.strftime('%Y-%m-%d %H:%M') + " (UTC)"
- elif isinstance(raw_time, str):
- try:
- # 如果传入的是 ISO 字符串 (例如 '2025-12-31T02:33:00Z')
- # 截取前19位通常能兼容大部分 ISO 格式
- dt_obj = datetime.fromisoformat(raw_time.replace('Z', '+00:00'))
- formatted_time = dt_obj.strftime('%Y-%m-%d %H:%M') + " (UTC)"
- except ValueError:
- # 如果解析失败,直接显示原字符串
- formatted_time = raw_time
- else:
- formatted_time = "N/A"
- # --- 2. HTML 模板 ---
- # 注意:这里保持了 {{key}} 占位符,下面会统一替换
- template = '''
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="UTF-8">
- <title>Ticket Created</title>
- <style>
- body { font-family: 'Helvetica Neue', Arial, sans-serif; background-color: #f4f6f8; margin: 0; padding: 0; color: #333; }
- .container { max-width: 600px; margin: 40px auto; background-color: #ffffff; padding: 40px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
- .header { border-bottom: 1px solid #eee; padding-bottom: 20px; margin-bottom: 30px; }
- .header h1 { font-size: 22px; color: #1a1a1a; margin: 0; }
- .ticket-info { background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin: 20px 0; }
- .info-row { margin-bottom: 10px; display: flex; justify-content: space-between; }
- .info-label { color: #64748b; font-size: 14px; }
- .info-value { font-weight: bold; color: #0f172a; font-size: 14px; }
- .btn { display: block; width: 200px; margin: 30px auto; background-color: #2563eb; color: #ffffff !important; text-align: center; padding: 12px 0; border-radius: 6px; text-decoration: none; font-weight: bold; font-size: 14px; }
- .footer { font-size: 12px; color: #94a3b8; margin-top: 40px; text-align: center; }
- </style>
- </head>
- <body>
- <div class="container">
- <div class="header">
- <h1>Support Request Received</h1>
- </div>
-
- <p>Hello {{username}},</p>
- <p>We wanted to let you know that we've received your request. Our team is currently reviewing the details.</p>
-
- <div class="ticket-info">
- <div class="info-row">
- <span class="info-label">Ticket ID:</span>
- <span class="info-value">#{{ticket_id}}</span>
- </div>
- <div class="info-row">
- <span class="info-label">Type:</span>
- <span class="info-value">{{ticket_type}}</span>
- </div>
- <div class="info-row" style="margin-bottom: 0;">
- <span class="info-label">Created Time:</span>
- <!-- 使用格式化后的时间 -->
- <span class="info-value">{{created_at}}</span>
- </div>
- </div>
- <p>We usually reply within 24 hours. You will receive an email notification when our agent replies.</p>
- <a href="{{ticket_url}}" class="btn">View Ticket Details</a>
-
- <div class="footer">
- © 2025 {{app_name}}. All rights reserved.<br>
- Please do not reply to this email directly.
- </div>
- </div>
- </body>
- </html>
- '''
- # --- 3. 执行替换 ---
- # 使用 payload 中的数据替换模板占位符
- html_content = template.replace('{{username}}', str(payload.get('username', 'User'))) \
- .replace('{{ticket_id}}', str(payload.get('ticket_id', ''))) \
- .replace('{{ticket_type}}', str(payload.get('ticket_type', ''))) \
- .replace('{{created_at}}', formatted_time) \
- .replace('{{ticket_url}}', str(payload.get('ticket_url', '#'))) \
- .replace('{{app_name}}', str(payload.get('app_name', 'Visafly')))
- return html_content
- def template_appointment_confirmation(payload):
- """
- 生成预约成功确认邮件 (VisaFly)
-
- payload 需包含:
- - username: 用户名
- - order_id: 订单号 (新增)
- - country: 国家
- - city: 城市
- - appointment_date: 预约时间 (字符串, 例如 "2026-03-15 09:00")
- - visa_type: 签证类型
- - user_email: 用户邮箱 (用于提示信件已发往此处)
- """
-
- # --- 1. 基础配置 (VisaFly) ---
- company_name = "VisaFly"
- support_email = "support@visafly.top"
- website_home = "https://visafly.top"
- website_contact = "https://visafly.top/refund-policy"
- # --- 2. HTML 模板 ---
- template = '''
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="UTF-8">
- <title>Appointment Confirmed</title>
- <style>
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f4f6f8; margin: 0; padding: 0; color: #333; }
- .container { max-width: 600px; margin: 30px auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
-
- /* 头部: 品牌蓝 */
- .header { background-color: #0056b3; padding: 30px 20px; text-align: center; color: white; }
- .header h1 { margin: 0; font-size: 24px; font-weight: bold; }
- .header .subtitle { margin-top: 5px; opacity: 0.9; font-size: 14px; }
-
- /* 正文区域 */
- .content { padding: 30px 25px; line-height: 1.6; }
-
- /* 核心警告框 (黄色高亮 - 强调查收邮件) */
- .alert-box { background-color: #fff8e1; border-left: 5px solid #ffc107; padding: 15px 20px; margin: 25px 0; border-radius: 4px; }
- .alert-title { font-weight: bold; color: #b00020; display: block; margin-bottom: 5px; font-size: 16px; }
-
- /* 订单详情卡片 */
- .details-box { background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 15px; margin-bottom: 25px; }
- .info-row { display: flex; justify-content: space-between; margin-bottom: 10px; border-bottom: 1px dashed #e2e8f0; padding-bottom: 8px; }
- .info-row:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
- .label { color: #64748b; font-size: 13px; font-weight: 500; }
- .value { font-weight: bold; color: #0f172a; font-size: 14px; text-align: right; }
-
- /* 底部联系方式 */
- .help-section { background-color: #f1f5f9; padding: 20px; text-align: center; font-size: 14px; color: #475569; border-top: 1px solid #e2e8f0; }
- .contact-link { color: #0056b3; font-weight: bold; text-decoration: none; margin: 0 5px; }
-
- .footer { text-align: center; padding: 20px; font-size: 12px; color: #94a3b8; }
- a { color: #0056b3; text-decoration: none; }
- </style>
- </head>
- <body>
- <div class="container">
- <div class="header">
- <h1>✅ Booking Successful!</h1>
- <div class="subtitle">Appointment Secured by {{company_name}}</div>
- </div>
-
- <div class="content">
- <p>Hello <b>{{username}}</b>,</p>
- <p>Great news! We have successfully secured your appointment slot.</p>
-
- <!-- 详情卡片 -->
- <div class="details-box">
- <div class="info-row">
- <span class="label">Order ID:</span>
- <span class="value">#{{order_id}}</span>
- </div>
- <div class="info-row">
- <span class="label">Country / City:</span>
- <span class="value">{{country}} - {{city}}</span>
- </div>
- <div class="info-row">
- <span class="label">Appointment Date:</span>
- <span class="value">{{appointment_date}}</span>
- </div>
- <div class="info-row">
- <span class="label">Visa Type:</span>
- <span class="value">{{visa_type}}</span>
- </div>
- </div>
- <!-- 核心:检查邮件提示 -->
- <div class="alert-box">
- <span class="alert-title">📩 Important: Check Your Email</span>
- We have sent the official confirmation letter to <b>{{user_email}}</b>.<br><br>
- <span style="font-size: 13px;">If you don't see it in your Inbox, please check your <b>Spam/Junk</b> folder immediately.</span>
- </div>
- </div>
- <!-- 兜底联系方式 -->
- <div class="help-section">
- <p style="margin-top: 0; font-weight: bold;">Did not receive the email?</p>
- <p style="margin-bottom: 15px;">Please check your Spam folder first. If still missing, contact us:</p>
-
- <!-- 方式1: 邮件 -->
- <a href="mailto:{{support_email}}" class="contact-link">✉️ Email Support</a>
- <span style="color: #cbd5e1;">|</span>
- <!-- 方式2: 官网 -->
- <a href="{{website_contact}}" class="contact-link">🌐 Contact Us</a>
- </div>
-
- <div class="footer">
- © 2026 {{company_name}}. All rights reserved.<br>
- <a href="{{website_home}}">{{website_home}}</a>
- </div>
- </div>
- </body>
- </html>
- '''
- # --- 3. 执行替换 ---
- html_content = template.replace('{{username}}', str(payload.get('username', 'Customer'))) \
- .replace('{{order_id}}', str(payload.get('order_id', 'N/A'))) \
- .replace('{{country}}', str(payload.get('country', ''))) \
- .replace('{{city}}', str(payload.get('city', ''))) \
- .replace('{{appointment_date}}', str(payload.get('appointment_date', 'Confirmed'))) \
- .replace('{{user_email}}', str(payload.get('user_email', 'your email'))) \
- .replace('{{visa_type}}', str(payload.get('visa_type', 'Standard'))) \
- .replace('{{company_name}}', company_name) \
- .replace('{{support_email}}', support_email) \
- .replace('{{website_contact}}', website_contact) \
- .replace('{{website_home}}', website_home)
- return html_content
|