notification_task.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import asyncio
  2. import json
  3. from datetime import datetime
  4. from typing import Dict, Any
  5. from redis.asyncio import Redis
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from app.services.wechat_service import WechatService
  8. from app.services.email_authorizations_service import EmailAuthorizationService
  9. from app.utils.redis_utils import redis_qpop
  10. async def notification_consumer(session_factory, redis_client: Redis):
  11. """
  12. 异步消费 Redis 队列 vas_notification_queue
  13. """
  14. queue_name = "vas_notification_queue"
  15. while True:
  16. try:
  17. # 阻塞获取队列消息
  18. message: Dict[str, Any] = await redis_qpop(redis_client, queue_name, timeout=5)
  19. if not message:
  20. await asyncio.sleep(1) # 队列为空,休眠
  21. continue
  22. channel = message.get("channel", "")
  23. template_id = message.get("template_id")
  24. payload = message.get("payload", {})
  25. # 按渠道发送
  26. if "email" == channel:
  27. content = None
  28. sender = None
  29. subject = None
  30. receiver = message.get("receiver", "")
  31. if "email_verification_for_bind" == template_id:
  32. sender = "donotreply@visafly.top"
  33. subject = "Email Verification"
  34. content = template_for_bind_email(payload)
  35. if "email_verification_for_reset" == template_id:
  36. sender = "donotreply@visafly.top"
  37. subject = "Reset Password"
  38. content = template_for_reset_pwd(payload)
  39. if "login_credentials" == template_id:
  40. sender = "donotreply@visafly.top"
  41. subject = "Your Account Details"
  42. content = template_for_login_credentials(payload)
  43. if "ticket_created" == template_id:
  44. sender = "donotreply@visafly.top"
  45. subject = "Ticket Created"
  46. content = template_ticket_open(payload)
  47. if content:
  48. async with session_factory() as db: # type: AsyncSession
  49. auth = await EmailAuthorizationService.get_by_email(db, sender)
  50. send_result = await EmailAuthorizationService.send_email(auth, receiver, subject, "html", content)
  51. print(f"Email send result: {send_result}")
  52. if "wechat" == channel:
  53. api_token = "a8f79817-e18b-4739-8459-adb2ed5e2e32"
  54. if "payment_user_confirmed" == template_id:
  55. status = await WechatService.push_payment_template(api_token, payload)
  56. print(f"Wechat send status: {status}")
  57. if "slot_snapshot" == template_id:
  58. status = await WechatService.push_slot_snapshot(api_token, payload)
  59. print(f"✅ Notification sent: {message.get('notification_id')}")
  60. except Exception as e:
  61. print(f"⚠️ Notification consumer error: {e}")
  62. await asyncio.sleep(1) # 避免异常循环过快
  63. def template_for_bind_email(payload):
  64. """
  65. 生成绑定邮箱验证码邮件
  66. Args:
  67. payload (dict): 包含以下字段:
  68. - app_name: 应用名称 (默认 Visafly)
  69. - code: 验证码
  70. - expiration_time: 过期时间描述 (如 "10 minutes")
  71. """
  72. # 1. 定义 HTML 模板
  73. template = '''
  74. <!DOCTYPE html>
  75. <html>
  76. <head>
  77. <meta charset="UTF-8">
  78. <title>Email Verification</title>
  79. <style>
  80. body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 0; }
  81. .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); }
  82. .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; }
  83. .footer { font-size: 12px; color: #888; margin-top: 30px; text-align: center; }
  84. </style>
  85. </head>
  86. <body>
  87. <div class="container">
  88. <h2>Verify your email address</h2>
  89. <p>Hello,</p>
  90. <p>You requested to bind this email address to your <strong>{{app_name}}</strong> account. Please use the verification code below to proceed:</p>
  91. <div class="code">{{code}}</div>
  92. <p>This code will expire in <strong>{{expiration_time}}</strong>.</p>
  93. <p>If you did not request this change, please ignore this email.</p>
  94. <br>
  95. <p>Best regards,<br>The {{app_name}} Team</p>
  96. <div class="footer">
  97. &copy; 2026 {{app_name}}. All rights reserved.
  98. </div>
  99. </div>
  100. </body>
  101. </html>
  102. '''
  103. # 2. 执行数据替换
  104. # 使用 .get() 提供默认值,防止缺少字段导致报错
  105. # 使用 str() 确保数据是字符串类型
  106. app_name = str(payload.get('app_name', 'Visafly'))
  107. code = str(payload.get('code', ''))
  108. expiration_time = str(payload.get('expiration_time', '10 minutes'))
  109. # 链式替换所有占位符
  110. html_content = template.replace('{{app_name}}', app_name) \
  111. .replace('{{code}}', code) \
  112. .replace('{{expiration_time}}', expiration_time)
  113. return html_content
  114. def template_for_reset_pwd(payload):
  115. """
  116. 生成重置密码验证码邮件
  117. Args:
  118. payload (dict): 包含以下字段:
  119. - app_name: 应用名称 (默认 Visafly)
  120. - code: 验证码
  121. - expiration_time: 过期时间描述 (如 "10 minutes")
  122. """
  123. # 1. 定义 HTML 模板
  124. template = '''
  125. <!DOCTYPE html>
  126. <html>
  127. <head>
  128. <meta charset="UTF-8">
  129. <title>Reset Password</title>
  130. <style>
  131. body { font-family: 'Helvetica Neue', Arial, sans-serif; background-color: #f9f9f9; margin: 0; padding: 0; color: #333; }
  132. .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); }
  133. .header { border-bottom: 1px solid #eee; padding-bottom: 20px; margin-bottom: 30px; }
  134. .header h2 { margin: 0; color: #333; }
  135. .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; }
  136. .warning { background-color: #fff7ed; border-left: 4px solid #f97316; padding: 15px; font-size: 14px; color: #c2410c; margin-top: 30px; }
  137. .footer { font-size: 12px; color: #999; margin-top: 40px; text-align: center; border-top: 1px solid #eee; padding-top: 20px; }
  138. </style>
  139. </head>
  140. <body>
  141. <div class="container">
  142. <div class="header">
  143. <h2>Password Reset Request</h2>
  144. </div>
  145. <p>Hello,</p>
  146. <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>
  147. <div class="code">{{code}}</div>
  148. <p>This code is valid for <strong>{{expiration_time}}</strong>.</p>
  149. <div class="warning">
  150. <strong>Security Tip:</strong> If you did not request a password reset, please ignore this email. No changes will be made to your account.
  151. </div>
  152. <br>
  153. <p>Best regards,<br>The {{app_name}} Team</p>
  154. <div class="footer">
  155. &copy; 2026 {{app_name}}. All rights reserved.<br>
  156. This is an automated message, please do not reply.
  157. </div>
  158. </div>
  159. </body>
  160. </html>
  161. '''
  162. # 2. 执行数据替换
  163. # 使用 .get() 提供默认值,防止缺少字段导致报错
  164. # 使用 str() 确保数据是字符串类型,防止 replace 报错
  165. app_name = str(payload.get('app_name', 'Visafly'))
  166. code = str(payload.get('code', ''))
  167. expiration_time = str(payload.get('expiration_time', '10 minutes'))
  168. html_content = template.replace('{{app_name}}', app_name) \
  169. .replace('{{code}}', code) \
  170. .replace('{{expiration_time}}', expiration_time)
  171. return html_content
  172. def template_for_login_credentials(payload):
  173. """
  174. 生成用户登录凭证邮件 (账号 + 临时密码)
  175. Args:
  176. payload (dict): 包含以下字段:
  177. - app_name: 应用名称
  178. - username: 登录账号
  179. - password: 临时密码
  180. - login_url: 登录链接
  181. """
  182. # 1. 定义 HTML 模板
  183. template = '''
  184. <!DOCTYPE html>
  185. <html>
  186. <head>
  187. <meta charset="UTF-8">
  188. <title>Your Account Details</title>
  189. <style>
  190. body { font-family: 'Helvetica Neue', Arial, sans-serif; background-color: #f4f6f8; margin: 0; padding: 0; color: #333; }
  191. .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); }
  192. .header { text-align: center; border-bottom: 1px solid #eee; padding-bottom: 20px; margin-bottom: 30px; }
  193. .header h1 { font-size: 24px; color: #1a1a1a; margin: 0; }
  194. .creds-box { background-color: #f0f7ff; border: 1px solid #dbeafe; border-radius: 8px; padding: 20px; margin: 25px 0; }
  195. .creds-item { margin-bottom: 10px; font-size: 16px; }
  196. .creds-label { font-weight: bold; color: #555; width: 100px; display: inline-block; }
  197. .creds-value { font-family: 'Courier New', Courier, monospace; font-weight: bold; color: #2563eb; }
  198. .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; }
  199. .note { font-size: 13px; color: #666; background-color: #fff4e5; padding: 10px; border-radius: 4px; border-left: 4px solid #f97316; }
  200. .footer { font-size: 12px; color: #999; margin-top: 40px; text-align: center; }
  201. </style>
  202. </head>
  203. <body>
  204. <div class="container">
  205. <div class="header">
  206. <h1>Welcome to {{app_name}}</h1>
  207. </div>
  208. <p>Dear User,</p>
  209. <p>Your account has been successfully set up. Below are your temporary login credentials.</p>
  210. <div class="creds-box">
  211. <div class="creds-item">
  212. <span class="creds-label">Username:</span>
  213. <span class="creds-value">{{username}}</span>
  214. </div>
  215. <div class="creds-item">
  216. <span class="creds-label">Password:</span>
  217. <span class="creds-value">{{password}}</span>
  218. </div>
  219. </div>
  220. <div class="note">
  221. <strong>Important:</strong> For your security, please change your password immediately after logging in.
  222. </div>
  223. <a href="{{login_url}}" class="btn">Log In Now</a>
  224. <p style="text-align: center; font-size: 14px;">
  225. Or copy this link: <a href="{{login_url}}">{{login_url}}</a>
  226. </p>
  227. <div class="footer">
  228. &copy; 2026 {{app_name}}. All rights reserved.<br>
  229. If you did not request this account, please contact support.
  230. </div>
  231. </div>
  232. </body>
  233. </html>
  234. '''
  235. # 2. 执行数据替换
  236. # 使用 payload.get() 提供默认值,防止缺少字段导致报错
  237. app_name = str(payload.get('app_name', 'Visafly'))
  238. username = str(payload.get('username', ''))
  239. password = str(payload.get('password', ''))
  240. login_url = str(payload.get('login_url', '#'))
  241. # 链式替换所有占位符
  242. html_content = template.replace('{{app_name}}', app_name) \
  243. .replace('{{username}}', username) \
  244. .replace('{{password}}', password) \
  245. .replace('{{login_url}}', login_url)
  246. return html_content
  247. def template_ticket_open(payload):
  248. """
  249. 生成工单创建通知邮件
  250. payload 需包含: username, ticket_id, ticket_type, created_at, ticket_url, app_name
  251. """
  252. # --- 1. 处理时间格式化逻辑 ---
  253. raw_time = payload.get('created_at')
  254. formatted_time = ""
  255. if isinstance(raw_time, datetime):
  256. # 如果传入的是 datetime 对象
  257. formatted_time = raw_time.strftime('%Y-%m-%d %H:%M') + " (UTC)"
  258. elif isinstance(raw_time, str):
  259. try:
  260. # 如果传入的是 ISO 字符串 (例如 '2025-12-31T02:33:00Z')
  261. # 截取前19位通常能兼容大部分 ISO 格式
  262. dt_obj = datetime.fromisoformat(raw_time.replace('Z', '+00:00'))
  263. formatted_time = dt_obj.strftime('%Y-%m-%d %H:%M') + " (UTC)"
  264. except ValueError:
  265. # 如果解析失败,直接显示原字符串
  266. formatted_time = raw_time
  267. else:
  268. formatted_time = "N/A"
  269. # --- 2. HTML 模板 ---
  270. # 注意:这里保持了 {{key}} 占位符,下面会统一替换
  271. template = '''
  272. <!DOCTYPE html>
  273. <html>
  274. <head>
  275. <meta charset="UTF-8">
  276. <title>Ticket Created</title>
  277. <style>
  278. body { font-family: 'Helvetica Neue', Arial, sans-serif; background-color: #f4f6f8; margin: 0; padding: 0; color: #333; }
  279. .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); }
  280. .header { border-bottom: 1px solid #eee; padding-bottom: 20px; margin-bottom: 30px; }
  281. .header h1 { font-size: 22px; color: #1a1a1a; margin: 0; }
  282. .ticket-info { background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin: 20px 0; }
  283. .info-row { margin-bottom: 10px; display: flex; justify-content: space-between; }
  284. .info-label { color: #64748b; font-size: 14px; }
  285. .info-value { font-weight: bold; color: #0f172a; font-size: 14px; }
  286. .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; }
  287. .footer { font-size: 12px; color: #94a3b8; margin-top: 40px; text-align: center; }
  288. </style>
  289. </head>
  290. <body>
  291. <div class="container">
  292. <div class="header">
  293. <h1>Support Request Received</h1>
  294. </div>
  295. <p>Hello {{username}},</p>
  296. <p>We wanted to let you know that we've received your request. Our team is currently reviewing the details.</p>
  297. <div class="ticket-info">
  298. <div class="info-row">
  299. <span class="info-label">Ticket ID:</span>
  300. <span class="info-value">#{{ticket_id}}</span>
  301. </div>
  302. <div class="info-row">
  303. <span class="info-label">Type:</span>
  304. <span class="info-value">{{ticket_type}}</span>
  305. </div>
  306. <div class="info-row" style="margin-bottom: 0;">
  307. <span class="info-label">Created Time:</span>
  308. <!-- 使用格式化后的时间 -->
  309. <span class="info-value">{{created_at}}</span>
  310. </div>
  311. </div>
  312. <p>We usually reply within 24 hours. You will receive an email notification when our agent replies.</p>
  313. <a href="{{ticket_url}}" class="btn">View Ticket Details</a>
  314. <div class="footer">
  315. &copy; 2025 {{app_name}}. All rights reserved.<br>
  316. Please do not reply to this email directly.
  317. </div>
  318. </div>
  319. </body>
  320. </html>
  321. '''
  322. # --- 3. 执行替换 ---
  323. # 使用 payload 中的数据替换模板占位符
  324. html_content = template.replace('{{username}}', str(payload.get('username', 'User'))) \
  325. .replace('{{ticket_id}}', str(payload.get('ticket_id', ''))) \
  326. .replace('{{ticket_type}}', str(payload.get('ticket_type', ''))) \
  327. .replace('{{created_at}}', formatted_time) \
  328. .replace('{{ticket_url}}', str(payload.get('ticket_url', '#'))) \
  329. .replace('{{app_name}}', str(payload.get('app_name', 'Visafly')))
  330. return html_content