|
@@ -1,17 +1,19 @@
|
|
|
|
|
|
|
|
import asyncio
|
|
import asyncio
|
|
|
|
|
+import json
|
|
|
|
|
+from datetime import datetime
|
|
|
from typing import Dict, Any
|
|
from typing import Dict, Any
|
|
|
from redis.asyncio import Redis
|
|
from redis.asyncio import Redis
|
|
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
from app.services.wechat_service import WechatService
|
|
from app.services.wechat_service import WechatService
|
|
|
from app.services.email_authorizations_service import EmailAuthorizationService
|
|
from app.services.email_authorizations_service import EmailAuthorizationService
|
|
|
from app.utils.redis_utils import redis_qpop
|
|
from app.utils.redis_utils import redis_qpop
|
|
|
|
|
|
|
|
-async def notification_consumer(redis_client: Redis):
|
|
|
|
|
|
|
+async def notification_consumer(session_factory, redis_client: Redis):
|
|
|
"""
|
|
"""
|
|
|
异步消费 Redis 队列 vas_notification_queue
|
|
异步消费 Redis 队列 vas_notification_queue
|
|
|
"""
|
|
"""
|
|
|
queue_name = "vas_notification_queue"
|
|
queue_name = "vas_notification_queue"
|
|
|
- return
|
|
|
|
|
while True:
|
|
while True:
|
|
|
try:
|
|
try:
|
|
|
# 阻塞获取队列消息
|
|
# 阻塞获取队列消息
|
|
@@ -20,22 +22,43 @@ async def notification_consumer(redis_client: Redis):
|
|
|
await asyncio.sleep(1) # 队列为空,休眠
|
|
await asyncio.sleep(1) # 队列为空,休眠
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
|
- channels = message.get("channels", [])
|
|
|
|
|
|
|
+ channel = message.get("channel", "")
|
|
|
template_id = message.get("template_id")
|
|
template_id = message.get("template_id")
|
|
|
payload = message.get("payload", {})
|
|
payload = message.get("payload", {})
|
|
|
- user_id = message.get("user_id")
|
|
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
# 按渠道发送
|
|
# 按渠道发送
|
|
|
- if "email" in channels:
|
|
|
|
|
- # EmailService.create(user_id, template_id, payload) 是你自己实现的发送逻辑
|
|
|
|
|
- await EmailAuthorizationService.send(user_id=user_id, template_id=template_id, payload=payload)
|
|
|
|
|
-
|
|
|
|
|
- if "wechat" in channels:
|
|
|
|
|
- api_token = payload.get("api_token")
|
|
|
|
|
- content = payload.get("message") or payload.get("content")
|
|
|
|
|
- if api_token and content:
|
|
|
|
|
- await WechatService.push_to_wechat({"api_token": api_token, "message": content})
|
|
|
|
|
|
|
+ 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 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}")
|
|
|
print(f"✅ Notification sent: {message.get('notification_id')}")
|
|
print(f"✅ Notification sent: {message.get('notification_id')}")
|
|
|
|
|
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
@@ -43,7 +66,17 @@ async def notification_consumer(redis_client: Redis):
|
|
|
await asyncio.sleep(1) # 避免异常循环过快
|
|
await asyncio.sleep(1) # 避免异常循环过快
|
|
|
|
|
|
|
|
def template_for_bind_email(payload):
|
|
def template_for_bind_email(payload):
|
|
|
|
|
+ """
|
|
|
|
|
+ 生成绑定邮箱验证码邮件
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ payload (dict): 包含以下字段:
|
|
|
|
|
+ - app_name: 应用名称 (默认 Visafly)
|
|
|
|
|
+ - code: 验证码
|
|
|
|
|
+ - expiration_time: 过期时间描述 (如 "10 minutes")
|
|
|
|
|
+ """
|
|
|
|
|
|
|
|
|
|
+ # 1. 定义 HTML 模板
|
|
|
template = '''
|
|
template = '''
|
|
|
<!DOCTYPE html>
|
|
<!DOCTYPE html>
|
|
|
<html>
|
|
<html>
|
|
@@ -72,14 +105,39 @@ def template_for_bind_email(payload):
|
|
|
<p>Best regards,<br>The {{app_name}} Team</p>
|
|
<p>Best regards,<br>The {{app_name}} Team</p>
|
|
|
|
|
|
|
|
<div class="footer">
|
|
<div class="footer">
|
|
|
- © 2025 {{app_name}}. All rights reserved.
|
|
|
|
|
|
|
+ © 2026 {{app_name}}. All rights reserved.
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</body>
|
|
</body>
|
|
|
</html>
|
|
</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):
|
|
def template_for_reset_pwd(payload):
|
|
|
|
|
+ """
|
|
|
|
|
+ 生成重置密码验证码邮件
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ payload (dict): 包含以下字段:
|
|
|
|
|
+ - app_name: 应用名称 (默认 Visafly)
|
|
|
|
|
+ - code: 验证码
|
|
|
|
|
+ - expiration_time: 过期时间描述 (如 "10 minutes")
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ # 1. 定义 HTML 模板
|
|
|
template = '''
|
|
template = '''
|
|
|
<!DOCTYPE html>
|
|
<!DOCTYPE html>
|
|
|
<html>
|
|
<html>
|
|
@@ -117,15 +175,40 @@ def template_for_reset_pwd(payload):
|
|
|
<p>Best regards,<br>The {{app_name}} Team</p>
|
|
<p>Best regards,<br>The {{app_name}} Team</p>
|
|
|
|
|
|
|
|
<div class="footer">
|
|
<div class="footer">
|
|
|
- © 2025 {{app_name}}. All rights reserved.<br>
|
|
|
|
|
|
|
+ © 2026 {{app_name}}. All rights reserved.<br>
|
|
|
This is an automated message, please do not reply.
|
|
This is an automated message, please do not reply.
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</body>
|
|
</body>
|
|
|
</html>
|
|
</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):
|
|
def template_for_login_credentials(payload):
|
|
|
|
|
+ """
|
|
|
|
|
+ 生成用户登录凭证邮件 (账号 + 临时密码)
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ payload (dict): 包含以下字段:
|
|
|
|
|
+ - app_name: 应用名称
|
|
|
|
|
+ - username: 登录账号
|
|
|
|
|
+ - password: 临时密码
|
|
|
|
|
+ - login_url: 登录链接
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ # 1. 定义 HTML 模板
|
|
|
template = '''
|
|
template = '''
|
|
|
<!DOCTYPE html>
|
|
<!DOCTYPE html>
|
|
|
<html>
|
|
<html>
|
|
@@ -177,15 +260,56 @@ def template_for_login_credentials(payload):
|
|
|
</p>
|
|
</p>
|
|
|
|
|
|
|
|
<div class="footer">
|
|
<div class="footer">
|
|
|
- © 2025 {{app_name}}. All rights reserved.<br>
|
|
|
|
|
|
|
+ © 2026 {{app_name}}. All rights reserved.<br>
|
|
|
If you did not request this account, please contact support.
|
|
If you did not request this account, please contact support.
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</body>
|
|
</body>
|
|
|
</html>
|
|
</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):
|
|
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 = '''
|
|
template = '''
|
|
|
<!DOCTYPE html>
|
|
<!DOCTYPE html>
|
|
|
<html>
|
|
<html>
|
|
@@ -224,7 +348,8 @@ def template_ticket_open(payload):
|
|
|
<span class="info-value">{{ticket_type}}</span>
|
|
<span class="info-value">{{ticket_type}}</span>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="info-row" style="margin-bottom: 0;">
|
|
<div class="info-row" style="margin-bottom: 0;">
|
|
|
- <span class="info-label">Time:</span>
|
|
|
|
|
|
|
+ <span class="info-label">Created Time:</span>
|
|
|
|
|
+ <!-- 使用格式化后的时间 -->
|
|
|
<span class="info-value">{{created_at}}</span>
|
|
<span class="info-value">{{created_at}}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -241,16 +366,109 @@ def template_ticket_open(payload):
|
|
|
</body>
|
|
</body>
|
|
|
</html>
|
|
</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 _format_date_en(dt_obj):
|
|
|
|
|
+ """Format date to '15 Jan 2024' to avoid US/UK format confusion."""
|
|
|
|
|
+ if not dt_obj:
|
|
|
|
|
+ return "N/A"
|
|
|
|
|
+ if isinstance(dt_obj, str):
|
|
|
|
|
+ return dt_obj
|
|
|
|
|
+ return dt_obj.strftime("%d %b %Y")
|
|
|
|
|
+
|
|
|
|
|
+def _format_time_en(dt_obj):
|
|
|
|
|
+ if not dt_obj:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ return dt_obj.strftime("%H:%M:%S")
|
|
|
|
|
+
|
|
|
|
|
+def _parse_slots_summary(availability_data):
|
|
|
|
|
+ """Convert JSON data to a readable English summary."""
|
|
|
|
|
+ if not availability_data:
|
|
|
|
|
+ return "No specific dates data."
|
|
|
|
|
|
|
|
-def template_confirm_payment(payload):
|
|
|
|
|
- template = {
|
|
|
|
|
- "touser": "ADMIN_USER_ID",
|
|
|
|
|
- "msgtype": "textcard",
|
|
|
|
|
- "agentid": 1000001,
|
|
|
|
|
- "textcard": {
|
|
|
|
|
- "title": "💰 待确认:收到新的手动转账",
|
|
|
|
|
- "description": "<div class=\"gray\">2025-12-31 10:30:00</div> <br>订单号:ORD-20251231-001<br>用户:user@example.com<br><div class=\"highlight\">金额:¥ 3,500.00</div><br>请核实资金到账后,点击卡片确认收款。",
|
|
|
|
|
- "url": "https://admin.visafly.com/payment/confirm?payment_id=123&token=secure_token_abc",
|
|
|
|
|
- "btntxt": "立即确认"
|
|
|
|
|
|
|
+ if isinstance(availability_data, list):
|
|
|
|
|
+ # Take first 3 dates only to keep it clean
|
|
|
|
|
+ dates = availability_data[:3]
|
|
|
|
|
+ text = ", ".join(str(d) for d in dates)
|
|
|
|
|
+ if len(availability_data) > 3:
|
|
|
|
|
+ text += f" (+{len(availability_data) - 3} more)"
|
|
|
|
|
+ return text
|
|
|
|
|
+ return str(availability_data)
|
|
|
|
|
+
|
|
|
|
|
+def _get_display_meta(data):
|
|
|
|
|
+ """
|
|
|
|
|
+ Return dynamic header logic based on status.
|
|
|
|
|
+ Goal: Put the most important info in the notification preview.
|
|
|
|
|
+ """
|
|
|
|
|
+ date_str = _format_date_en(data.earliest_date)
|
|
|
|
|
+
|
|
|
|
|
+ if data.availability_status == 'Available':
|
|
|
|
|
+ # Notification Preview: "🟢 UK London: 15 Jan 2024"
|
|
|
|
|
+ emoji = "🟢"
|
|
|
|
|
+ # If available, the DATE is the headline
|
|
|
|
|
+ headline = f"{data.country}, {data.city}: {date_str}"
|
|
|
|
|
+ color = "info" # Green for WeChat
|
|
|
|
|
+ elif data.availability_status == 'Waitlist':
|
|
|
|
|
+ emoji = "🟡"
|
|
|
|
|
+ headline = f"Waitlist: {data.country}, {data.city}"
|
|
|
|
|
+ color = "warning" # Orange for WeChat
|
|
|
|
|
+ else:
|
|
|
|
|
+ emoji = "🔴"
|
|
|
|
|
+ headline = f"No Slots: {data.country}, {data.city}"
|
|
|
|
|
+ color = "comment" # Grey for WeChat
|
|
|
|
|
+
|
|
|
|
|
+ return emoji, headline, color, date_str
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def generate_wechat_markdown(data) -> dict:
|
|
|
|
|
+ emoji, headline, color, date_str = _get_display_meta(data)
|
|
|
|
|
+ slots_summary = _parse_slots_summary(data.availability)
|
|
|
|
|
+
|
|
|
|
|
+ # WeCom uses Markdown.
|
|
|
|
|
+ # Logic: Keep the header distinct based on availability.
|
|
|
|
|
+
|
|
|
|
|
+ markdown_content = (
|
|
|
|
|
+ f"# {emoji} {headline}\n"
|
|
|
|
|
+ f"> **Visa Type**: <font color=\"comment\">{data.visa_type}</font>\n"
|
|
|
|
|
+ f"> **Earliest**: <font color=\"{color}\">{date_str}</font>\n"
|
|
|
|
|
+ f"> **Details**: <font color=\"comment\">{slots_summary}</font>\n"
|
|
|
|
|
+ f"\n"
|
|
|
|
|
+ f"👉 [Tap to Book Appointment]({data.website})\n"
|
|
|
|
|
+ f"\n"
|
|
|
|
|
+ f"<font color=\"comment\">Updated: {_format_time_en(data.snapshot_at)}</font>"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "msgtype": "markdown",
|
|
|
|
|
+ "markdown": {
|
|
|
|
|
+ "content": markdown_content
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+def generate_telegram_message(data) -> str:
|
|
|
|
|
+ emoji, headline, _, date_str = _get_display_meta(data)
|
|
|
|
|
+ slots_summary = _parse_slots_summary(data.availability)
|
|
|
|
|
+
|
|
|
|
|
+ # HTML formatting
|
|
|
|
|
+ # <b> for headers, <code> for copying/highlighting data
|
|
|
|
|
+ msg = (
|
|
|
|
|
+ f"{emoji} <b>{headline}</b>\n"
|
|
|
|
|
+ f"──────────────────\n"
|
|
|
|
|
+ f"🛂 <b>Visa:</b> {data.visa_type}\n"
|
|
|
|
|
+ f"📅 <b>Earliest:</b> <code>{date_str}</code>\n"
|
|
|
|
|
+ f"📊 <b>Slots:</b> {slots_summary}\n"
|
|
|
|
|
+ f"──────────────────\n"
|
|
|
|
|
+ f"🔗 <a href='{data.website}'><b>Book Now ➜</b></a>\n\n"
|
|
|
|
|
+ f"🕒 <i>Checked at {_format_time_en(data.snapshot_at)}</i>"
|
|
|
|
|
+ )
|
|
|
|
|
+ return msg
|