Kaynağa Gözat

feat: update

jerry 5 ay önce
ebeveyn
işleme
40e7b4c854
75 değiştirilmiş dosya ile 3908 ekleme ve 678 silme
  1. 2 0
      .env
  2. 303 318
      app/api/router.py
  3. 25 6
      app/core/auth.py
  4. 27 0
      app/core/biz_exception.py
  5. 6 1
      app/core/config.py
  6. 6 0
      app/core/payment.py
  7. 35 1
      app/main.py
  8. 16 0
      app/models/email_verification.py
  9. 25 0
      app/models/order.py
  10. 38 0
      app/models/payment.py
  11. 41 0
      app/models/payment_event.py
  12. 17 0
      app/models/payment_provider.py
  13. 14 0
      app/models/payment_qr.py
  14. 26 0
      app/models/product.py
  15. 19 0
      app/models/product_routing.py
  16. 15 0
      app/models/schema.py
  17. 16 0
      app/models/session.py
  18. 0 20
      app/models/slot.py
  19. 25 0
      app/models/slot_snapshot.py
  20. 24 0
      app/models/ticket.py
  21. 25 0
      app/models/user.py
  22. 32 0
      app/models/vas_task.py
  23. 0 16
      app/models/visafly_config.py
  24. 23 0
      app/schemas/auth.py
  25. 10 0
      app/schemas/common.py
  26. 44 0
      app/schemas/fake.py
  27. 25 0
      app/schemas/order.py
  28. 45 0
      app/schemas/payment.py
  29. 26 0
      app/schemas/payment_event.py
  30. 44 0
      app/schemas/payment_provider.py
  31. 25 0
      app/schemas/payment_qr.py
  32. 50 0
      app/schemas/product.py
  33. 32 0
      app/schemas/product_routing.py
  34. 27 0
      app/schemas/schema.py
  35. 5 4
      app/schemas/short_url.py
  36. 0 24
      app/schemas/slot.py
  37. 23 0
      app/schemas/slot_snapshot.py
  38. 10 0
      app/schemas/telegram.py
  39. 37 0
      app/schemas/ticket.py
  40. 19 4
      app/schemas/user.py
  41. 21 0
      app/schemas/vas_task.py
  42. 0 22
      app/schemas/visafly_config.py
  43. 13 0
      app/schemas/webhook.py
  44. 9 0
      app/schemas/wechat.py
  45. 162 0
      app/services/auth_service.py
  46. 10 3
      app/services/configuration_service.py
  47. 158 152
      app/services/email_authorizations_service.py
  48. 258 0
      app/services/fake_service.py
  49. 8 4
      app/services/http_session_service.py
  50. 25 0
      app/services/notification_service.py
  51. 118 0
      app/services/order_service.py
  52. 88 0
      app/services/payment_provider_service.py
  53. 26 0
      app/services/payment_qr_service.py
  54. 231 0
      app/services/payment_service.py
  55. 15 0
      app/services/product_routing_service.py
  56. 31 0
      app/services/product_service.py
  57. 38 0
      app/services/schema_service.py
  58. 39 0
      app/services/session_service.py
  59. 5 2
      app/services/short_url_service.py
  60. 0 42
      app/services/slot_service.py
  61. 15 0
      app/services/slot_snapshot_service.py
  62. 0 1
      app/services/sms_service.py
  63. 6 2
      app/services/task_service.py
  64. 21 0
      app/services/telegram_service.py
  65. 53 0
      app/services/ticket_service.py
  66. 19 6
      app/services/troov_service.py
  67. 67 0
      app/services/vas_task_service.py
  68. 0 50
      app/services/visafly_config_service.py
  69. 286 0
      app/services/webhook_service.py
  70. 26 0
      app/services/wechat_service.py
  71. 860 0
      app/utils/france_slot_api.py
  72. 56 0
      app/utils/proxy_utils.py
  73. 22 0
      app/utils/redis_utils.py
  74. 31 0
      app/utils/response.py
  75. 9 0
      app/utils/validation_utils.py

+ 2 - 0
.env

@@ -1,3 +1,5 @@
 DATABASE_URL=mysql://root:GqLLL7Bofj0WaaOpp.0@visafly.top:3306/book_user_info?charset=utf8mb4
 REDIS_URL=redis://:STEs2x6ML0U1HlpE9SojM6YU7QPhqzY8@45.137.220.138:6379/0
 API_TOKEN=7x9EjFpmv7GjZc6AfVeqxuUBANpqkpkHAtxJM7CAW5oZhs0nEyCJBy39N4XXs5hgfYWXw3jFrcgXqQ42HAx9Qvwtk9vC2GvKBbWz
+OPENAI_API_KEY=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
+STRIPE_API_KEY=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

+ 303 - 318
app/api/router.py

@@ -1,25 +1,47 @@
 import time
+import uuid
+import json
 import requests
 from typing import List
 from app.core.logger import logger
-from fastapi import APIRouter, Query, Depends, Body, UploadFile, File, HTTPException
+from fastapi import APIRouter, Request, Query, Depends, Body, UploadFile, File, HTTPException
 from fastapi.responses import RedirectResponse
 from sqlalchemy.orm import Session
+from app.utils.redis_utils import redis_qpush
+from app.utils.validation_utils import validate_user_inputs
 from app.core.redis import get_redis_client
 from app.core.database import get_db
+from app.core.auth import get_current_user
 from redis.asyncio import Redis
-from app.schemas.user import UserOut
+
+from app.utils.response import success, fail
+from app.models.user import VasUser
+from app.models.order import VasOrder
+from app.models.schema import VasSchema
+from app.models.product import VasProduct
+from app.models.payment import VasPayment
+from app.schemas.common import ApiResponse
 from app.schemas.troov import TroovRate
 from app.schemas.sms import ShortMessageDetail
 from app.schemas.configuration import ConfigurationCreate, ConfigurationUpdate, ConfigurationOut
 from app.schemas.email_authorizations import EmailContent, EmailAuthorizationCreate, EmailAuthorizationUpdate, EmailAuthorizationOut
 from app.schemas.card import CardCreate, CardOut
 from app.schemas.task import TaskCreate, TaskOut, TaskUpdate
-from app.schemas.short_url import ShortUrlCreate
+from app.schemas.short_url import ShortUrlCreate, ShortUrlOut
 from app.schemas.auto_booking import AutoBookingCreate, AutoBookingOut
 from app.schemas.http_session import HttpSessionCreate, HttpSessionUpdate,HttpSessionOut
-from app.schemas.visafly_config import VisaflyConfigCreate, VisaflyConfigOut
-from app.schemas.slot import SlotCreate, SlotOut
+from app.schemas.fake import FakeUser
+from app.schemas.auth import BindEmailRequest, LoginRequest, LoginData, AutoRegisterRequest, AutoRegisterData
+from app.schemas.product import VasProductCreate, VasProductUpdate, VasProductOut
+from app.schemas.order import VasOrderCreate, VasOrderOut
+from app.schemas.payment import VasPaymentCreate, VasPaymentOut
+from app.schemas.payment_qr import VasPaymentQrSimpleOut
+from app.schemas.payment_provider import VasPaymentProviderSimpleOut
+from app.schemas.webhook import SMSHelperWebhookPayload
+from app.schemas.vas_task import VasTaskCreate, VasTaskOut
+from app.schemas.ticket import VasTicketCreate
+from app.schemas.telegram import TelegramIn
+from app.schemas.wechat import WechatIn
 from app.services.configuration_service import ConfigurationService
 from app.services.troov_service import get_rate_by_date
 from app.services.sms_service import save_short_message, query_short_message
@@ -30,26 +52,32 @@ from app.services.card_service import CardService
 from app.services.seaweedfs_service import SeaweedFSService
 from app.services.auto_booking_service import AutoBookingService
 from app.services.http_session_service import HttpSessionService
-from app.services.visafly_config_service import VisaflyConfigService
-from app.services.slot_service import SlotService
+from app.services.fake_service import generate_fake_users
+from app.services.auth_service import AuthService
+from app.services.product_service import ProductService
+from app.services.order_service import OrderService
+from app.services.schema_service import SchemaService
+from app.services.payment_service import PaymentService
+from app.services.payment_provider_service import PaymentProviderSerivce
+from app.services.payment_qr_service import PaymentQrService
+from app.services.vas_task_service import VasTaskService
+from app.services.webhook_service import WebhookService
+from app.services.notification_service import NotificationService
+from app.services.ticket_service import TicketService
+from app.services.telegram_service import TelegramService
+from app.services.wechat_service import WechatService
+
 
 # 公共路由
 public_router = APIRouter()
 # 受保护路由
 protected_router = APIRouter()
 
-@public_router.get("/ping", summary="心跳检测", tags=["测试接口"])
+@protected_router.get("/ping", summary="心跳检测", tags=["测试接口"])
 def ping():
     return {"message": "pong"}
 
-@protected_router.get("/users", summary="查询用户", tags=["通用接口"], response_model=List[UserOut])
-def get_users():
-    return [
-        {"id": 1, "name": "Alice"},
-        {"id": 2, "name": "Bob"}
-    ]
-
-@public_router.get("/sms/upload", summary="上报短信", tags=["短信接口"], response_model=ShortMessageDetail)
+@public_router.get("/sms/upload", summary="上报短信", tags=["短信接口"], response_model=ApiResponse[ShortMessageDetail])
 def sms_upload(
     phone: str = Query(..., description="手机号"),
     message: str = Query(..., description="短信内容"),
@@ -61,9 +89,9 @@ def sms_upload(
     """
     received_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
     msg = save_short_message(redis_client, phone, message, received_at, max_ttl)
-    return msg
+    return success(data=msg)
 
-@protected_router.get("/sms/download", summary="读取短信", tags=["短信接口"], response_model=List[ShortMessageDetail])
+@protected_router.get("/sms/download", summary="读取短信", tags=["短信接口"], response_model=ApiResponse[List[ShortMessageDetail]])
 def sms_download(
     phone: str = Query(..., description="手机号"),
     keyword: str = Query('', description="短信内容关键字"),
@@ -73,165 +101,118 @@ def sms_download(
     """
     查询短信(支持关键字和时间过滤)
     """
-    results = query_short_message(redis_client, phone, keyword or None, sent_at or None)
-    return results
+    obj = query_short_message(redis_client, phone, keyword or None, sent_at or None)
+    return success(data=obj)
 
-@protected_router.get("/troov/rate", summary="TROOV 查询rate", tags=["通用接口"], response_model=List[TroovRate])
+@protected_router.get("/troov/rate", summary="TROOV 查询rate", tags=["通用接口"], response_model=ApiResponse[List[TroovRate]])
 def troov_rate(date: str = Query(..., description="查询的日期, 格式: YYYY-MM-DD"),
                redis_client: Redis = Depends(get_redis_client)):
     # 调用 service 层获取数据
-    return get_rate_by_date(redis_client, date)
+    obj = get_rate_by_date(redis_client, date)
+    return success(data=obj)
 
-@protected_router.post("/dynamic-configurations", summary="创建动态配置", tags=["动态配置"], response_model=ConfigurationOut)
+@protected_router.post("/dynamic-configurations", summary="创建动态配置", tags=["动态配置"], response_model=ApiResponse[ConfigurationOut])
 def dynamic_config_create(config_in: ConfigurationCreate, db: Session = Depends(get_db)):
-    existing = ConfigurationService.get_by_key(db, config_in.config_key)
-    if existing:
-        raise HTTPException(status_code=400, detail=f"配置 Key '{config_in.config_key}' 已存在")
-    return ConfigurationService.create(db, config_in)
-
+    obj = ConfigurationService.create(db, config_in)
+    return success(data=obj)
 
-@protected_router.get("/dynamic-configurations/all", summary="读取所有动态配置", tags=["动态配置"], response_model=List[ConfigurationOut])
+@protected_router.get("/dynamic-configurations/all", summary="读取所有动态配置", tags=["动态配置"], response_model=ApiResponse[List[ConfigurationOut]])
 def dynamic_config_get_all(db: Session = Depends(get_db)):
-    return ConfigurationService.get_all(db)
-
-
-@protected_router.get("/dynamic-configurations/key/{config_key}", summary="根据Key读取动态配置", tags=["动态配置"], response_model=ConfigurationOut)
+    obj = ConfigurationService.get_all(db)
+    return success(data=obj)
+    
+@protected_router.get("/dynamic-configurations/key/{config_key}", summary="根据Key读取动态配置", tags=["动态配置"], response_model=ApiResponse[ConfigurationOut])
 def dynamic_config_get_by_key(config_key: str, db: Session = Depends(get_db)):
     config = ConfigurationService.get_by_key(db, config_key)
-    if not config:
-        raise HTTPException(status_code=404, detail=f"配置 Key '{config_key}' 不存在")
-    return config
+    return success(data=config)
 
 
-@protected_router.put("/dynamic-configurations/key/{config_key}", summary="根据Key更新动态配置", tags=["动态配置"], response_model=ConfigurationOut)
+@protected_router.put("/dynamic-configurations/key/{config_key}", summary="根据Key更新动态配置", tags=["动态配置"], response_model=ApiResponse[ConfigurationOut])
 def dynamic_config_update_by_key(config_key: str, config_in: ConfigurationUpdate, db: Session = Depends(get_db)):
     config = ConfigurationService.update_by_key(db, config_key, config_in)
-    if not config:
-        raise HTTPException(status_code=404, detail=f"配置 Key '{config_key}' 不存在")
-    return config
+    return success(data=config)
 
 
-@protected_router.delete("/dynamic-configurations/key/{config_key}", summary="根据Key删除动态配置", tags=["动态配置"], response_model=ConfigurationOut)
+@protected_router.delete("/dynamic-configurations/key/{config_key}", summary="根据Key删除动态配置", tags=["动态配置"], response_model=ApiResponse[ConfigurationOut])
 def dynamic_config_delete_by_key(config_key: str, db: Session = Depends(get_db)):
     config = ConfigurationService.delete_by_key(db, config_key)
-    if not config:
-        raise HTTPException(status_code=404, detail=f"配置 Key '{config_key}' 不存在")
-    return config
-
-@protected_router.post(
-    "/http-session",
-    summary="创建http session",
-    tags=["会话管理"],
-    response_model=HttpSessionOut
-)
+    return success(data=config)
+
+@protected_router.post("/http-session", summary="创建http session", tags=["会话管理"],response_model=ApiResponse[HttpSessionOut])
 def http_session_create(
     data: HttpSessionCreate,
     db: Session = Depends(get_db)
 ):
     logger.info(f"[Create HttpSession] sid={data.session_id}")
-    return HttpSessionService.create(db, data)
+    obj = HttpSessionService.create(db, data)
+    return success(data=obj)
 
 
-@protected_router.delete(
-    "/http-session",
-    summary="删除http session",
-    tags=["会话管理"]
-)
+@protected_router.delete("/http-session", summary="删除http session", tags=["会话管理"], response_model=ApiResponse)
 def http_session_delete_by_sid(
     session_id: str = Query(...),
     db: Session = Depends(get_db)
 ):
     logger.info(f"[Delete HttpSession] sid={session_id}")
-
-    ok = HttpSessionService.delete_by_sid(db, session_id)
-    if not ok:
-        raise HTTPException(status_code=404, detail="session 不存在")
-
-    return {"success": True, "session_id": session_id}
+    HttpSessionService.delete_by_sid(db, session_id)
+    return success()
 
 
-@protected_router.put(
-    "/http-session",
-    summary="更新http session",
-    tags=["会话管理"],
-    response_model=HttpSessionOut
-)
+@protected_router.put("/http-session", summary="更新http session", tags=["会话管理"], response_model=ApiResponse[HttpSessionOut])
 def http_session_update_by_sid(
     session_id: str = Query(...),
     data: HttpSessionUpdate = Body(...),
     db: Session = Depends(get_db)
 ):
     logger.info(f"[Update HttpSession] sid={session_id}")
-
     obj = HttpSessionService.update_by_sid(db, session_id, data)
-    if not obj:
-        raise HTTPException(status_code=404, detail="session 不存在")
-
-    return obj
+    return success(data=obj)
 
-@protected_router.get(
-    "/http-session",
-    summary="读取http session",
-    tags=["会话管理"],
-    response_model=HttpSessionOut
-)
+@protected_router.get("/http-session", summary="读取http session", tags=["会话管理"],response_model=ApiResponse[HttpSessionOut])
 def http_session_get_by_sid(
     session_id: str = Query(...),
     db: Session = Depends(get_db)
 ):
     logger.info(f"[Get HttpSession] sid={session_id}")
-
     obj = HttpSessionService.get_by_sid(db, session_id)
-    if not obj:
-        raise HTTPException(status_code=404, detail="session 不存在")
-
-    return obj
+    return success(data=obj)
 
 
-@protected_router.get("/email-authorizations", summary="查询所有内部邮箱", tags=["邮箱接口"], response_model=List[EmailAuthorizationOut])
+@protected_router.get("/email-authorizations", summary="查询所有内部邮箱", tags=["邮箱接口"], response_model=ApiResponse[List[EmailAuthorizationOut]])
 def email_authorizations_get(db: Session = Depends(get_db)):
-    return EmailAuthorizationService.get_all(db)
+    obj = EmailAuthorizationService.get_all(db)
+    return success(data=obj)
 
 
-@protected_router.post("/email-authorizations", summary="创建内部邮箱", tags=["邮箱接口"], response_model=EmailAuthorizationOut)
+@protected_router.post("/email-authorizations", summary="创建内部邮箱", tags=["邮箱接口"], response_model=ApiResponse[EmailAuthorizationOut])
 def email_authorizations_create(data: EmailAuthorizationCreate, db: Session = Depends(get_db)):
-    existing = EmailAuthorizationService.get_by_email(db, data.email)
-    if existing:
-        raise HTTPException(status_code=400, detail=f"邮箱 {data.email} 已存在")
-    return EmailAuthorizationService.create(db, data)
+    obj = EmailAuthorizationService.create(db, data)
+    return success(data=obj)
 
-@protected_router.get("/email-authorizations/{id}", summary="通过id查询内部邮箱", tags=["邮箱接口"], response_model=EmailAuthorizationOut)
+@protected_router.get("/email-authorizations/{id}", summary="通过id查询内部邮箱", tags=["邮箱接口"], response_model=ApiResponse[EmailAuthorizationOut])
 def email_authorizations_get_by_id(id: int, db: Session = Depends(get_db)):
     email_auth = EmailAuthorizationService.get_by_id(db, id)
-    if not email_auth:
-        raise HTTPException(status_code=404, detail=f"ID={id} 的邮箱记录不存在")
-    return email_auth
+    return success(data=email_auth)
 
 
-@protected_router.put("/email-authorizations/{id}", summary="通过id更新内部邮箱", tags=["邮箱接口"], response_model=EmailAuthorizationOut)
+@protected_router.put("/email-authorizations/{id}", summary="通过id更新内部邮箱", tags=["邮箱接口"], response_model=ApiResponse[EmailAuthorizationOut])
 def email_authorizations_update_by_id(id: int, data: EmailAuthorizationUpdate, db: Session = Depends(get_db)):
     updated = EmailAuthorizationService.update(db, id, data)
-    if not updated:
-        raise HTTPException(status_code=404, detail=f"ID={id} 的邮箱记录不存在")
-    return updated
+    return success(data=updated)
 
 
-@protected_router.delete("/email-authorizations/{id}", summary="通过id删除内部邮箱", tags=["邮箱接口"], response_model=EmailAuthorizationOut)
+@protected_router.delete("/email-authorizations/{id}", summary="通过id删除内部邮箱", tags=["邮箱接口"], response_model=ApiResponse[EmailAuthorizationOut])
 def email_authorizations_delete_by_id(id: int, db: Session = Depends(get_db)):
     deleted = EmailAuthorizationService.delete(db, id)
-    if not deleted:
-        raise HTTPException(status_code=404, detail=f"ID={id} 的邮箱记录不存在")
     return deleted
 
 
-@protected_router.get("/email-authorizations/email/{email}", summary="通过邮箱地址查询内部邮箱", tags=["邮箱接口"], response_model=EmailAuthorizationOut)
+@protected_router.get("/email-authorizations/email/{email}", summary="通过邮箱地址查询内部邮箱", tags=["邮箱接口"], response_model=ApiResponse[EmailAuthorizationOut])
 def email_authorizations_get_by_email(email: str, db: Session = Depends(get_db)):
     email_auth = EmailAuthorizationService.get_by_email(db, email)
-    if not email_auth:
-        raise HTTPException(status_code=404, detail=f"邮箱 {email} 不存在")
     return email_auth
 
-@protected_router.post("/email-authorizations/fetch", summary="读取邮件, 仅限文本内容", tags=["邮箱接口"])
+@protected_router.post("/email-authorizations/fetch", summary="读取邮件, 仅限文本内容", tags=["邮箱接口"], response_model=ApiResponse[EmailContent])
 def email_authorizations_fetch_email(
     email: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     sender: str = Query(..., description="发件人邮箱账号或者名字"),
@@ -243,8 +224,6 @@ def email_authorizations_fetch_email(
     db: Session = Depends(get_db)
 ):
     auth = EmailAuthorizationService.get_by_email(db, email)
-    if not auth:
-        raise HTTPException(status_code=404, detail=f"未找到邮箱授权记录: {email}")
     result = EmailAuthorizationService.fetch_email_authorizations(
         auth,
         sender=sender,
@@ -255,11 +234,9 @@ def email_authorizations_fetch_email(
         expiry=expiry,
         only_text=True
     )
-    if result is None:
-        raise HTTPException(status_code=404, detail="在有效期内未找到匹配邮件")
-    return {"body": result}
+    return success(data={"body": result})
 
-@protected_router.post("/email-authorizations/fetch-top", summary="从最近的几封邮件读取目标邮件,仅限文本内容, 性能会更好, 邮件多时有可能漏读", tags=["邮箱接口"])
+@protected_router.post("/email-authorizations/fetch-top", summary="从最近的几封邮件读取目标邮件,仅限文本内容, 性能会更好, 邮件多时有可能漏读", tags=["邮箱接口"], response_model=ApiResponse[EmailContent])
 def email_authorizations_fetch_email_from_topn(
     email: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     sender: str = Query(..., description="发件人邮箱账号或者名字"),
@@ -270,8 +247,6 @@ def email_authorizations_fetch_email_from_topn(
     db: Session = Depends(get_db)
 ):
     auth = EmailAuthorizationService.get_by_email(db, email)
-    if not auth:
-        raise HTTPException(status_code=404, detail=f"未找到邮箱授权记录: {email}")
     result = EmailAuthorizationService.fetch_email_authorizations_from_top_n(
         auth,
         sender=sender,
@@ -281,11 +256,9 @@ def email_authorizations_fetch_email_from_topn(
         top=top,
         only_text=True
     )
-    if result is None:
-        raise HTTPException(status_code=404, detail=f"未在前{top}封邮件中查找到匹配邮件")
-    return {"body": result}
+    return success(data={"body": result})
 
-@protected_router.post("/email-authorizations/forward", summary="转发邮件", tags=["邮箱接口"])
+@protected_router.post("/email-authorizations/forward", summary="转发邮件", tags=["邮箱接口"], response_model=ApiResponse[EmailContent])
 def email_authorizations_forward_email(
     emailAccount: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     forwardTo: str = Query(..., description="转发到哪个邮箱地址, 格式: xxx@xxx.xxx"),
@@ -296,8 +269,6 @@ def email_authorizations_forward_email(
     db: Session = Depends(get_db)
 ):
     auth = EmailAuthorizationService.get_by_email(db, emailAccount)
-    if not auth:
-        raise HTTPException(status_code=404, detail=f"未找到邮箱授权记录: {emailAccount}")
     result = EmailAuthorizationService.forward_first_matching_email(
         auth,
         forward_to = forwardTo,
@@ -306,11 +277,9 @@ def email_authorizations_forward_email(
         subject_keywords = subjectKeywords,
         body_keywords = bodyKeywords
     )
-    if result is None:
-        raise HTTPException(status_code=404, detail=f"未找可转发的邮件")
-    return {"body": result}
+    return success(data={"body": result})
 
-@protected_router.post("/email-authorizations/sendmail", summary="发送邮件", tags=["邮箱接口"])
+@protected_router.post("/email-authorizations/sendmail", summary="发送邮件", tags=["邮箱接口"], response_model=ApiResponse[EmailContent])
 def email_authorizations_send_email(
     emailAccount: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     sendTo: str = Query(..., description="收件人邮箱账号"),
@@ -320,8 +289,6 @@ def email_authorizations_send_email(
     db: Session = Depends(get_db)
 ):
     auth = EmailAuthorizationService.get_by_email(db, emailAccount)
-    if not auth:
-        raise HTTPException(status_code=404, detail=f"未找到邮箱授权记录: {emailAccount}")
     result = EmailAuthorizationService.send_email(
         auth,
         send_to = sendTo,
@@ -329,11 +296,9 @@ def email_authorizations_send_email(
         content_type = contentType,
         content = content.body
     )
-    if result is None:
-        raise HTTPException(status_code=404, detail=f"邮件发送失败")
-    return {"body": result}
+    return success(data={"body": result})
 
-@protected_router.post("/email-authorizations/sendmail-bulk", summary="群发送邮件", tags=["邮箱接口"])
+@protected_router.post("/email-authorizations/sendmail-bulk", summary="群发送邮件", tags=["邮箱接口"], response_model=ApiResponse[EmailContent])
 def email_authorizations_send_email_bulk(
     emailAccount: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     sendTo: str = Query(..., description="收件人邮箱账号,多个用逗号隔开"),
@@ -343,8 +308,6 @@ def email_authorizations_send_email_bulk(
     db: Session = Depends(get_db)
 ):
     auth = EmailAuthorizationService.get_by_email(db, emailAccount)
-    if not auth:
-        raise HTTPException(status_code=404, detail=f"未找到邮箱授权记录: {emailAccount}")
     result = EmailAuthorizationService.send_email_bulk(
         auth,
         send_to = sendTo,
@@ -352,10 +315,7 @@ def email_authorizations_send_email_bulk(
         content_type = contentType,
         content = content.body
     )
-    if result is None:
-        raise HTTPException(status_code=404, detail=f"邮件发送失败")
-    return {"body": result}
-
+    return success(data={"body": result})
 
 @protected_router.post("/resource/pdf", summary="上传pdf文件", tags=["文件管理"])
 def resource_upload_pdf(pdf: UploadFile = File(...)):
@@ -407,43 +367,33 @@ def resource_delete_image(fid: str):
         raise HTTPException(status_code=404, detail="图片不存在或删除失败")
     return {"success": True, "fid": fid}
 
-@protected_router.post("/s/generate", summary="生成短链接地址<压缩地址长度>", tags=["Short URL"])
+@protected_router.post("/s/generate", summary="生成短链接地址<压缩地址长度>", tags=["Short URL"], response_model=ApiResponse[ShortUrlOut])
 def short_url_generate(
     data: ShortUrlCreate,
     db: Session = Depends(get_db),
 ):
     """生成短链接"""
-    record = ShortUrlService.create_short_url(db, data.longUrl)
-    return {
-        "short_key": record.short_key,
-        "short_url": f"/s/{record.short_key}",
-        "long_url": record.long_url,
-        "created_at": record.created_at,
-    }
-
+    record = ShortUrlService.create_short_url(db, data.long_url)
+    return success(data=record)
 
-@public_router.get("/s/{shortKey}", summary="访问短链接地址<自动重定向到真实链接地址>", tags=["Short URL"])
-def short_url_request(shortKey: str, db: Session = Depends(get_db)):
+@public_router.get("/s/{short_key}", summary="访问短链接地址<自动重定向到真实链接地址>", tags=["Short URL"])
+def short_url_request(short_key: str, db: Session = Depends(get_db)):
     """访问短链接自动重定向"""
-    long_url = ShortUrlService.get_long_url(db, shortKey)
-    if not long_url:
-        raise HTTPException(status_code=404, detail="短链接不存在或已失效")
+    long_url = ShortUrlService.get_long_url(db, short_key)
     return RedirectResponse(url=long_url, status_code=302)
 
-@protected_router.post("/tasks", summary="创建任务", tags=["任务管理接口"], response_model=TaskOut)
+@protected_router.post("/tasks", summary="创建任务", tags=["任务管理接口"], response_model=ApiResponse[TaskOut])
 def task_create(data: TaskCreate, db: Session = Depends(get_db)):
     """创建任务"""
     return TaskService.create(db, data)
 
-@protected_router.get("/tasks/{taskId:int}", summary="根据taskId读取任务状态", tags=["任务管理接口"], response_model=TaskOut)
-def task_get_by_id(taskId: int, db: Session = Depends(get_db)):
+@protected_router.get("/tasks/{task_id:int}", summary="根据taskId读取任务状态", tags=["任务管理接口"], response_model=ApiResponse[TaskOut])
+def task_get_by_id(task_id: int, db: Session = Depends(get_db)):
     """获取任务"""
-    task = TaskService.get_by_id(db, taskId)
-    if not task:
-        raise HTTPException(status_code=404, detail=f"任务 {taskId} 不存在")
-    return task
+    task = TaskService.get_by_id(db, task_id)
+    return success(data=task)
 
-@protected_router.get("/tasks/pending", summary="获取等待执行的任务", tags=["任务管理接口"], response_model=List[TaskOut])
+@protected_router.get("/tasks/pending", summary="获取等待执行的任务", tags=["任务管理接口"], response_model=ApiResponse[List[TaskOut]])
 def task_get_pending(
     page: int = Query(0, description="第几页"),
     size: int = Query(10, description="分页大小"),
@@ -451,106 +401,48 @@ def task_get_pending(
     db: Session = Depends(get_db),
 ):
     """分页获取等待执行的任务"""
-    return TaskService.get_pending(db, command, page, size)
+    obj = TaskService.get_pending(db, command, page, size)
+    return success(data=obj)
 
-@protected_router.put("/tasks/{taskId}", summary="根据taskId更新任务状态", tags=["任务管理接口"], response_model=TaskOut)
-def task_update_by_id(taskId: int, data: TaskUpdate, db: Session = Depends(get_db)):
+@protected_router.put("/tasks/{task_id}", summary="根据taskId更新任务状态", tags=["任务管理接口"], response_model=ApiResponse[TaskOut])
+def task_update_by_id(task_id: int, data: TaskUpdate, db: Session = Depends(get_db)):
     """更新任务状态或结果"""
-    updated = TaskService.update(db, taskId, data)
-    if not updated:
-        raise HTTPException(status_code=404, detail=f"任务 {taskId} 不存在")
-    return updated
+    updated = TaskService.update(db, task_id, data)
+    return success(data=updated)
 
-@protected_router.post("/tg/send_message", summary="推送电报消息", tags=["消息推送接口"])
+@protected_router.post("/tg/send_message", summary="推送电报消息", tags=["消息推送接口"], response_model=ApiResponse)
 def tg_send_message(
-    apiToken: str = Query(..., description="电报的APITOKEN"),
-    chatID: str = Query(..., description="电报群ID"),
-    message: str = Query(..., description="推送的文本信息")
-):
-    url = f"https://api.telegram.org/bot{apiToken}/sendMessage"
-    payload = {
-        "chat_id": chatID,
-        "text": message,
-        "parse_mode": "HTML"
-    }
+    payload: TelegramIn
+):
+    TelegramService.push_to_telegram(payload)
+    return success()
 
-    try:
-        response = requests.post(url, json=payload, timeout=10)
-        if response.status_code != 200:
-            # logger.error(f"Telegram 推送失败: {response.text}")
-            raise HTTPException(status_code=500, detail=f"Telegram 推送失败: {response.text}")
-        return {"success": True, "detail": "Telegram 消息推送成功"}
-    except Exception as e:
-        # logger.exception("Telegram 发送消息异常")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@protected_router.post("/tg/send_image", summary="推送电报图片", tags=["消息推送接口"])
-def tg_send_image(
-    apiToken: str = Query(..., description="电报的APITOKEN"),
-    chatID: str = Query(..., description="电报群ID"),
-    message: str = Query("", description="推送的文本信息"),
-    image: UploadFile = File(..., description="推送的图像文件")
-):
-    url = f"https://api.telegram.org/bot{apiToken}/sendPhoto"
-
-    files = {"photo": (image.filename, image.file, image.content_type)}
-    data = {"chat_id": chatID, "caption": message}
-
-    try:
-        response = requests.post(url, data=data, files=files, timeout=15)
-        if response.status_code != 200:
-            # logger.error(f"Telegram 图片推送失败: {response.text}")
-            raise HTTPException(status_code=500, detail=f"Telegram 图片推送失败: {response.text}")
-        return {"success": True, "detail": "Telegram 图片推送成功"}
-    except Exception as e:
-        # logger.exception("Telegram 发送图片异常")
-        raise HTTPException(status_code=500, detail=str(e))
-
-
-@protected_router.post("/wechat/send", summary="推送微信消息", tags=["消息推送接口"])
+@protected_router.post("/wechat/send", summary="推送微信消息", tags=["消息推送接口"], response_model=ApiResponse)
 def wechat_send(
-    apikey: str = Query(..., description="企业微信的APITOKEN"),
-    message: str = Query(..., description="推送的文本信息")
+    payload: WechatIn
 ):
-    """
-    企业微信 WebHook 格式:
-    https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY
-    """
-    url = f"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={apikey}"
-    payload = {"msgtype": "text", "text": {"content": message}}
-
-    try:
-        response = requests.post(url, json=payload, timeout=10)
-        data = response.json()
-
-        if response.status_code != 200 or data.get("errcode") != 0:
-            # logger.error(f"企业微信推送失败: {response.text}")
-            raise HTTPException(status_code=500, detail=f"企业微信推送失败: {response.text}")
+    WechatService.push_to_wechat(payload)
+    return success()
 
-        return {"success": True, "detail": "企业微信消息推送成功"}
-    except Exception as e:
-        # logger.exception("企业微信推送异常")
-        raise HTTPException(status_code=500, detail=str(e))
-
-@protected_router.post("/cards/publish", summary="创建新的消息卡片", tags=["信息卡片接口"], response_model=CardOut)
+@protected_router.post("/cards/publish", summary="创建新的消息卡片", tags=["信息卡片接口"], response_model=ApiResponse[CardOut])
 def cards_publish(
     data: CardCreate = Body(...),
     db: Session = Depends(get_db)
 ):
-    return CardService.create(db, data)
-
+    obj = CardService.create(db, data)
+    return success(data=obj)
 
-@public_router.get("/cards/view", summary="分页读取全部卡片, 可选择语言", tags=["信息卡片接口"], response_model=List[CardOut])
+@public_router.get("/cards/view", summary="分页读取全部卡片, 可选择语言", tags=["信息卡片接口"], response_model=ApiResponse[List[CardOut]])
 def cards_view_paginated(
     page: int = Query(0, description="第几页"),
     size: int = Query(10, description="分页大小"),
     culture: str = Query("english", description="语言, 可设置 chinese, english"),
     db: Session = Depends(get_db)
 ):
-    return CardService.get_paginated(db, page, size, culture)
+    obj = CardService.get_paginated(db, page, size, culture)
+    return success(data=obj)
 
-
-@public_router.get("/cards/view2", summary="根据关键词分页查询卡片, 可选择语言", tags=["信息卡片接口"], response_model=List[CardOut])
+@public_router.get("/cards/view2", summary="根据关键词分页查询卡片, 可选择语言", tags=["信息卡片接口"], response_model=ApiResponse[List[CardOut]])
 def cards_view_paginated2(
     keywords: str = Query("", description="查询的关键词,多个关键词用逗号隔开"),
     page: int = Query(0, description="第几页"),
@@ -559,24 +451,30 @@ def cards_view_paginated2(
     db: Session = Depends(get_db)
 ):
     keyword_list = [k.strip() for k in keywords.split(",") if k.strip()]
-    return CardService.get_by_keywords(db, keyword_list, page, size, culture)
+    obj = CardService.get_by_keywords(db, keyword_list, page, size, culture)
+    return success(data=obj)
+
+@protected_router.get("/fake/users", summary="生成虚假的预约人信息", tags=["数据生成"], response_model=ApiResponse[List[FakeUser]])
+def fake_generate_fake_users(
+    num: int = Query(1, description="生成几个数据"),
+    living_country = Query("Ireland", description="居住在哪个国家, China, India, United Kingdom, Ireland"),
+):
+    obj = generate_fake_users(num, living_country=living_country)
+    return success(data=obj)
 
 @protected_router.post("/autobooking", summary="创建自动预定订单", tags=["自动预定订单管理接口"], response_model=AutoBookingOut)
 def autobooking_create(data: AutoBookingCreate, db: Session = Depends(get_db)):
     return AutoBookingService.create(db, data)
 
-
 @protected_router.post("/autobooking/create-by-ai", summary="用自然语言创建自动预定订单(底层使用chatgpt)", tags=["自动预定订单管理接口"])
 def autobooking_create_by_ai():
     # TODO: 这里可以对接 GPT 解析自然语言生成结构化 AutoBooking 数据
     return {"message": "AI 自动创建暂未实现"}
 
-
 @protected_router.post("/autobooking/batch", summary="批量查询多个自动预定订单信息", tags=["自动预定订单管理接口"])
 def autobooking_get_by_ids(ids: List[int] = Body(...), db: Session = Depends(get_db)):
     return AutoBookingService.batch_get_by_ids(db, ids)
 
-
 @protected_router.get("/autobooking", summary="分页查询所有的自动预定订单信息", tags=["自动预定订单管理接口"], response_model=List[AutoBookingOut])
 def autobooking_get_paginated(
     tech_provider: str = Query("", description="签证网站技术提供商"),
@@ -587,7 +485,6 @@ def autobooking_get_paginated(
 ):
     return AutoBookingService.get_paginated(db, tech_provider, keyword, page, size)
 
-
 @protected_router.get("/autobooking/{id:int}", summary="根据id查询自动预定订单详情", tags=["自动预定订单管理接口"], response_model=AutoBookingOut)
 def autobooking_get_by_id(id: int, db: Session = Depends(get_db)):
     result = AutoBookingService.get_by_id(db, id)
@@ -595,7 +492,6 @@ def autobooking_get_by_id(id: int, db: Session = Depends(get_db)):
         raise HTTPException(status_code=404, detail="未找到订单")
     return result
 
-
 @protected_router.delete("/autobooking/{id:int}", summary="根据id删除自动预定订单", tags=["自动预定订单管理接口"])
 def autobooking_delete_by_id(id: int, db: Session = Depends(get_db)):
     ok = AutoBookingService.delete_by_id(db, id)
@@ -603,7 +499,6 @@ def autobooking_delete_by_id(id: int, db: Session = Depends(get_db)):
         raise HTTPException(status_code=404, detail="删除失败或记录不存在")
     return {"success": True, "id": id}
 
-
 @protected_router.put("/autobooking/{id:int}", summary="根据id更新自动预定订单信息", tags=["自动预定订单管理接口"], response_model=AutoBookingOut)
 def autobooking_update_by_id(id: int, updated_order_info: dict = Body(...), db: Session = Depends(get_db)):
     result = AutoBookingService.update_by_id(db, id, updated_order_info)
@@ -611,7 +506,6 @@ def autobooking_update_by_id(id: int, updated_order_info: dict = Body(...), db:
         raise HTTPException(status_code=404, detail="更新失败或记录不存在")
     return result
 
-
 @protected_router.get("/autobooking/statistics", summary="统计自动预定订单信息", tags=["自动预定订单管理接口"])
 def autobooking_statistics(
     tech_provider: str = Query("", description="签证网站技术提供商"),
@@ -619,7 +513,6 @@ def autobooking_statistics(
 ):
     return AutoBookingService.statistics(db, tech_provider)
 
-
 @protected_router.get("/autobooking/pending", summary="获取未处理的自动预定订单信息列表", tags=["自动预定订单管理接口"], response_model=List[AutoBookingOut])
 def autobooking_pending(
     tech_provider: str = Query("", description="签证网站技术提供商"),
@@ -627,88 +520,180 @@ def autobooking_pending(
 ):
     return AutoBookingService.get_pending(db, tech_provider)
 
-@protected_router.get("/autobooking/trigger-finish", summary="触发自动预定订单完成操作", tags=["自动预定订单管理接口"])
-def autobooking_trigger_finish(id: int):
-    pass
-
-@protected_router.post("/stripe-price/create", summary="创建stripe 商品", tags=["Stripe 操作接口"])
-def stripe_price_create(
-    stripePrice: str = Body(..., description="stripe 商品价格信息")
-):
-    pass
-
-@protected_router.post(
-    "/slot/report",
-    summary="上报 slot 记录",
-    tags=["SLOT接口"],
-    response_model=SlotOut
-)
-def slot_report(data: SlotCreate = Body(...), db: Session = Depends(get_db)):
-    logger.info(f"[Slot Report] {data}")
-    return SlotService.report(db, data)
-
-
-@protected_router.get(
-    "/slot/search",
-    summary="查询 slot 记录",
-    tags=["SLOT接口"],
-    response_model=SlotOut
-)
-def slot_search(
-    submit_city: str = Query(..., description="提交城市"),
-    travel_country: str = Query(..., description="旅行国家"),
-    visa_type: str = Query(..., description="签证类别"),
-    date_type: str = Query("latest", description="查询方式, latest 最近一条上报的信息, earliest 最早的日期"),
+@public_router.post("/webhook/smshelper", summary="双开助手Webhook", tags=["webhook"], response_model=ApiResponse)
+def webhook_smshelper(
+    payload: SMSHelperWebhookPayload,
+    db: Session = Depends(get_db),
+    redis_client: Redis = Depends(get_redis_client)
+):
+    if "微信支付" in payload.title:
+        res = WebhookService.smshelper_payment_webhook(db, payload)
+        if res.get('status', 'ok') == 'ok':
+            print(f"📧 send payment succeeded notification email")
+    return success()
+
+@public_router.post("/webhook/stripe", include_in_schema=False, summary="Stripe Webhook", tags=["webhook"], response_model=ApiResponse)
+def webhook_stripe(
+    request: Request,
+    db: Session = Depends(get_db),
+    redis_client: Redis = Depends(get_redis_client)
+):
+    payload = request.body()
+    sig_header = request.headers.get("stripe-signature")
+    event = stripe.Webhook.construct_event(
+        payload=payload,
+        sig_header=sig_header,
+        secret=settings.STRIPE_WEBHOOK_SECRET,
+    )
+    res = WebhookService.stripe_payment_webhook(db, event)
+    if res.get('status', 'ok') == 'ok':
+        print(f"📧 send payment succeeded notification email")
+    return success()
+
+@public_router.post("/vas/auth/auto-register", summary="自动注册", tags=["Visafly签证系统"], response_model=ApiResponse[AutoRegisterData])
+def vas_auto_register(
+    payload: AutoRegisterRequest,
     db: Session = Depends(get_db)
 ):
-    result = SlotService.search(db, submit_city, travel_country, visa_type, date_type)
-    if not result:
-        raise HTTPException(status_code=404, detail="未找到相关记录")
-    return result
+    res = AuthService.auto_register(db, payload)
+    return success(data=res)
+
+@public_router.post("/vas/auth/bind-email", summary="绑定邮箱", tags=["Visafly签证系统"], response_model=ApiResponse)
+def vas_bind_email(
+    payload: BindEmailRequest,
+    current_user: VasUser = Depends(get_current_user),
+    db: Session = Depends(get_db),
+    redis_client: Redis = Depends(get_redis_client)
+):
+    AuthService.bind_email(db, payload, current_user, redis_client)
+    return success(message="verify email sent, please check your inbox")
 
+@public_router.get("/vas/auth/verify-email", summary="验证邮箱", tags=["Visafly签证系统"], response_model=ApiResponse)
+def vas_verify_email(
+    token: str,
+    db: Session = Depends(get_db),
+    redis_client: Redis = Depends(get_redis_client)
+):
+    AuthService.verify_email(db, token, redis_client)
+    return success(message="success")
 
-@protected_router.post(
-    "/visafly-config",
-    summary="创建一条可以被前端查询到的签证类别",
-    tags=["visafly-config接口"],
-    response_model=VisaflyConfigOut
-)
-def visafly_config_create(
-    visafly_config: VisaflyConfigCreate = Body(...),
+@public_router.post("/vas/auth/login", summary="邮箱登录", tags=["Visafly签证系统"], response_model=ApiResponse[LoginData])
+def vas_login(
+    payload: LoginRequest,
     db: Session = Depends(get_db)
 ):
-    logger.info(f"[VisaflyConfig Create] {visa_slot_queries}")
-    return VisaflyConfigService.create(db, visa_slot_queries)
+    res = AuthService.login(db, payload)
+    return success(data=res)
 
+@public_router.post("/vas/product/create", summary="创建商品", tags=["Visafly签证系统"], response_model=ApiResponse[VasProductOut])
+def vas_product_create(
+    payload: VasProductCreate,
+    db: Session = Depends(get_db)
+):
+    created_product = ProductService.create(db, payload)
+    return success(data=created_product)
 
-@protected_router.get(
-    "/visafly-config/submission-countries",
-    summary="查询支持从哪些国家递交申请",
-    tags=["visafly-config接口"]
-)
-def visafly_config_get_submission_countries(db: Session = Depends(get_db)):
-    return VisaflyConfigService.get_submission_countries(db)
+@public_router.post("/vas/order/create", summary="创建订单", tags=["Visafly签证系统"], response_model=ApiResponse[VasOrderOut])
+def vas_order_create(
+    payload: VasOrderCreate,
+    current_user: VasUser = Depends(get_current_user),
+    db: Session = Depends(get_db),
+    redis_client: Redis = Depends(get_redis_client)
+):
+    product = ProductService.get(db, payload.product_id)
+    # ① 获取产品绑定的 schema
+    schema = SchemaService.get(db, product.schema_id)
+    # ② 校验 user_inputs
+    validate_user_inputs(
+        schema_json=schema.schema_json,
+        user_inputs=json.loads(payload.user_inputs),
+    )
+    created_order = OrderService.create(db, payload, product, current_user)
+    if current_user.role == "admin":
+        OrderService.mark_as_admin_paid(db, created_order, current_user)
+        OrderService.create_tasks_for_order(db, order)
+    print(f"📧 send order created notification email")
+    NotificationService.create(
+        redis=redis_client,
+        ntype="order create notify",
+        user_id=current_user.id,
+        channels=["email"],
+        template_id="order_create_notify",
+        payload={
+            "sendTo": current_user.email,
+            "orderId": created_order.id
+        }
+    )
+    return success(data=created_order)
 
+@public_router.get("/vas/payment_provider", summary="获取支付方式", tags=["Visafly签证系统"], response_model=ApiResponse[List[VasPaymentProviderSimpleOut]])
+def vas_payment_provider_simple_get(db: Session = Depends(get_db)):
+    providers = PaymentProviderSerivce.list_enabled(db)
+    return success(data=providers)
 
-@protected_router.get(
-    "/visafly-config/cities",
-    summary="查询支持从哪个国家的哪些城市递交申请",
-    tags=["visafly-config接口"]
-)
-def visafly_config_get_cities_by_country_code(
-    country_code: str = Query(..., description="递交申请的国家编号,大写的两个英文字符"),
+@public_router.post("/vas/payment/create", summary="创建支付", tags=["Visafly签证系统"], response_model=ApiResponse[VasPaymentOut])
+def vas_payment_create(
+    payload: VasPaymentCreate,
     db: Session = Depends(get_db)
 ):
-    return VisaflyConfigService.get_cities_by_country(db, country_code)
-
+    rate_table = {
+        "EUR->EUR": "1",
+        "EUR->CNY": "8.3174",
+        "EUR->USD": "1.0842",
+    }
+    res = PaymentService.create_payment(db, payload)
+    return success(data=res)
+
+    
+@public_router.get("/vas/payment_qr/qrcode", summary="获取支付的QRCode", tags=["Visafly签证系统"], response_model=ApiResponse[VasPaymentQrSimpleOut])
+def vas_payment_qr_get_qrcode_by_id(id: int, db: Session = Depends(get_db)):
+    qr = PaymentQrService.get_by_id(db, id)
+    return success(data=qr)
+
+@public_router.get("/vas/task/pending", summary="获取待执行的任务", tags=["Visafly签证系统"], response_model=ApiResponse[List[VasTaskOut]])
+def vas_task_pending(
+    routing_key: str = Query(..., description="task 自定义索引"),
+    script_version: str = Query("", description="脚本版本, 用来向后兼容"),
+    db: Session = Depends(get_db)
+):
+    tasks = VasTaskService.get_pending(db, routing_key, order_id, script_version)
+    return success(data=tasks)
 
-@protected_router.get(
-    "/visafly-config/travel-countries-with-categories",
-    summary="查询某个城市可以办理哪些国家的签证(包含签证类别)",
-    tags=["visafly-config接口"]
-)
-def visafly_config_get_travel_countries_by_city_code(
-    city_code: str = Query(..., description="递交申请的城市编号,大写的三个英文字符"),
+@public_router.get("/vas/task/get_by_order", summary="根据订单查找任务", tags=["Visafly签证系统"], response_model=ApiResponse[List[VasTaskOut]])
+def vas_task_pending(
+    order_id: str = Query(..., description="订单编号"),
+    script_version: str = Query("", description="脚本版本, 用来向后兼容"),
     db: Session = Depends(get_db)
 ):
-    return VisaflyConfigService.get_travel_countries_by_city(db, city_code)
+    tasks = VasTaskService.get_active_task_by_order_id(db, order_id)
+    return success(data=tasks)
+
+@public_router.post("/vas/task/return_to_queue", summary="重新放回队列", tags=["Visafly签证系统"])
+def vas_task_return_to_queue(task_id:int, db: Session = Depends(get_db)):
+    VasTaskService.return_to_queue(db, task_id)
+    return success()
+
+@public_router.post("/vas/task/manual_confirm", summary="设置任务完成", tags=["Visafly签证系统"])
+def vas_task_manual_confirm(task_id:int, db: Session = Depends(get_db)):
+    VasTaskService.manual_confirm(db, task_id)
+    return success()
+
+@public_router.post("/vas/ticket/create", summary="创建工单", tags=["Visafly签证系统"])
+def vas_ticket_create(data:VasTicketCreate, db: Session = Depends(get_db)):
+    TicketService.create(db, data)
+
+@public_router.post("/vas/ticket/refund/approve", summary="批准退款", tags=["Visafly签证系统"])
+def vas_ticket_refund_approve(id:int, admin_comment:str, db: Session = Depends(get_db)):
+    TicketService.set_refund_approve(db, id, admin_comment)
+
+@public_router.post("/vas/ticket/refund/need-info", summary="管理员批准退款,但是需要补充资料", tags=["Visafly签证系统"])
+def vas_ticket_refund_need_info(id:int, admin_comment:str, db: Session = Depends(get_db)):
+    TicketService.set_refund_need_info(db, id, admin_comment)
+
+@public_router.post("/vas/ticket/refund/submit-info", summary="用户提交退款补充信息", tags=["Visafly签证系统"])
+def vas_ticket_refund_submit_info(ticket_id:int, extra:dict, db: Session = Depends(get_db)):
+    TicketService.set_refund_need_info(db, ticket_id, extra)
+
+@public_router.post("/vas/ticket/refund/reject", summary="管理员:拒绝退款", tags=["Visafly签证系统"])
+def vas_ticket_refund_reject(id:int, admin_comment:str, db: Session = Depends(get_db)):
+    TicketService.reject_refund(db, id, admin_comment)

+ 25 - 6
app/core/auth.py

@@ -1,18 +1,37 @@
 from fastapi import Depends, HTTPException, status
 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
 from app.core.config import settings
+from sqlalchemy.orm import Session
+from app.core.database import get_db
+from app.services.session_service import SessionService
 
-security = HTTPBearer()
 
-API_TOKEN = settings.api_token
+security = HTTPBearer()
 
-def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
+def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)):
     """
     全局鉴权依赖
     """
-    if credentials.credentials != API_TOKEN:
+    user = session_service.get_user_by_token(db, credentials.credentials)
+    if not user:
+        raise HTTPException(
+            status_code=401,
+            detail="Invalid or expired session token",
+        )
+    return user
+
+
+def get_current_user(
+    credentials: HTTPAuthorizationCredentials = Depends(security),
+    db: Session = Depends(get_db)
+):
+    token = credentials.credentials
+    user = SessionService().get_user_by_token(db, token)
+
+    if not user:
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Invalid or missing token",
-            headers={"WWW-Authenticate": "Bearer"},
+            detail="Invalid or expired token"
         )
+
+    return user

+ 27 - 0
app/core/biz_exception.py

@@ -0,0 +1,27 @@
+from typing import Optional, Dict, Any
+
+class BizException(Exception):
+    def __init__(
+        self,
+        code: int,
+        message: str,
+        http_status: int = 400,
+        extra: Optional[Dict[str, Any]] = None,
+    ):
+        self.code = code
+        self.message = message
+        self.http_status = http_status
+        self.extra = extra
+
+
+class NotFoundError(BizException):
+    def __init__(self, message="Resource not found"):
+        super().__init__(code=40401, message=message, http_status=404)
+
+class PermissionDeniedError(BizException):
+    def __init__(self, message="Permission denied"):
+        super().__init__(code=40301, message=message, http_status=403)
+
+class BizLogicError(BizException):
+    def __init__(self, message="Business logic error"):
+        super().__init__(code=40001, message=message)

+ 6 - 1
app/core/config.py

@@ -2,12 +2,17 @@ from pydantic_settings import BaseSettings
 
 class Settings(BaseSettings):
     app_name: str = "MyApp"
-    debug: bool = True
+    debug: bool = False
     database_url: str
     redis_url: str
     api_token: str
+    openai_api_key: str
+    stripe_api_key: str
     
     class Config:
         env_file = ".env"
 
 settings = Settings()
+
+base_currency = "EUR"
+

+ 6 - 0
app/core/payment.py

@@ -0,0 +1,6 @@
+# app/core/payment/stripe.py
+import stripe
+from app.core.config import settings
+
+def init_stripe():
+    stripe.api_key = settings.stripe_api_key

+ 35 - 1
app/main.py

@@ -1,4 +1,5 @@
-from fastapi import FastAPI, Depends
+from fastapi import FastAPI, Depends, Request
+from fastapi.responses import JSONResponse
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.openapi.utils import get_openapi
 from fastapi.security import HTTPBearer
@@ -6,9 +7,42 @@ from fastapi.security import HTTPBearer
 from app.api import router
 from app.core.auth import verify_token
 from app.core.config import settings
+from app.core.payment import init_stripe
+from app.core.biz_exception import BizException
+from app.core.logger import logger
+
 
 app = FastAPI(title=settings.app_name)
 
+@app.on_event("startup")
+def startup():
+    init_stripe()
+    
+@app.exception_handler(BizException)
+async def biz_exception_handler(request: Request, exc: BizException):
+    return JSONResponse(
+        status_code=exc.http_status,
+        content={
+            "code": exc.code,
+            "message": exc.message,
+            "data": exc.extra,
+        },
+    )
+
+@app.exception_handler(Exception)
+async def unhandled_exception_handler(request: Request, exc: Exception):
+    # ⚠️ 一定要打日志
+    logger.error("Unhandled exception")
+
+    return JSONResponse(
+        status_code=500,
+        content={
+            "code": 50000,
+            "message": "Internal Server Error",
+            "data": None,
+        },
+    )
+
 # -----------------------
 # CORS(可选)
 # -----------------------

+ 16 - 0
app/models/email_verification.py

@@ -0,0 +1,16 @@
+from sqlalchemy import Column, String, DateTime, Integer
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasEmailVerification(Base):
+    __tablename__ = "vas_email_verification"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    user_id = Column(String(64), nullable=False)
+    email = Column(String(255), nullable=False)
+    token = Column(String(128), nullable=False)
+    used = Column(Integer, default=0)
+    expire_at = Column(DateTime, nullable=False)
+
+    created_at = Column(DateTime, default=datetime.utcnow)

+ 25 - 0
app/models/order.py

@@ -0,0 +1,25 @@
+from sqlalchemy import Column, String, Integer, DateTime, JSON, Enum
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasOrder(Base):
+    __tablename__ = "vas_order"
+
+    id = Column(String(128), primary_key=True)
+    user_id = Column(String(128), nullable=False)
+    product_id = Column(Integer, nullable=False)
+
+    base_amount = Column(Integer, nullable=False)
+    currency = Column(String(10), nullable=False)
+
+    status = Column(
+        Enum('pending','paid','completed','closed'),
+        default='pending',
+        nullable=False
+    )
+
+    user_inputs = Column(JSON)
+
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 38 - 0
app/models/payment.py

@@ -0,0 +1,38 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, JSON, DECIMAL
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasPayment(Base):
+    __tablename__ = "vas_payment"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    order_id = Column(String(128), nullable=False)
+
+    provider = Column(Enum('stripe','wechat','alipay'), nullable=False)
+    channel = Column(String(50), nullable=False)
+    payment_intent_id = Column(String(255))
+    external_trade_no = Column(String(255))
+
+    status = Column(
+        Enum('pending','succeeded','failed','expired', 'late_paid'),
+        default='pending'
+    )
+    
+    base_amount = Column(Integer, nullable=False)
+    base_currency = Column(String(10), nullable=False)
+
+    amount = Column(Integer, nullable=False)
+    currency = Column(String(10), nullable=False)
+    random_offset = Column(Integer, nullable=False)
+    
+    exchange_rate = Column(DECIMAL(18, 8), nullable=False)
+
+    qr_id = Column(Integer)
+    payment_url = Column(Text)
+    expire_at = Column(DateTime)
+
+    provider_payload = Column(JSON)
+
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 41 - 0
app/models/payment_event.py

@@ -0,0 +1,41 @@
+# app/models/payment_event.py
+from sqlalchemy import (
+    Column, Integer, String, Text, DateTime, JSON, BigInteger
+)
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasPaymentEvent(Base):
+    __tablename__ = "vas_payment_event"
+
+    id = Column(BigInteger, primary_key=True, autoincrement=True)
+
+    # ---- 来源 ----
+    provider = Column(String(32), nullable=False)      # wechat | alipay | stripe
+    event_type = Column(String(64))                    # payment_received | checkout.session.completed
+    event_id = Column(String(128), unique=True)        # Stripe event.id / 自生成 hash
+
+    # ---- 原始内容 ----
+    title = Column(Text)
+    content = Column(Text)
+    raw_payload = Column(JSON)
+
+    # ---- 解析结果 ----
+    parsed_amount = Column(BigInteger)                 # 最小货币单位
+    parsed_currency = Column(String(8))
+    parsed_device = Column(String(64))
+
+    # ---- 业务关联 ----
+    matched_payment_id = Column(BigInteger)
+    matched_order_id = Column(String(128))
+
+    # ---- 状态 ----
+    status = Column(
+        String(32),                                    # received | matched | applied | duplicate | failed
+        nullable=False,
+        default="received"
+    )
+    error_message = Column(Text)
+
+    created_at = Column(DateTime, default=datetime.utcnow)

+ 17 - 0
app/models/payment_provider.py

@@ -0,0 +1,17 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, JSON
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasPaymentProvider(Base):
+    __tablename__ = "vas_payment_provider"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    name = Column(String(64), nullable=False)
+    channel = Column(String(64), nullable=False)
+    currency = Column(String(3), nullable=False)
+    icon = Column(Text)
+    enabled = Column(Integer, default=1)
+    config = Column(JSON)
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 14 - 0
app/models/payment_qr.py

@@ -0,0 +1,14 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, Enum
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasPaymentQR(Base):
+    __tablename__ = "vas_payment_qr"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    provider = Column(Enum('wechat', 'alipay'), nullable=False)
+    qr_code = Column(Text, nullable=False)
+    description = Column(Text, nullable=False)
+    is_active = Column(Integer, default=1)
+    created_at = Column(DateTime, default=datetime.utcnow)

+ 26 - 0
app/models/product.py

@@ -0,0 +1,26 @@
+from sqlalchemy import Column, String, Integer, Text, JSON, DateTime
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasProduct(Base):
+    __tablename__ = "vas_product"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    country = Column(String(64), nullable=False)
+    city = Column(String(64), nullable=False)
+    visa_type = Column(String(64))
+
+    provider = Column(String(32), nullable=False)
+    title = Column(String(255), nullable=False)
+    description = Column(Text)
+    extra_fields = Column(JSON)
+
+    price_amount = Column(Integer, nullable=False)
+    price_currency = Column(String(8), nullable=False)
+
+    schema_id = Column(Integer)
+    enabled = Column(Integer, default=1)
+
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 19 - 0
app/models/product_routing.py

@@ -0,0 +1,19 @@
+from sqlalchemy import Column, Integer, String, DateTime, JSON
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasProductRouting(Base):
+    __tablename__ = "vas_product_routing"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    product_id = Column(Integer, nullable=False)
+
+    routing_key = Column(String(255), nullable=False)
+    script_version = Column(String(50), nullable=False)
+    is_active = Column(Integer, default=1)
+
+    config = Column(JSON)
+
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 15 - 0
app/models/schema.py

@@ -0,0 +1,15 @@
+from sqlalchemy import Column, String, DateTime, Text, JSON, Integer
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasSchema(Base):
+    __tablename__ = "vas_schema"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    name = Column(String(255), nullable=False)
+    description = Column(Text)
+    schema_json = Column(JSON, nullable=False)
+
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 16 - 0
app/models/session.py

@@ -0,0 +1,16 @@
+from sqlalchemy import Column, String, DateTime
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasSession(Base):
+    __tablename__ = "vas_session"
+
+    id = Column(String(128), primary_key=True)   # token
+    user_id = Column(String(128), nullable=False)
+    user_agent = Column(String(128), nullable=False)
+    ip = Column(String(128), nullable=False)
+    expire_at = Column(DateTime, nullable=False)
+
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 0 - 20
app/models/slot.py

@@ -1,20 +0,0 @@
-from sqlalchemy import Column, Integer, String, Date, DateTime, Text
-from app.core.database import Base
-from datetime import datetime
-
-class Slot(Base):
-    __tablename__ = "visa_slot"
-
-    id = Column(Integer, primary_key=True, autoincrement=True)
-    submit_country = Column(String(100), nullable=False)
-    submit_city = Column(String(100))
-    travel_country = Column(String(100), nullable=False)
-    visa_type = Column(String(100), nullable=False)
-    lasted_slot_date = Column(Date)
-    available_dates = Column(Text)
-    available_times = Column(Text)
-    slot_number = Column(Integer)
-    website = Column(String(255))
-    open_mode = Column(Integer, default=0)
-    create_at = Column(DateTime, default=datetime.utcnow)
-    update_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 25 - 0
app/models/slot_snapshot.py

@@ -0,0 +1,25 @@
+from sqlalchemy import Column, Integer, String, DateTime, Enum, JSON
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasSlotSnapshot(Base):
+    __tablename__ = "vas_slot_snapshot"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+
+    country = Column(String(100), nullable=False)
+    city = Column(String(100), nullable=False)
+    visa_type = Column(String(100), nullable=False)
+    routing_key = Column(String(255), nullable=False)
+
+    availability_status = Column(
+        Enum('None','Available','Waitlist'),
+        nullable=False
+    )
+
+    earliest_date = Column(DateTime)
+    availability = Column(JSON, nullable=False)
+
+    snapshot_source = Column(Enum('worker','manual','sync'), nullable=False)
+    snapshot_at = Column(DateTime, nullable=False)

+ 24 - 0
app/models/ticket.py

@@ -0,0 +1,24 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, JSON
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasTicket(Base):
+    __tablename__ = "vas_ticket"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    order_id = Column(String(128), nullable=False)
+    user_id = Column(String(128), nullable=False)
+
+    type = Column(Enum('refund','dispute','change_request'), nullable=False)
+    status = Column(
+        Enum('pending','info_required','resolved','rejected'),
+        default='pending'
+    )
+
+    reason = Column(Text)
+    admin_comment = Column(Text)
+    extra_fields = Column(JSON)
+
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 25 - 0
app/models/user.py

@@ -0,0 +1,25 @@
+from sqlalchemy import Column, String, DateTime, Integer
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasUser(Base):
+    __tablename__ = "vas_user"
+
+    id = Column(String(128), primary_key=True)
+    role = Column(String(32))
+    email = Column(String(128))
+    phone = Column(String(32))
+    nickname = Column(String(64))
+    avatar_url = Column(String(255))
+    preferred_language = Column(String(8))
+    timezone = Column(String(32))
+
+    register_ip = Column(String(64))
+    last_login_ip = Column(String(64))
+
+    password_hash = Column(String(255))
+    email_verified = Column(Integer, default=0)
+
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 32 - 0
app/models/vas_task.py

@@ -0,0 +1,32 @@
+from sqlalchemy import Column, Integer, String, DateTime, JSON, Enum
+from datetime import datetime
+from app.core.database import Base
+
+
+class VasTask(Base):
+    __tablename__ = "vas_task"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    order_id = Column(String(128), nullable=False)
+
+    routing_key = Column(String(255), nullable=False)
+    priority = Column(Integer, default=0)
+    script_version = Column(String(50))
+
+    config = Column(JSON)
+    status = Column(
+        Enum('pending','grabbed','running','cancelled','completed'),
+        default='pending'
+    )
+
+    user_inputs = Column(JSON)
+    grabbed_history = Column(JSON)
+    meta = Column(JSON)
+
+    attempt_count = Column(Integer, default=0)
+    notify_count = Column(Integer, default=0)
+
+    expire_at = Column(DateTime)
+
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

+ 0 - 16
app/models/visafly_config.py

@@ -1,16 +0,0 @@
-from sqlalchemy import Column, Integer, String
-from app.core.database import Base
-
-class VisaflyConfig(Base):
-    __tablename__ = "visa_slot_queries"
-
-    id = Column(Integer, primary_key=True, autoincrement=True)
-    submission_country = Column(String(100), nullable=False)
-    submission_country_code = Column(String(10), nullable=False)
-    submission_city = Column(String(100), nullable=False)
-    submission_city_code = Column(String(10), nullable=False)
-    travel_country = Column(String(100), nullable=False)
-    travel_country_code = Column(String(10), nullable=False)
-    visa_type = Column(String(100), nullable=False)
-    provider = Column(String(100), nullable=True)
-    field_requirement_type = Column(String(100), nullable=True)

+ 23 - 0
app/schemas/auth.py

@@ -0,0 +1,23 @@
+from pydantic import BaseModel
+from typing import Optional
+from app.schemas.common import ApiResponse
+from app.schemas.user import VasUserOut
+
+class AutoRegisterRequest(BaseModel):
+    user_agent: Optional[str] = None
+    register_ip: str
+
+class AutoRegisterData(BaseModel):
+    user: VasUserOut
+    token: str
+
+class BindEmailRequest(BaseModel):
+    email: str
+
+class LoginRequest(BaseModel):
+    email: str
+    password: str
+
+class LoginData(BaseModel):
+    user: VasUserOut
+    token: str

+ 10 - 0
app/schemas/common.py

@@ -0,0 +1,10 @@
+# app/schemas/common.py
+from typing import Generic, TypeVar, Optional
+from pydantic import BaseModel
+
+T = TypeVar("T")
+
+class ApiResponse(BaseModel, Generic[T]):
+    code: int = 0
+    message: str = "success"
+    data: Optional[T] = None

+ 44 - 0
app/schemas/fake.py

@@ -0,0 +1,44 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import date, datetime
+
+class FakeUser(BaseModel):
+    provider: Optional[str] = None
+    visa_center: Optional[str] = None
+    order_no: Optional[str] = None
+    social_account: Optional[str] = None
+    account: Optional[str] = None
+    password: Optional[str] = None
+    last_name: Optional[str] = None
+    first_name: Optional[str] = None
+    gender: Optional[str] = None
+    birthday: Optional[date] = None
+    email: Optional[str] = None
+    alias_email: Optional[str] = None
+    phone_country_code: Optional[str] = None
+    phone_no: Optional[str] = None
+    passport_no: Optional[str] = None
+    nationality: Optional[str] = None
+    passport_expiry_date: Optional[date] = None
+    address_line1: Optional[str] = None
+    address_line2: Optional[str] = None
+    state: Optional[str] = None
+    city: Optional[str] = None
+    postcode: Optional[str] = None
+    travel_date: Optional[date] = None
+    cover_letter: Optional[str] = None
+    passport_image_url: Optional[str] = None
+    selfie_image_url: Optional[str] = None
+    application_form_url: Optional[str] = None
+    priority: Optional[int] = None
+    expected_submit_start: Optional[date] = None
+    expected_submit_end: Optional[date] = None
+    rules: Optional[str] = None
+    status: Optional[int] = None
+    placeholder: Optional[int] = None
+    appointment_datetime: Optional[datetime] = None
+    appointment_letter_url: Optional[str] = None
+    pnr_number: Optional[str] = None
+    payment_link: Optional[str] = None
+    payment_help: Optional[int] = None
+    note: Optional[str] = None

+ 25 - 0
app/schemas/order.py

@@ -0,0 +1,25 @@
+# app/schemas/order.py
+from pydantic import BaseModel
+from typing import Optional, Any, Literal, List
+from datetime import datetime
+
+class VasOrderBase(BaseModel):
+    status: Optional[Literal['pending','paid','completed','closed']] = None
+    user_inputs: Optional[Any] = None  
+
+class VasOrderCreate(BaseModel):
+    product_id: int
+    user_inputs: Optional[Any] = None
+    
+class VasOrderUpdate(VasOrderBase):
+    pass
+
+class VasOrderOut(VasOrderBase):
+    id: str
+    user_id: str
+    base_amount: int
+    currency: str
+    created_at: datetime
+    updated_at: datetime
+    class Config:
+        orm_mode = True

+ 45 - 0
app/schemas/payment.py

@@ -0,0 +1,45 @@
+from pydantic import BaseModel
+from typing import Optional, Literal
+from datetime import datetime
+
+
+class VasPaymentBase(BaseModel):
+    status: Optional[Literal['pending', 'succeeded', 'failed', 'expired', 'late_paid']] = None
+
+    qr_id: Optional[int] = None
+    payment_url: Optional[str] = None
+    expire_at: Optional[datetime] = None
+
+    provider_payload: Optional[dict] = None
+    
+class VasPaymentCreate(BaseModel):
+    order_id: str
+    provider: Literal['stripe', 'wechat', 'alipay']
+
+class VasPaymentUpdate(VasPaymentBase):
+    pass
+
+class VasPaymentOut(VasPaymentBase):
+    id: int
+    order_id: str
+
+    provider: str
+    channel: str
+
+    payment_intent_id: Optional[str]
+    external_trade_no: Optional[str]
+    
+    base_amount: int
+    base_currency: str
+
+    amount: int
+    currency: str
+    random_offset: int
+    
+    exchange_rate: float  # 注意:仅用于展示,DB 里是 DECIMAL
+
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        orm_mode = True

+ 26 - 0
app/schemas/payment_event.py

@@ -0,0 +1,26 @@
+# app/schemas/payment_event.py
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+
+
+class VasPaymentEventOut(BaseModel):
+    id: int
+    provider: str
+    event_type: Optional[str]
+    event_id: Optional[str]
+
+    parsed_amount: Optional[int]
+    parsed_currency: Optional[str]
+    parsed_device: Optional[str]
+
+    matched_payment_id: Optional[int]
+    matched_order_id: Optional[str]
+
+    status: str
+    error_message: Optional[str]
+
+    created_at: datetime
+
+    class Config:
+        orm_mode = True

+ 44 - 0
app/schemas/payment_provider.py

@@ -0,0 +1,44 @@
+# app/schemas/payment_provider.py
+from datetime import datetime
+from pydantic import BaseModel
+from typing import Optional, Dict, Any
+
+
+class VasPaymentProviderBase(BaseModel):
+    name: Optional[str] = None
+    channel: Optional[str] = None
+    currency: Optional[str] = None
+
+    icon: Optional[str] = None
+    enabled: Optional[int] = None
+    config: Optional[Dict[str, Any]] = None
+
+class VasPaymentProviderCreate(VasPaymentProviderBase):
+    name: str
+    channel: str
+    currency: str
+
+class VasPaymentProviderUpdate(VasPaymentProviderBase):
+    pass
+
+class VasPaymentProviderOut(VasPaymentProviderBase):
+    id: int
+
+    name: str
+    channel: str
+    currency: str
+
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        orm_mode = True
+        
+class VasPaymentProviderSimpleOut(BaseModel):
+    name: str
+    # channel: str
+    currency: str
+    icon: Optional[str] = None
+    
+    class Config:
+        orm_mode = True

+ 25 - 0
app/schemas/payment_qr.py

@@ -0,0 +1,25 @@
+# app/schemas/payment_qr.py
+from pydantic import BaseModel
+from datetime import datetime
+from typing import Optional
+
+class VasPaymentQrBase(BaseModel):
+    provider: Optional[str] = None
+    qr_code: Optional[str] = None
+    description: Optional[str] = None
+
+class VasPaymentQrCreate(VasPaymentQrBase):
+    provider: str
+    qr_code: str
+    description: str
+
+class VasPaymentQrOut(VasPaymentQrBase):
+    id: int
+    created_at: datetime
+    class Config:
+        orm_mode = True
+
+class VasPaymentQrSimpleOut(BaseModel):
+    qr_code: str
+    class Config:
+        orm_mode = True

+ 50 - 0
app/schemas/product.py

@@ -0,0 +1,50 @@
+from pydantic import BaseModel
+from typing import Optional, Dict, Any
+from datetime import datetime
+
+
+class VasProductBase(BaseModel):
+    title: Optional[str] = None
+    description: Optional[str] = None
+    extra_fields: Optional[Dict[str, Any]] = None
+
+    enabled: Optional[int] = None
+
+class VasProductCreate(BaseModel):
+    country: str
+    city: str
+    visa_type: Optional[str]
+
+    provider: str
+
+    title: str
+    description: Optional[str]
+    extra_fields: Optional[Dict[str, Any]]
+
+    price_amount: int
+    price_currency: str
+
+    schema_id: Optional[int]
+    
+class VasProductUpdate(VasProductBase):
+    pass
+
+class VasProductOut(VasProductBase):
+    id: int
+
+    country: str
+    city: str
+    visa_type: Optional[str]
+
+    provider: str
+
+    price_amount: int
+    price_currency: str
+
+    schema_id: Optional[int]
+
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        orm_mode = True

+ 32 - 0
app/schemas/product_routing.py

@@ -0,0 +1,32 @@
+from pydantic import BaseModel
+from typing import Optional, Dict, Any
+from datetime import datetime
+
+
+class VasProductRoutingBase(BaseModel):
+    is_active: Optional[int] = None
+
+class VasProductRoutingCreate(BaseModel):
+    product_id: int
+
+    routing_key: str
+    script_version: str
+
+    config: Optional[Dict[str, Any]]
+
+class VasProductRoutingUpdate(VasProductRoutingBase):
+    pass
+
+class VasProductRoutingOut(VasProductRoutingBase):
+    id: int
+    product_id: int
+
+    routing_key: str
+    script_version: str
+    config: Optional[Dict[str, Any]]
+
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        orm_mode = True

+ 27 - 0
app/schemas/schema.py

@@ -0,0 +1,27 @@
+# app/schemas/schema.py
+from pydantic import BaseModel
+from typing import Optional, Any, Dict
+from datetime import datetime
+
+class VasSchemaBase(BaseModel):
+    name: Optional[str] = None
+    description: Optional[str] = None
+    
+class VasSchemaCreate(BaseModel):
+    name: str
+    description: Optional[str]
+    schema_json: Dict[str, Any]
+
+class VasSchemaUpdate(VasSchemaBase):
+    pass
+
+class VasSchemaOut(VasSchemaBase):
+    id: int
+    schema_json: Dict[str, Any]
+
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        orm_mode = True
+

+ 5 - 4
app/schemas/short_url.py

@@ -7,9 +7,10 @@ class ShortUrlBase(BaseModel):
     long_url: HttpUrl
     created_at: datetime
 
+class ShortUrlCreate(BaseModel):
+    long_url: HttpUrl
+
+class ShortUrlOut(ShortUrlBase):
     class Config:
         orm_mode = True
-
-
-class ShortUrlCreate(BaseModel):
-    longUrl: HttpUrl
+    

+ 0 - 24
app/schemas/slot.py

@@ -1,24 +0,0 @@
-from pydantic import BaseModel
-from typing import Optional
-from datetime import date
-
-class SlotBase(BaseModel):
-    submit_country: str
-    submit_city: Optional[str] = None
-    travel_country: str
-    visa_type: str
-    lasted_slot_date: Optional[date] = None
-    available_dates: Optional[str] = None
-    available_times: Optional[str] = None
-    slot_number: Optional[int] = None
-    website: Optional[str] = None
-    open_mode: Optional[int] = 0
-
-class SlotCreate(SlotBase):
-    pass
-
-class SlotOut(SlotBase):
-    id: int
-
-    class Config:
-        orm_mode = True

+ 23 - 0
app/schemas/slot_snapshot.py

@@ -0,0 +1,23 @@
+# app/schemas/slot_snapshot.py
+from pydantic import BaseModel
+from typing import Any
+from datetime import datetime, date
+
+class SlotSnapshotBase(BaseModel):
+    country: str
+    city: str
+    visa_type: str
+    routing_key: str
+    availability_status: str
+    earliest_date: Optional[date] = None
+    availability: Any
+    snapshot_source: str
+    snapshot_at: datetime
+    
+class SlotSnapshotCreate(SlotSnapshotBase):
+    pass 
+
+class SlotSnapshotOut(SlotSnapshotBase):
+    id: int
+    class Config:
+        orm_mode = True

+ 10 - 0
app/schemas/telegram.py

@@ -0,0 +1,10 @@
+from pydantic import BaseModel
+from typing import Optional, Dict, Any
+from datetime import datetime
+
+
+class TelegramIn(BaseModel):
+    chat_id: str
+    api_token: str
+    message: str
+    image: str    

+ 37 - 0
app/schemas/ticket.py

@@ -0,0 +1,37 @@
+from pydantic import BaseModel
+from typing import Optional, Literal, Dict, Any
+from datetime import datetime
+
+
+class VasTicketBase(BaseModel):
+    status: Optional[
+        Literal['pending', 'info_required', 'resolved', 'rejected']
+    ] = None
+
+    admin_comment: Optional[str] = None
+    extra_fields: Optional[Dict[str, Any]] = None
+
+class VasTicketCreate(BaseModel):
+    order_id: str
+    user_id: str
+
+    type: Literal['refund', 'dispute', 'change_request']
+    reason: Optional[str]
+    extra_fields: Optional[Dict[str, Any]]
+
+class VasTicketUpdate(VasTicketBase):
+    pass
+
+class VasTicketOut(VasTicketBase):
+    id: int
+    order_id: str
+    user_id: str
+
+    type: str
+    reason: Optional[str]
+
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        orm_mode = True

+ 19 - 4
app/schemas/user.py

@@ -1,5 +1,20 @@
-from pydantic import BaseModel
+# app/schemas/user.py
+from pydantic import BaseModel, EmailStr
+from typing import Optional
+from datetime import datetime
 
-class UserOut(BaseModel):
-    id: int
-    name: str
+
+class VasUserOut(BaseModel):
+    id: str
+    role: Optional[str]
+    email: Optional[EmailStr]
+    phone: Optional[str]
+    nickname: Optional[str]
+    avatar_url: Optional[str]
+    preferred_language: Optional[str]
+    timezone: Optional[str]
+    email_verified: Optional[int]
+    created_at: datetime
+
+    class Config:
+        orm_mode = True

+ 21 - 0
app/schemas/vas_task.py

@@ -0,0 +1,21 @@
+# app/schemas/task.py
+from pydantic import BaseModel
+from typing import Optional, Any
+from datetime import datetime
+
+class VasTaskCreate(BaseModel):
+    order_id: str
+    routing_key: str
+    priority: int = 0
+    script_version: Optional[str] = None
+    config: Optional[Any] = None
+    user_inputs: Optional[Any] = None
+    expire_at: datetime
+
+class VasTaskOut(VasTaskCreate):
+    id: int
+    status: str
+    created_at: datetime
+    updated_at: datetime
+    class Config:
+        orm_mode = True

+ 0 - 22
app/schemas/visafly_config.py

@@ -1,22 +0,0 @@
-from pydantic import BaseModel
-from typing import Optional
-
-class VisaflyConfigBase(BaseModel):
-    submission_country: str
-    submission_country_code: str
-    submission_city: str
-    submission_city_code: str
-    travel_country: str
-    travel_country_code: str
-    visa_type: str
-    provider: Optional[str] = None
-    field_requirement_type: Optional[str] = None
-
-class VisaflyConfigCreate(VisaflyConfigBase):
-    pass
-
-class VisaflyConfigOut(VisaflyConfigBase):
-    id: int
-
-    class Config:
-        orm_mode = True

+ 13 - 0
app/schemas/webhook.py

@@ -0,0 +1,13 @@
+# app/schemas/troov.py
+from pydantic import BaseModel
+
+class SMSHelperWebhookPayload(BaseModel):
+    title: str
+    content: str
+    
+class PaymentWebhookOut(BaseModel):
+    status: bool
+    order_id: str
+    user_id: str
+    payment_id: int
+    notify: bool = True

+ 9 - 0
app/schemas/wechat.py

@@ -0,0 +1,9 @@
+from pydantic import BaseModel
+from typing import Optional, Dict, Any
+from datetime import datetime
+
+
+class WechatIn(BaseModel):
+    api_token: str
+    message: str
+    image: str    

+ 162 - 0
app/services/auth_service.py

@@ -0,0 +1,162 @@
+import uuid, bcrypt, random, string
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+from redis.asyncio import Redis
+from app.utils.redis_utils import redis_qpush
+from app.core.auth import get_current_user
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.models.user import VasUser
+from app.models.session import VasSession
+from app.models.email_verification import VasEmailVerification
+from app.schemas.auth import AutoRegisterRequest, BindEmailRequest, LoginRequest
+
+
+def _random_password(length=16):
+    return ''.join(random.choices(string.ascii_letters + string.digits + "!@#$%", k=length))
+
+class AuthService:
+    # -----------------------
+    # 自动注册
+    # -----------------------
+    @staticmethod
+    def auto_register(db: Session, req:AutoRegisterRequest):
+        uid = str(uuid.uuid4())
+
+        user = VasUser(
+            id=uid,
+            role="user",
+            nickname="anonymous visitor",
+            preferred_language="en",
+            timezone="Asia/Shanghai",
+            register_ip=req.register_ip,
+        )
+        db.add(user)
+        db.commit()
+
+        # 创建 session
+        token = f"tok_{uuid.uuid4().hex}"
+
+        session = VasSession(
+            id=token,
+            user_id=uid,
+            user_agent=req.user_agent or "",
+            ip=req.register_ip,
+            expire_at=datetime.utcnow() + timedelta(days=7)
+        )
+        db.add(session)
+        db.commit()
+        return {
+            "user": user,
+            "token": token
+        }
+
+    # -----------------------
+    # 绑定邮箱
+    # -----------------------
+    @staticmethod
+    def bind_email(db: Session, req: BindEmailRequest, auth_user: VasUser, redis_client:Redis):
+        user = db.query(VasUser).filter_by(email=req.email).first()
+        if user:
+            raise BizLogicError("Email has been bound")
+        
+        token = uuid.uuid4().hex
+            
+        record = VasEmailVerification(
+            user_id=auth_user.id,
+            email=req.email,
+            token=token,
+            expire_at=datetime.utcnow() + timedelta(minutes=30)
+        )
+        db.add(record)
+        db.commit()
+
+        print(f"📧 send verification email token={token}")
+        notification_payload = {
+            "notification_id": uuid.uuid4().hex,
+            "type": "verification email",
+            "user_id": auth_user.id,
+            "channels": ["email"],
+            "template_id": "verification_email",
+            "payload": {
+                "sendTo": req.email,
+                "token": token
+            }
+        }
+        redis_qpush(redis_client, 'vas_notification_queue', notification_payload)
+        return True
+
+    # -----------------------
+    # 邮箱验证
+    # -----------------------
+    @staticmethod
+    def verify_email(db: Session, token: str, redis_client:Redis):
+        record = (
+            db.query(VasEmailVerification)
+            .filter_by(token=token, used=0)
+            .first()
+        )
+        if not record:
+            raise BizLogicError("Token invalid")
+
+        if record.expire_at < datetime.utcnow():
+            raise BizLogicError("Token expired")
+
+        # 更新 user.email
+        user = db.query(VasUser).filter_by(id=record.user_id).first()
+        user.email = record.email
+
+        # 随机密码
+        plain = _random_password()
+        hashed = bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
+        setattr(user, "password_hash", hashed)
+
+        record.used = 1
+
+        db.commit()
+
+        print(f"📧 send login email and password")
+        notification_payload = {
+            "notification_id": uuid.uuid4().hex,
+            "type": "login credentials",
+            "user_id": record.user_id,
+            "channels": ["email"],
+            "template_id": "login_credentials",
+            "payload": {
+                "sendTo": record.email,
+                "username": record.email,
+                "password": plain
+            }
+        }
+        redis_qpush(redis_client, 'vas_notification_queue', notification_payload)
+        return True
+
+    # -----------------------
+    # 用户登录
+    # -----------------------
+    @staticmethod
+    def login(db: Session, req:LoginRequest):
+        user = db.query(VasUser).filter_by(email=req.email).first()
+        if not user:
+            raise NotFoundError("User not found")
+
+        # 对比密码
+        if not bcrypt.checkpw(req.password.encode(), user.password_hash.encode()):
+            raise PermissionDeniedError("Password incorrect")
+
+        # 创建 session
+        token = "tok_" + uuid.uuid4().hex
+
+        session = VasSession(
+            id=token,
+            user_id=user.id,
+            user_agent="",
+            ip="",
+            expire_at=datetime.utcnow() + timedelta(days=7)
+        )
+        db.add(session)
+        db.commit()
+
+        return {
+            "user": user,
+            "token": token
+        }

+ 10 - 3
app/services/configuration_service.py

@@ -1,5 +1,6 @@
 from sqlalchemy.orm import Session
 from typing import List, Optional
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
 from app.models.configuration import Configuration
 from app.schemas.configuration import ConfigurationCreate, ConfigurationUpdate
 
@@ -7,6 +8,9 @@ from app.schemas.configuration import ConfigurationCreate, ConfigurationUpdate
 class ConfigurationService:
     @staticmethod
     def create(db: Session, config_in: ConfigurationCreate) -> Configuration:
+        config = db.query(Configuration).filter(Configuration.config_key == config_in.config_key).first()
+        if config:
+            raise BizLogicError(f"Config Key '{config_in.config_key}' already exist")
         db_obj = Configuration(**config_in.dict())
         db.add(db_obj)
         db.commit()
@@ -19,13 +23,16 @@ class ConfigurationService:
 
     @staticmethod
     def get_by_key(db: Session, config_key: str) -> Optional[Configuration]:
-        return db.query(Configuration).filter(Configuration.config_key == config_key).first()
+        config = db.query(Configuration).filter(Configuration.config_key == config_key).first()
+        if not config:
+            raise NotFoundError(f"Config Key '{config_key}' not exist")
+        return config
 
     @staticmethod
     def update_by_key(db: Session, config_key: str, config_in: ConfigurationUpdate) -> Optional[Configuration]:
         db_obj = db.query(Configuration).filter(Configuration.config_key == config_key).first()
         if not db_obj:
-            return None
+            raise NotFoundError(f"Config Key '{config_key}' not exist")
         for field, value in config_in.dict(exclude_unset=True).items():
             setattr(db_obj, field, value)
         db.add(db_obj)
@@ -37,7 +44,7 @@ class ConfigurationService:
     def delete_by_key(db: Session, config_key: str) -> Optional[Configuration]:
         db_obj = db.query(Configuration).filter(Configuration.config_key == config_key).first()
         if not db_obj:
-            return None
+            raise NotFoundError(f"Config Key '{config_key}' not exist")
         db.delete(db_obj)
         db.commit()
         return db_obj

+ 158 - 152
app/services/email_authorizations_service.py

@@ -10,6 +10,8 @@ from email.message import EmailMessage
 from email.header import decode_header
 from sqlalchemy.orm import Session
 from typing import List, Optional
+from app.core.logger import logger
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
 from app.models.email_authorizations import EmailAuthorization
 from app.schemas.email_authorizations import EmailAuthorizationCreate, EmailAuthorizationUpdate
 
@@ -25,14 +27,23 @@ class EmailAuthorizationService:
 
     @staticmethod
     def get_by_id(db: Session, id: int) -> Optional[EmailAuthorization]:
-        return db.query(EmailAuthorization).filter(EmailAuthorization.id == id).first()
-
+        obj = db.query(EmailAuthorization).filter(EmailAuthorization.id == id).first()
+        if not obj:
+            raise NotFoundError("Email authorization not found")
+        return obj
+        
     @staticmethod
     def get_by_email(db: Session, email: str) -> Optional[EmailAuthorization]:
-        return db.query(EmailAuthorization).filter(EmailAuthorization.email == email).first()
-
+        obj = db.query(EmailAuthorization).filter(EmailAuthorization.email == email).first()
+        if not obj:
+            raise NotFoundError("Email authorization not found")
+        return obj
+        
     @staticmethod
     def create(db: Session, obj_in: EmailAuthorizationCreate) -> EmailAuthorization:
+        existing = EmailAuthorizationService.get_by_email(db, obj_in.email)
+        if existing:
+            raise BizLogicError(f"Email {obj_in.email} already exist")
         db_obj = EmailAuthorization(**obj_in.dict(exclude_unset=True))
         db.add(db_obj)
         db.commit()
@@ -43,7 +54,7 @@ class EmailAuthorizationService:
     def update(db: Session, id: int, obj_in: EmailAuthorizationUpdate) -> Optional[EmailAuthorization]:
         db_obj = db.query(EmailAuthorization).filter(EmailAuthorization.id == id).first()
         if not db_obj:
-            return None
+            raise NotFoundError("Email authorization not found")
         for field, value in obj_in.dict(exclude_unset=True).items():
             setattr(db_obj, field, value)
         db.add(db_obj)
@@ -55,7 +66,7 @@ class EmailAuthorizationService:
     def delete(db: Session, id: int) -> Optional[EmailAuthorization]:
         db_obj = db.query(EmailAuthorization).filter(EmailAuthorization.id == id).first()
         if not db_obj:
-            return None
+            raise NotFoundError("Email authorization not found")
         db.delete(db_obj)
         db.commit()
         return db_obj
@@ -161,7 +172,7 @@ class EmailAuthorizationService:
             if not received_headers:
                 return None
             for i, header in enumerate(received_headers, 1):
-                print(f"  [{i}] {header}")
+                logger.debug(f"  [{i}] {header}")
             last_received = received_headers[-1]
             if ";" not in last_received:
                 return None
@@ -171,112 +182,109 @@ class EmailAuthorizationService:
                 return None
             return datetime.fromtimestamp(email.utils.mktime_tz(dt_tuple), tz=timezone.utc)
 
-        try:
-            mail = EmailAuthorizationService._connect_imap_with_proxy(
-                IMAP_SERVER,
-                IMAP_PORT,
-                auth.proxy_host,
-                auth.proxy_port,
-                auth.proxy_username,
-                auth.proxy_password,
-            )
-            mail.login(EMAIL_ACCOUNT, EMAIL_PASSWORD)
-            mail.select("INBOX")
+        mail = EmailAuthorizationService._connect_imap_with_proxy(
+            IMAP_SERVER,
+            IMAP_PORT,
+            auth.proxy_host,
+            auth.proxy_port,
+            auth.proxy_username,
+            auth.proxy_password,
+        )
+        mail.login(EMAIL_ACCOUNT, EMAIL_PASSWORD)
+        mail.select("INBOX")
+
+        while time.time() < expiry_at:
+            mail.noop()  # 刷新邮箱状态
+            _, data = mail.search(None, "ALL")
+            mail_ids = data[0].split()
+            if not mail_ids:
+                time.sleep(EmailAuthorizationService.RETRY_DELAY_SECONDS)
+                continue
+
+            recent_ids = mail_ids[-EmailAuthorizationService.DEFAULT_READ_TOP_N_EMAIL:]
+            messages = []
+            debug = True
+
+            for email_id in reversed(recent_ids):
+                res, msg_data = mail.fetch(email_id, "(RFC822)")
+                if res != "OK" or not msg_data:
+                    if debug:
+                        logger.debug(f"[WARN] 邮件 ID={email_id.decode()} 获取失败")
+                    continue
+
+                msg_bytes = None
+                for part in msg_data:
+                    if isinstance(part, tuple):
+                        msg_bytes = part[1]
 
-            while time.time() < expiry_at:
-                mail.noop()  # 刷新邮箱状态
-                _, data = mail.search(None, "ALL")
-                mail_ids = data[0].split()
-                if not mail_ids:
-                    time.sleep(EmailAuthorizationService.RETRY_DELAY_SECONDS)
+                if not msg_bytes:
+                    if debug:
+                        logger.debug(f"[WARN] 邮件 ID={email_id.decode()} 无正文")
                     continue
 
-                recent_ids = mail_ids[-EmailAuthorizationService.DEFAULT_READ_TOP_N_EMAIL:]
-                messages = []
-                debug = True
-
-                for email_id in reversed(recent_ids):
-                    res, msg_data = mail.fetch(email_id, "(RFC822)")
-                    if res != "OK" or not msg_data:
-                        if debug:
-                            print(f"[WARN] 邮件 ID={email_id.decode()} 获取失败")
-                        continue
-
-                    msg_bytes = None
-                    for part in msg_data:
-                        if isinstance(part, tuple):
-                            msg_bytes = part[1]
-
-                    if not msg_bytes:
-                        if debug:
-                            print(f"[WARN] 邮件 ID={email_id.decode()} 无正文")
-                        continue
-
-                    msg = email.message_from_bytes(msg_bytes)
-                    received_dt = get_received_time(msg)
-                    if not received_dt:
-                        if debug:
-                            print(f"[WARN] 邮件 ID={email_id.decode()} 未解析出 Received 时间")
-                        continue
-
-                    messages.append((msg, received_dt))
-
-                if debug:
-                    print(f"[DEBUG] 成功解析邮件数: {len(messages)}")
-                    print(f"[DEBUG] 收件时间列表: {[m[1] for m in messages]}")
-
-                # 按收件时间降序排序
-                messages.sort(key=lambda x: x[1], reverse=True)
-
-                for msg, received_dt in messages:
-                    # 判断是否在发送时间后的有效窗口内
-                    if received_dt < sent_dt:
-                        if debug:
-                            print(f"[INFO] 邮件太旧: {received_dt}")
-                        continue
-                    if received_dt > sent_dt + timedelta(seconds=expiry):
-                        if debug:
-                            print(f"[INFO] 邮件太新: {received_dt}")
-                        continue
-
-                    # 匹配发件人/收件人
-                    msg_from = msg.get("From", "")
-                    msg_to = msg.get("To", "")
-                    if sender.lower() not in msg_from.lower():
-                        if debug:
-                            print("发件人不匹配")
-                        continue
-                    if recipient.lower() not in msg_to.lower():
-                        if debug:
-                            print("收件人不匹配")
-                        continue
-
-                    # 匹配主题
-                    subject, enc = decode_header(msg.get("Subject"))[0]
-                    if isinstance(subject, bytes):
-                        subject = subject.decode(enc or "utf-8", errors="ignore")
-                    if subject_keys and not any(k.lower() in subject.lower() for k in subject_keys):
-                        continue
-
-                    # 提取正文
-                    body = EmailAuthorizationService._extract_body(msg, only_text)
-                    if body_keys and not any(k.lower() in body.lower() for k in body_keys):
-                        continue
-
-                    # 找到匹配邮件 → 返回内容
-                    mail.close()
-                    mail.logout()
-                    return body.strip()
-
-                # 未匹配到 → 等待重试
-                time.sleep(EmailAuthorizationService.RETRY_DELAY_SECONDS)
+                msg = email.message_from_bytes(msg_bytes)
+                received_dt = get_received_time(msg)
+                if not received_dt:
+                    if debug:
+                        logger.debug(f"[WARN] 邮件 ID={email_id.decode()} 未解析出 Received 时间")
+                    continue
 
-            mail.close()
-            mail.logout()
-            return None  # 超时未找到
+                messages.append((msg, received_dt))
+
+            if debug:
+                logger.debug(f"[DEBUG] 成功解析邮件数: {len(messages)}")
+                logger.debug(f"[DEBUG] 收件时间列表: {[m[1] for m in messages]}")
+
+            # 按收件时间降序排序
+            messages.sort(key=lambda x: x[1], reverse=True)
+
+            for msg, received_dt in messages:
+                # 判断是否在发送时间后的有效窗口内
+                if received_dt < sent_dt:
+                    if debug:
+                        logger.debug(f"[INFO] 邮件太旧: {received_dt}")
+                    continue
+                if received_dt > sent_dt + timedelta(seconds=expiry):
+                    if debug:
+                        logger.debug(f"[INFO] 邮件太新: {received_dt}")
+                    continue
+
+                # 匹配发件人/收件人
+                msg_from = msg.get("From", "")
+                msg_to = msg.get("To", "")
+                if sender.lower() not in msg_from.lower():
+                    if debug:
+                        logger.debug("发件人不匹配")
+                    continue
+                if recipient.lower() not in msg_to.lower():
+                    if debug:
+                        logger.debug("收件人不匹配")
+                    continue
+
+                # 匹配主题
+                subject, enc = decode_header(msg.get("Subject"))[0]
+                if isinstance(subject, bytes):
+                    subject = subject.decode(enc or "utf-8", errors="ignore")
+                if subject_keys and not any(k.lower() in subject.lower() for k in subject_keys):
+                    continue
+
+                # 提取正文
+                body = EmailAuthorizationService._extract_body(msg, only_text)
+                if body_keys and not any(k.lower() in body.lower() for k in body_keys):
+                    continue
+
+                # 找到匹配邮件 → 返回内容
+                mail.close()
+                mail.logout()
+                return body.strip()
+
+            # 未匹配到 → 等待重试
+            time.sleep(EmailAuthorizationService.RETRY_DELAY_SECONDS)
+
+        mail.close()
+        mail.logout()
+        raise NotFoundError("Get email timeout")
 
-        except Exception as e:
-            raise e
         
 
     @staticmethod
@@ -326,7 +334,7 @@ class EmailAuthorizationService:
                 res, msg_data = mail.fetch(email_id, "(RFC822)")
                 if res != "OK" or not msg_data:
                     if debug:
-                        print(f"[WARN] 邮件 ID={email_id.decode()} 获取失败")
+                        logger.debug(f"[WARN] 邮件 ID={email_id.decode()} 获取失败")
                     continue
 
                 msg_bytes = None
@@ -336,14 +344,14 @@ class EmailAuthorizationService:
 
                 if not msg_bytes:
                     if debug:
-                        print(f"[WARN] 邮件 ID={email_id.decode()} 无正文")
+                        logger.debug(f"[WARN] 邮件 ID={email_id.decode()} 无正文")
                     continue
 
                 msg = email.message_from_bytes(msg_bytes)
                 messages.append(msg)
 
             if debug:
-                print(f"[DEBUG] 成功解析邮件数: {len(messages)}")
+                logger.debug(f"[DEBUG] 成功解析邮件数: {len(messages)}")
 
             for msg in messages:
           
@@ -352,11 +360,11 @@ class EmailAuthorizationService:
                 msg_to = msg.get("To", "")
                 if sender.lower() not in msg_from.lower():
                     if debug:
-                        print("发件人不匹配")
+                        logger.debug("发件人不匹配")
                     continue
                 if recipient.lower() not in msg_to.lower():
                     if debug:
-                        print("收件人不匹配")
+                        logger.debug("收件人不匹配")
                     continue
 
                 # 匹配主题
@@ -443,11 +451,11 @@ class EmailAuthorizationService:
                 msg_to = msg.get("To", "")
                 if sender.lower() not in msg_from.lower():
                     if debug:
-                        print("发件人不匹配")
+                        logger.debug("发件人不匹配")
                     continue
                 if recipient.lower() not in msg_to.lower():
                     if debug:
-                        print("收件人不匹配")
+                        logger.debug("收件人不匹配")
                     continue
 
                 # 匹配主题
@@ -537,46 +545,44 @@ class EmailAuthorizationService:
             content_type: str,
             content: str
     ):
-        try:
-            bcc_list = [s.strip() for s in send_to.split(",") if s.strip()]
-            EMAIL_ACCOUNT = auth.email
-            msg = EmailMessage()
-            msg["From"] = EMAIL_ACCOUNT
-            # TO 可以留空或放一个默认收件人
-            msg["To"] = bcc_list[0]  # 或者固定一个自己邮箱作为 TO
-            # BCC 添加所有收件人
+        bcc_list = [s.strip() for s in send_to.split(",") if s.strip()]
+        EMAIL_ACCOUNT = auth.email
+        msg = EmailMessage()
+        msg["From"] = EMAIL_ACCOUNT
+        # TO 可以留空或放一个默认收件人
+        msg["To"] = bcc_list[0]  # 或者固定一个自己邮箱作为 TO
+        # BCC 添加所有收件人
+        bcc_list = bcc_list
+        msg["Subject"] = subject
+        
+        html_content = None;
+        text_content = None;
+        if ("html" == content_type.lower()):
+            html_content = content
+        else:
+            text_content = content
+
+        # 设置正文内容
+        if html_content and text_content:
+            # multipart/alternative
+            msg.set_content(text_content)
+            msg.add_alternative(html_content, subtype="html")
+        elif html_content:
+            msg.add_alternative(html_content, subtype="html")
+        elif text_content:
+            msg.set_content(text_content)
+        else:
+            msg.set_content("")  # 空邮件
+        
+        # 发送
+        EmailAuthorizationService.send_email_smtp(
+            auth,
+            msg,
             bcc_list = bcc_list
-            msg["Subject"] = subject
-            
-            html_content = None;
-            text_content = None;
-            if ("html" == content_type.lower()):
-                html_content = content
-            else:
-                text_content = content
+        )
 
-            # 设置正文内容
-            if html_content and text_content:
-                # multipart/alternative
-                msg.set_content(text_content)
-                msg.add_alternative(html_content, subtype="html")
-            elif html_content:
-                msg.add_alternative(html_content, subtype="html")
-            elif text_content:
-                msg.set_content(text_content)
-            else:
-                msg.set_content("")  # 空邮件
-            
-            # 发送
-            EmailAuthorizationService.send_email_smtp(
-                auth,
-                msg,
-                bcc_list = bcc_list
-            )
+        return f"邮件 '{subject}' 成功发送至: {send_to}"
 
-            return f"邮件 '{subject}' 成功发送至: {send_to}"
-        except Exception as e:
-            raise e
 
     # ----------------------------------------------------------------------
     # SMTP 发送邮件

+ 258 - 0
app/services/fake_service.py

@@ -0,0 +1,258 @@
+import random
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from typing import List
+from datetime import date, datetime, timedelta
+from app.schemas.fake import FakeUser
+
+# ----------------------------------------
+# 邮箱域名
+# ----------------------------------------
+DOMAINS = [
+    "gmail-app.com",
+    "outlooksearch.com",
+    "hotmails.vip",
+    "gmail365.cc",
+    "ymails.top",
+    "teamymail.cfd"
+]
+
+def get_email_prefix(email: str) -> str:
+    if not email or "@" not in email:
+        return ""
+    return email.split("@", 1)[0]
+
+def generate_stable_email(base: str) -> str:
+    if not base or not base.strip():
+        base = "user"
+    index = abs(hash(base)) % len(DOMAINS)
+    return f"{base}@{DOMAINS[index]}"
+
+# ----------------------------------------
+# 国籍对应名字和护照格式
+# 全 ASCII
+# ----------------------------------------
+NATIONALITY_DATA = {
+    "China": {
+        "male_names": ["Wei", "Jian", "Liang", "Hao", "Jun"],
+        "female_names": ["Mei", "Li", "Fang", "Xia", "Lan"],
+        "surnames": ["Zhang", "Wang", "Li", "Liu", "Chen"],
+        "passport_pattern": "E?########"
+    },
+    "India": {
+        "male_names": ["Arjun", "Rohan", "Raj", "Vikram", "Siddharth"],
+        "female_names": ["Ananya", "Priya", "Aisha", "Neha", "Kavya"],
+        "surnames": ["Sharma", "Patel", "Singh", "Kumar", "Gupta"],
+        "passport_pattern": "?#######"
+    },
+    # "United States": {
+    #     "male_names": ["James", "John", "Robert", "Michael", "William"],
+    #     "female_names": ["Mary", "Patricia", "Linda", "Elizabeth", "Jennifer"],
+    #     "surnames": ["Smith", "Johnson", "Williams", "Brown", "Jones"],
+    #     "passport_pattern": "#########"
+    # },
+    # "United Kingdom": {
+    #     "male_names": ["Oliver", "George", "Harry", "Jack", "Jacob"],
+    #     "female_names": ["Olivia", "Amelia", "Isla", "Ava", "Emily"],
+    #     "surnames": ["Smith", "Jones", "Taylor", "Brown", "Williams"],
+    #     "passport_pattern": "#########"
+    # },
+    # "Ireland": {
+    #     "male_names": ["Conor", "Sean", "Patrick", "Liam", "Finn"],
+    #     "female_names": ["Aoife", "Saoirse", "Emily", "Grace", "Ciara"],
+    #     "surnames": ["Murphy", "Kelly", "OBrien", "Walsh", "Ryan"],
+    #     "passport_pattern": "P########"
+    # },
+    # "Germany": {
+    #     "male_names": ["Max", "Lukas", "Felix", "Leon", "Paul"],
+    #     "female_names": ["Anna", "Emma", "Sophia", "Marie", "Laura"],
+    #     "surnames": ["Mueller", "Schmidt", "Schneider", "Fischer", "Weber"],
+    #     "passport_pattern": "C########"
+    # },
+    # "France": {
+    #     "male_names": ["Louis", "Gabriel", "Raphael", "Arthur", "Jules"],
+    #     "female_names": ["Emma", "Louise", "Alice", "Chloe", "Lina"],
+    #     "surnames": ["Martin", "Bernard", "Thomas", "Robert", "Richard"],
+    #     "passport_pattern": "########"
+    # },
+    # "Japan": {
+    #     "male_names": ["Hiroshi", "Takumi", "Kenta", "Yuki", "Ryo"],
+    #     "female_names": ["Yui", "Sakura", "Mao", "Aya", "Hana"],
+    #     "surnames": ["Sato", "Suzuki", "Takahashi", "Tanaka", "Watanabe"],
+    #     "passport_pattern": "TR#######"
+    # }
+}
+
+# ----------------------------------------
+# 居住国家地址/电话/邮编(全 ASCII)
+# ----------------------------------------
+RESIDENCE_DATA = {
+    "China": {
+        "cities": ["Beijing", "Shanghai", "Shenzhen", "Guangzhou", "Chengdu"],
+        "streets": ["Heping Road", "Jiefang Road", "Renmin Street", "Zhongshan Avenue", "Fuxing Street"],
+        "postcode_prefix": "1",
+        "phone_code": "+86",
+        "phone_length": 11
+    },
+    "India": {
+        "cities": ["Delhi", "Mumbai", "Bangalore", "Kolkata", "Chennai"],
+        "streets": ["MG Road", "Connaught Place", "Brigade Road", "Park Street", "Anna Salai"],
+        "postcode_prefix": "5",
+        "phone_code": "+91",
+        "phone_length": 10
+    },
+    "United Kingdom": {
+        "cities": ["London", "Manchester", "Birmingham", "Leeds", "Glasgow"],
+        "streets": ["High Street", "Station Road", "Main Street", "Church Road", "London Road"],
+        "postcode_prefix": "SW",
+        "phone_code": "+44",
+        "phone_length": 10
+    },
+    "Ireland": {
+        "cities": ["Dublin", "Cork", "Galway", "Limerick", "Waterford"],
+        "streets": ["OConnell Street", "Patrick Street", "Main Street", "High Street", "Bridge Street"],
+        "postcode_prefix": "D",
+        "phone_code": "+353",
+        "phone_length": 9
+    }
+}
+
+# ----------------------------------------
+# 辅助函数
+# ----------------------------------------
+def generate_passport(pattern: str) -> str:
+    s = ""
+    for c in pattern:
+        if c == "#":
+            s += str(random.randint(0, 9))
+        elif c == "?":
+            s += random.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
+        else:
+            s += c
+    return s
+
+def generate_name(nationality: str, gender: str):
+    data = NATIONALITY_DATA[nationality]
+    if gender == "Male":
+        first = random.choice(data["male_names"])
+    else:
+        first = random.choice(data["female_names"])
+    last = random.choice(data["surnames"])
+    return first, last
+
+def generate_address(residence_country: str):
+    data = RESIDENCE_DATA.get(residence_country)
+    street = random.choice(data["streets"])
+    city = random.choice(data["cities"])
+    postcode = data["postcode_prefix"] + "".join([str(random.randint(0, 9)) for _ in range(5)])
+    return f"{random.randint(1, 200)} {street}", city, postcode
+
+# def generate_phone(residence_country: str):
+#     data = RESIDENCE_DATA.get(residence_country)
+#     num = "".join([str(random.randint(0,9)) for _ in range(data["phone_length"])])
+#     return data["phone_code"], num
+
+def generate_phone(country: str):
+    if country == "China":
+        prefix = "86"
+        first_digit = "1"
+        length = 11
+    elif country == "India":
+        prefix = "91"
+        first_digit = random.choice(["7", "8", "9"])
+        length = 10
+    elif country == "Ireland":
+        prefix = "353"
+        first_digit = "8"
+        length = 9
+    elif country == "United Kingdom":
+        prefix = "44"
+        first_digit = "7"
+        length = 10
+    elif country == "United States":
+        prefix = "1"
+        first_digit = str(random.randint(2,9))
+        length = 10
+    elif country == "Germany":
+        prefix = "49"
+        first_digit = str(random.randint(1,9))
+        length = 10
+    else:
+        prefix = "+00"
+        first_digit = "1"
+        length = 10
+    
+    # 生成国内号码(ASCII数字)
+    number = first_digit + "".join(str(random.randint(0,9)) for _ in range(length-1))
+    return prefix, '0' + number
+
+# ----------------------------------------
+# 主函数
+# ----------------------------------------
+def generate_fake_users(num: int, living_country: str = "Ireland") -> List[FakeUser]:
+    users = []
+    if living_country not in RESIDENCE_DATA.keys():
+        raise BizLogicError(f'Living country only have {RESIDENCE_DATA.keys()}')
+    for _ in range(num):
+        gender = random.choice(["Male","Female"])
+        nationality = random.choice(list(NATIONALITY_DATA.keys()))
+        first_name, last_name = generate_name(nationality, gender)
+        passport_no = generate_passport(NATIONALITY_DATA[nationality]["passport_pattern"])
+
+        address_line1, city, postcode = generate_address(living_country)
+        phone_country_code, phone_no = generate_phone(living_country)
+
+        fake_email = f"{first_name.lower()}.{last_name.lower()}@example.com"
+        alias_email = generate_stable_email(f"{first_name.lower()}.{last_name.lower()}")
+
+        travel_date = date.today() + timedelta(days=random.randint(30,90))
+        passport_expiry_date = date.today() + timedelta(days=random.randint(365,3650))
+        birthday = date.today() - timedelta(days=random.randint(18*365,50*365))
+
+        user = FakeUser(
+            provider="fake",
+            visa_center="fake",
+            order_no=f"{random.randint(10000000,99999999)}",
+            social_account=f"fake-{first_name.lower()}{random.randint(1,99)}",
+            account=fake_email,
+            password="Password123!",
+
+            last_name=last_name,
+            first_name=first_name,
+            gender=gender,
+            birthday=birthday,
+            nationality=nationality,
+            passport_no=passport_no,
+            passport_expiry_date=passport_expiry_date,
+            email=fake_email,
+            alias_email=alias_email,
+
+            address_line1=address_line1,
+            address_line2="",
+            city=city,
+            state="",
+            postcode=postcode,
+            phone_country_code=phone_country_code,
+            phone_no=phone_no,
+
+            travel_date=travel_date,
+            cover_letter="This is a test cover letter.",
+            passport_image_url="https://placekitten.com/400/400",
+            selfie_image_url="https://placekitten.com/401/401",
+            application_form_url="https://example.com/form",
+
+            priority=random.randint(1,5),
+            expected_submit_start='2000-01-01',
+            expected_submit_end='2100-01-01',
+            rules="",
+            status=0,
+            placeholder=1,
+            appointment_datetime=datetime.now() + timedelta(days=random.randint(20,200)),
+            appointment_letter_url="https://example.com/appointment",
+            pnr_number="",
+            payment_link="",
+            payment_help=1,
+            note="This is a test note."
+        )
+        users.append(user)
+
+    return users

+ 8 - 4
app/services/http_session_service.py

@@ -1,4 +1,5 @@
 from sqlalchemy.orm import Session
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
 from app.models.http_session import HttpSession
 from app.schemas.http_session import HttpSessionCreate, HttpSessionUpdate
 from typing import Optional
@@ -15,22 +16,25 @@ class HttpSessionService:
 
     @staticmethod
     def get_by_sid(db: Session, session_id: str) -> Optional[HttpSession]:
-        return db.query(HttpSession).filter(HttpSession.session_id == session_id).first()
+        obj = db.query(HttpSession).filter(HttpSession.session_id == session_id).first()
+        if not obj:
+            raise NotFoundError("Session not found")
+        return obj
 
     @staticmethod
     def delete_by_sid(db: Session, session_id: str) -> bool:
         obj = db.query(HttpSession).filter(HttpSession.session_id == session_id).first()
         if not obj:
-            return False
+            raise NotFoundError("Session not found")
         db.delete(obj)
         db.commit()
-        return True
+        return obj
 
     @staticmethod
     def update_by_sid(db: Session, session_id: str, data: HttpSessionUpdate):
         obj = db.query(HttpSession).filter(HttpSession.session_id == session_id).first()
         if not obj:
-            return None
+            raise NotFoundError("Session not found")
         for k, v in data.dict().items():
             if v is not None:
                 setattr(obj, k, v)

+ 25 - 0
app/services/notification_service.py

@@ -0,0 +1,25 @@
+# app/services/product_service.py
+import uuid
+from sqlalchemy.orm import Session
+from typing import Optional, List, Dict
+from redis.asyncio import Redis
+
+class NotificationService:
+
+    def create(redis: Redis, ntype: str, user_id:str, channels:List[str], template_id=str, payload=Dict):
+        notification_payload = {
+            "notification_id": f'nid_{uuid.uuid4().hex}',
+            "type": ntype,
+            "user_id": user_id,
+            "channels": channels,
+            "template_id": template_id,
+            "payload": payload
+        }
+
+        redis_qpush(
+            redis_client,
+            "vas_notification_queue",
+            notification_payload
+        )
+
+

+ 118 - 0
app/services/order_service.py

@@ -0,0 +1,118 @@
+# app/services/order_service.py
+import uuid
+from datetime import datetime
+from sqlalchemy.orm import Session
+from typing import List
+
+from app.utils.redis_utils import redis_qpush
+from app.core.auth import get_current_user
+from app.models.user import VasUser
+from app.models.order import VasOrder
+from app.models.vas_task import VasTask
+from app.models.product import VasProduct
+from app.models.product_routing import VasProductRouting
+from app.schemas.order import VasOrderCreate
+
+class OrderService:
+    
+    @staticmethod
+    def mark_as_admin_paid(db: Session, order: VasOrder, admin_user):
+        if order.status == "paid":
+            return order
+
+        order.status = "paid"
+
+        # 记录绕过支付的原因(非常重要)
+        order.meta = order.meta or {}
+        order.meta["_admin_bypass"] = {
+            "enabled": True,
+            "by": admin_user.id,
+            "at": datetime.utcnow().isoformat(),
+            "reason": "admin manual order",
+        }
+
+        db.add(order)
+        db.commit()
+        db.refresh(order)
+
+        return order
+    
+    @staticmethod
+    def create_tasks_for_order(db: Session, order: VasOrder):
+        """
+        为已支付订单创建任务(幂等)
+        """
+        if order.status != "paid":
+            return []
+
+        # ---------- 1. 查 routing ----------
+        routings = (
+            db.query(VasProductRouting)
+            .filter(
+                VasProductRouting.product_id == order.product_id,
+                VasProductRouting.is_active == 1
+            )
+            .all()
+        )
+
+        if not routings:
+            return []
+
+        created_tasks = []
+
+        for routing in routings:
+
+            # ---------- 2. 幂等判断 ----------
+            exists = (
+                db.query(VasTask)
+                .filter(
+                    VasTask.order_id == order.id,
+                    VasTask.routing_key == routing.routing_key,
+                    VasTask.script_version == routing.script_version,
+                )
+                .first()
+            )
+            if exists:
+                continue
+
+            # ---------- 3. 创建 task ----------
+            task = VasTask(
+                order_id=order.id,
+                routing_key=routing.routing_key,
+                script_version=routing.script_version,
+                priority=10,
+                status="pending",
+                user_inputs=order.user_inputs,
+                config=routing.config,
+                attempt_count=0,
+                notify_count=0,
+                expire_at=datetime.utcnow() + timedelta(days=7),
+                created_at=datetime.utcnow(),
+            )
+
+            db.add(task)
+            created_tasks.append(task)
+
+        db.commit()
+
+        return created_tasks
+    
+    @staticmethod
+    def create(db: Session, data: VasOrderCreate, product: VasProduct, auth_user: VasUser):
+        order_id = f"ORD-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}"
+        rec = VasOrder(id=order_id, **data.dict())
+        rec.base_amount = product.price_amount
+        rec.currency = product.price_currency
+        rec.user_id = auth_user.id
+        db.add(rec)
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    @staticmethod
+    def get(db: Session, id: str):
+        return db.query(VasOrder).filter_by(id=id).first()
+
+    @staticmethod
+    def list_by_user(db: Session, user_id: str, skip=0, limit=20) -> List[VasOrder]:
+        return db.query(VasOrder).filter_by(user_id=user_id).offset(skip).limit(limit).all()

+ 88 - 0
app/services/payment_provider_service.py

@@ -0,0 +1,88 @@
+# app/services/payment_provider.py
+
+from sqlalchemy.orm import Session
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.models.payment_provider import VasPaymentProvider
+from app.schemas.payment_provider import VasPaymentProviderCreate, VasPaymentProviderUpdate
+
+class PaymentProviderSerivce:
+
+    def create(
+        db: Session,
+        data: VasPaymentProviderCreate
+    ) -> VasPaymentProvider:
+        # 防止重复注册
+        exists = (
+            db.query(VasPaymentProvider)
+            .filter(
+                VasPaymentProvider.name == data.name,
+                VasPaymentProvider.channel == data.channel,
+                VasPaymentProvider.currency == data.currency,
+            )
+            .first()
+        )
+
+        if exists:
+            raise BizLogicError("Payment provider already exists")
+
+        provider = VasPaymentProvider(
+            name=data.name,
+            channel=data.channel,
+            currency=data.currency,
+            icon=data.icon,
+            enabled=data.enabled if data.enabled is not None else 1,
+            config=data.config,
+        )
+
+        db.add(provider)
+        db.commit()
+        db.refresh(provider)
+        return provider
+
+    def update(
+        db: Session,
+        provider_id: int,
+        data: VasPaymentProviderUpdate
+    ) -> VasPaymentProvider:
+        provider = db.query(VasPaymentProvider).get(provider_id)
+        if not provider:
+            raise BizLogicError("Payment provider not found")
+
+        update_data = data.dict(exclude_unset=True)
+
+        # 安全起见,禁止修改三元组
+        for forbidden in ("name", "channel", "currency"):
+            update_data.pop(forbidden, None)
+
+        for key, value in update_data.items():
+            setattr(provider, key, value)
+
+        db.commit()
+        db.refresh(provider)
+        return provider
+
+    def list_enabled(
+        db: Session,
+        currency: str = None
+    ):
+        q = db.query(VasPaymentProvider).filter(
+            VasPaymentProvider.enabled == 1
+        )
+
+        if currency:
+            q = q.filter(VasPaymentProvider.currency == currency)
+
+        return q.all()
+    
+    def get_by_name(
+        db: Session,
+        name: str
+    ):
+        q = db.query(VasPaymentProvider).filter(
+            VasPaymentProvider.enabled == 1
+        )
+
+        if name:
+            q = q.filter(VasPaymentProvider.name == name)
+
+        return q.first()

+ 26 - 0
app/services/payment_qr_service.py

@@ -0,0 +1,26 @@
+# app/services/payment_qr_service.py
+from sqlalchemy.orm import Session
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.models.payment_qr import VasPaymentQR
+from app.schemas.payment_qr import VasPaymentQrCreate
+
+class PaymentQrService:
+
+    def create(db: Session, data: VasPaymentQrCreate):
+        rec = VasPaymentQR(**data.dict())
+        db.add(rec)
+        db.commit()
+        db.refresh(rec)
+        return rec
+    
+    def get_by_id(db: Session, id: int):
+        obj = db.query(VasPaymentQR).filter(id == id).first()
+        if not obj:
+            raise NotFoundError("QR not exist")
+        return obj
+    
+    def get_by_devid(db: Session, devid: str):
+        return db.query(VasPaymentQR).filter(VasPaymentQR.devid == devid).all()
+
+    def get_by_provider(db: Session, provider: str):
+        return db.query(VasPaymentQR).filter(VasPaymentQR.provider == provider).all()

+ 231 - 0
app/services/payment_service.py

@@ -0,0 +1,231 @@
+# app/services/payment_service.py
+import time
+import stripe
+import random
+from typing import List,Dict
+from decimal import Decimal, ROUND_HALF_UP
+from datetime import datetime, timedelta
+from sqlalchemy.orm import Session
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.models.order import VasOrder
+from app.models.product import VasProduct
+from app.models.payment import VasPayment
+from app.models.payment_provider import VasPaymentProvider
+from app.models.payment_qr import VasPaymentQR
+from app.schemas.payment import VasPaymentCreate
+
+
+class PaymentService:
+    
+    @staticmethod
+    def create_payment(db: Session, payload: VasPaymentCreate, rate_table: Dict):
+        with db.begin():
+            # ① 锁住订单,防止并发创建 payment
+            order = (
+                db.query(VasOrder)
+                .filter(VasOrder.id == payload.order_id)
+                .with_for_update()
+                .one()
+            )
+
+            # ② 是否已有进行中的 payment
+            active_payment = (
+                db.query(VasPayment)
+                .filter(
+                    VasPayment.order_id == order.id,
+                    VasPayment.status == "pending"
+                )
+                .first()
+            )
+            if active_payment:
+                raise BizLogicError("Order already has active payment")
+
+            if payload.provider in ("wechat", "alipay"):
+                return success(
+                    data = PaymentService.create_offline_payment(
+                        db=db,
+                        order=order,
+                        provider_name=payload.provider,
+                        rate_table=rate_table,
+                    )
+                )
+
+            if payload.provider == "stripe":
+                return success(
+                    data = PaymentService.create_stripe_payment(
+                        db=db,
+                        order=order,
+                        rate_table=rate_table,
+                    )
+                )
+
+            raise BizLogicError("Unsupported provider")
+    
+    @staticmethod
+    def create_offline_payment(db, order, provider_name: str, rate_table: dict):
+        payment = (
+            PaymentService._create_wechat_payment(db, order)
+            if provider_name == "wechat"
+            else PaymentService._create_alipay_payment(db, order)
+        )
+        provider = db.query(VasPaymentProvider).filter(
+            VasPaymentProvider.enabled == 1,
+            VasPaymentProvider.name == provider_name
+        ).first()
+        qrs = db.query(VasPaymentQR).filter(VasPaymentQR.provider == provider_name).all()
+        if not qrs:
+            raise BizLogicError("No payment QR available")
+
+        qr = random.choice(qrs)
+        payment.qr_id = qr.id
+
+        rate_key = f"{order.currency}->{provider.currency}".upper()
+        exchange_rate = Decimal(rate_table[rate_key])
+
+        converted = (
+            Decimal(payment.base_amount) * exchange_rate
+        ).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
+        
+        max_discount = min(99, int(converted * Decimal("0.01")))
+        discount = random.randint(1, max_discount) if max_discount >= 1 else 0
+
+        final_amount = int(converted) - discount
+
+        payment.exchange_rate = exchange_rate
+        payment.amount = final_amount
+        payment.currency = provider.currency
+        payment.random_offset = discount
+        return payment
+    
+    @staticmethod
+    def create_stripe_payment(db, order, rate_table: dict):
+        payment = PaymentService._create_stripe_payment(db, order)
+        provider = db.query(VasPaymentProvider).filter(
+            VasPaymentProvider.enabled == 1,
+            VasPaymentProvider.name == 'stripe'
+        ).first()
+        rate_key = f"{order.currency}->{provider.currency}".upper()
+        exchange_rate = Decimal(rate_table[rate_key])
+
+        converted = (
+            Decimal(payment.base_amount) * exchange_rate
+        ).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
+
+        payment.exchange_rate = exchange_rate
+        payment.amount = int(converted)
+        payment.currency = provider.currency
+        payment.random_offset = 0
+
+        stripe_session = PaymentService.create_checkout_session(
+            order=order,
+            payment=payment,
+            success_url="https://yourdomain.com/pay/success",
+            cancel_url="https://yourdomain.com/pay/cancel"
+        )
+
+        payment.payment_intent_id = stripe_session.id
+        payment.payment_url = stripe_session.url
+
+        return payment
+
+    @staticmethod
+    def create_checkout_session(
+        order,
+        payment,
+        success_url: str,
+        cancel_url: str,
+    ):
+        """
+        order.base_amount  单位:cent
+        payment.amount     单位:cent
+        """
+        
+        expires_at = int(time.time()) + 30 * 60  # Stripe 专用
+
+        session = stripe.checkout.Session.create(
+            mode="payment",
+            payment_method_types=["card"],
+
+            line_items=[
+                {
+                    "price_data": {
+                        "currency": payment.currency.lower(),
+                        "product_data": {
+                            "name": f"Visa Service Order {order.id}",
+                        },
+                        "unit_amount": payment.amount,
+                    },
+                    "quantity": 1,
+                }
+            ],
+
+            metadata={
+                "order_id": order.id,
+                "payment_id": payment.id,
+                "user_id": order.user_id,
+            },
+
+            success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}",
+            cancel_url=cancel_url,
+
+            expires_at=expires_at,
+        )
+
+        return session
+
+    @staticmethod
+    def _create_wechat_payment(db: Session, order: VasOrder):
+        payment = VasPayment(
+            order_id=order.id,
+            provider="wechat",
+            channel="qr_static",
+            base_amount=order.base_amount,
+            base_currency=order.currency,
+            amount=0,
+            currency="CNY",
+            random_offset=0,
+            exchange_rate=0,
+            status="pending",
+            expire_at=datetime.utcnow() + timedelta(minutes=30),
+        )
+        db.add(payment)
+        db.flush()
+        return payment
+    
+    @staticmethod
+    def _create_alipay_payment(db: Session, order: VasOrder):
+        payment = VasPayment(
+            order_id=order.id,
+            provider="alipay",
+            channel="qr_static",
+            base_amount=order.base_amount,
+            base_currency=order.currency,
+            amount=0,
+            currency="CNY",
+            random_offset=0,
+            exchange_rate=0,
+            status="pending",
+            expire_at=datetime.utcnow() + timedelta(minutes=30),
+        )
+        db.add(payment)
+        db.flush()
+        return payment
+    
+    @staticmethod
+    def _create_stripe_payment(db: Session, order: VasOrder):
+        payment = VasPayment(
+            order_id=order.id,
+            provider="stripe",
+            channel="online_link",
+            base_amount=order.base_amount,
+            base_currency=order.currency,
+            amount=0,
+            currency="EUR",
+            random_offset=0,
+            exchange_rate=0,
+            status="pending",
+            expire_at=datetime.utcnow() + timedelta(minutes=30),
+        )
+        db.add(payment)
+        db.flush()
+        return payment

+ 15 - 0
app/services/product_routing_service.py

@@ -0,0 +1,15 @@
+# app/services/product_routing_service.py
+from sqlalchemy.orm import Session
+from app.models.product_routing import VasProductRouting
+from app.schemas.product_routing import ProductRoutingCreate
+
+class ProductRoutingService:
+    def create(db: Session, data: ProductRoutingCreate):
+        rec = VasProductRouting(**data.dict())
+        db.add(rec)
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    def list_by_product(db: Session, product_id:int):
+        return db.query(VasProductRouting).filter_by(product_id=product_id).all()

+ 31 - 0
app/services/product_service.py

@@ -0,0 +1,31 @@
+# app/services/product_service.py
+from sqlalchemy.orm import Session
+from typing import Optional, List
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.models.product import VasProduct
+from app.schemas.product import VasProductCreate, VasProductUpdate, VasProductOut
+
+class ProductService:
+
+    def create(db: Session, data: VasProductCreate):
+        rec = VasProduct(**data.dict())
+        db.add(rec)
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    def get(db: Session, id:int):
+        obj = db.query(VasProduct).filter_by(id=id).first()
+        if not obj:
+            raise NotFoundError('Product not exist')
+        return obj
+
+    def update(db: Session, id:int, data: VasProductUpdate):
+        rec = db.query(VasProduct).filter_by(id=id).first()
+        if not rec:
+            raise NotFoundError('Product not exist')
+        for k,v in data.dict(exclude_unset=True).items():
+            setattr(rec,k,v)
+        db.commit()
+        db.refresh(rec)
+        return rec

+ 38 - 0
app/services/schema_service.py

@@ -0,0 +1,38 @@
+# app/services/schema_service.py
+from sqlalchemy.orm import Session
+from typing import Optional
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.models.schema import VasSchema
+from app.schemas.schema import VasSchemaCreate, VasSchemaUpdate
+
+class SchemaService:
+
+    def create(db: Session, data: VasSchemaCreate):
+        rec = VasSchema(**data.dict())
+        db.add(rec)
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    def get(db: Session, id: int):
+        obj = db.query(VasSchema).filter_by(id=id).first()
+        if not obj:
+            raise NotFoundError('Schema not exist')
+        return obj
+
+    def update(db: Session, id: int, data: VasSchemaUpdate):
+        rec = SchemaService.get(db, id)
+        if not obj:
+            raise NotFoundError('Schema not exist')
+        for k,v in data.dict(exclude_unset=True).items():
+            setattr(rec, k, v)
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    def delete(db: Session, id:int):
+        rec = SchemaService.get(db, id)
+        if not obj:
+            raise NotFoundError('Schema not exist')
+        db.delete(rec)
+        db.commit()

+ 39 - 0
app/services/session_service.py

@@ -0,0 +1,39 @@
+# app/services/session_service.py
+
+from datetime import datetime, timedelta
+import uuid
+
+from sqlalchemy.orm import Session as DBSession
+from sqlalchemy import delete
+
+from app.models.session import VasSession
+from app.models.user import VasUser
+
+
+class SessionService:
+
+    # ============================
+    # token → user(鉴权用)
+    # ============================
+    @staticmethod
+    def get_user_by_token(db: DBSession, session_id: str) -> VasUser:
+        session_obj = db.query(VasSession).filter(VasSession.id == session_id).first()
+        if not session_obj:
+            return None
+
+        # session 是否过期
+        if session_obj.expire_at < datetime.utcnow():
+            # 自动删除过期 session
+            SessionService.delete_session(db, session_id)
+            return None
+
+        user = db.query(VasUser).filter(VasUser.id == session_obj.user_id).first()
+        return user
+
+    # ============================
+    # 删除 session(登出)
+    # ============================
+    @staticmethod
+    def delete_session(db: DBSession, session_id: str):
+        db.query(VasSession).filter(VasSession.id == session_id).delete()
+        db.commit()

+ 5 - 2
app/services/short_url_service.py

@@ -1,6 +1,7 @@
 import string
 import random
 from sqlalchemy.orm import Session
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
 from app.models.short_url import ShortUrl
 
 
@@ -17,7 +18,7 @@ class ShortUrlService:
         # 检查是否已经存在相同的长链接
         existing = db.query(ShortUrl).filter(ShortUrl.long_url == long_url).first()
         if existing:
-            return existing
+            raise BizLogicError("Short url already exist")
 
         # 生成唯一 short_key
         short_key = ShortUrlService.generate_short_key()
@@ -34,4 +35,6 @@ class ShortUrlService:
     def get_long_url(db: Session, short_key: str) -> str:
         """通过短 key 获取原始长链接"""
         record = db.query(ShortUrl).filter(ShortUrl.short_key == short_key).first()
-        return record.long_url if record else ""
+        if not record:
+            raise NotFoundError("Short url not found")
+        return record.long_url

+ 0 - 42
app/services/slot_service.py

@@ -1,42 +0,0 @@
-from sqlalchemy.orm import Session
-from sqlalchemy import asc, desc
-from app.models.slot import Slot
-from app.schemas.slot import SlotCreate
-from typing import List
-
-class SlotService:
-
-    @staticmethod
-    def report(db: Session, obj_in: SlotCreate) -> Slot:
-        # 可以根据 submit_city + travel_country + visa_type 查重,决定是更新还是新增
-        obj = db.query(Slot).filter(
-            Slot.submit_city == obj_in.submit_city,
-            Slot.travel_country == obj_in.travel_country,
-            Slot.visa_type == obj_in.visa_type
-        ).first()
-        if obj:
-            # 更新现有记录
-            for k, v in obj_in.dict().items():
-                setattr(obj, k, v)
-        else:
-            obj = VisaSlot(**obj_in.dict())
-            db.add(obj)
-        db.commit()
-        db.refresh(obj)
-        return obj
-
-    @staticmethod
-    def search(db: Session, submit_city: str, travel_country: str, visa_type: str, date_type: str):
-        query = db.query(Slot).filter(
-            Slot.submit_city == submit_city,
-            Slot.travel_country == travel_country,
-            Slot.visa_type == visa_type
-        )
-        if date_type == "latest":
-            obj = query.order_by(desc(Slot.update_at)).first()
-            return obj
-        elif date_type == "earliest":
-            obj = query.order_by(asc(Slot.lasted_slot_date)).first()
-            return obj
-        else:
-            return query.all()

+ 15 - 0
app/services/slot_snapshot_service.py

@@ -0,0 +1,15 @@
+# app/services/slot_snapshot_service.py
+from sqlalchemy.orm import Session
+from app.models.slot_snapshot import VasSlotSnapshot
+from app.schemas.slot_snapshot import SlotSnapshotCreate
+from datetime import datetime
+
+class SlotSnapshotService:
+    def __init__(self, db: Session): self.db = db
+
+    def create(self, data: SlotSnapshotCreate):
+        rec = VasSlotSnapshot(**data.dict())
+        self.db.add(rec); self.db.commit(); self.db.refresh(rec); return rec
+
+    def latest_for(self, country:str, city:str, visa_type:str):
+        return self.db.query(VasSlotSnapshot).filter_by(country=country, city=city, visa_type=visa_type).order_by(VasSlotSnapshot.snapshot_at.desc()).first()

+ 0 - 1
app/services/sms_service.py

@@ -2,7 +2,6 @@ import json
 import time
 import requests
 from typing import List
-from fastapi import Depends
 from app.schemas.sms import ShortMessageDetail
 
 

+ 6 - 2
app/services/task_service.py

@@ -1,6 +1,7 @@
 import json
 from sqlalchemy.orm import Session
 from typing import List, Optional
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
 from app.models.task import Task
 from app.schemas.task import TaskCreate, TaskUpdate
 
@@ -20,13 +21,16 @@ class TaskService:
 
     @staticmethod
     def get_by_id(db: Session, task_id: int) -> Optional[Task]:
-        return db.query(Task).filter(Task.id == task_id).first()
+        obj = db.query(Task).filter(Task.id == task_id).first()
+        if not obj:
+            raise NotFoundError("Task not exist")
+        return obj
 
     @staticmethod
     def update(db: Session, task_id: int, obj_in: TaskUpdate) -> Optional[Task]:
         db_obj = db.query(Task).filter(Task.id == task_id).first()
         if not db_obj:
-            return None
+            raise NotFoundError("Task not exist")
 
         if obj_in.result is not None:
             db_obj.result = obj_in.result

+ 21 - 0
app/services/telegram_service.py

@@ -0,0 +1,21 @@
+import json
+import time
+import requests
+from typing import List
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.schemas.telegram import TelegramIn
+
+
+class TelegramService:
+    def push_to_telegram(payload: TelegramIn):
+        url = f"https://api.telegram.org/bot{payload.api_token}/sendMessage"
+        payload = {
+            "chat_id": payload.chat_id,
+            "text": payload.message,
+            "parse_mode": "HTML"
+        }
+
+        response = requests.post(url, json=payload, timeout=10)
+        if response.status_code != 200:
+            raise BizLogicError("Telegram push failed")
+    

+ 53 - 0
app/services/ticket_service.py

@@ -0,0 +1,53 @@
+# app/services/ticket_service.py
+from sqlalchemy.orm import Session
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.models.ticket import VasTicket
+from app.schemas.ticket import VasTicketCreate
+from datetime import datetime
+
+class TicketService:
+    def create(db: Session, data: VasTicketCreate):
+        rec = VasTicket(**data.dict(), status='pending', created_at=datetime.utcnow())
+        db.add(rec)
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    def set_refund_approve(db: Session, id:int, admin_comment:str):
+        rec = db.query(VasTicket).filter_by(id=id).first()
+        if not rec:
+            raise NotFoundError("Ticket not exist")
+        rec.status = 'resolved'
+        rec.admin_comment = admin_comment
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    def set_refund_need_info(db: Session, id:int, admin_comment:str):
+        rec = db.query(VasTicket).filter_by(id=id).first()
+        if not rec:
+            raise NotFoundError("Ticket not exist")
+        rec.status = 'info_required'
+        rec.admin_comment = admin_comment
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    def submit_refund_extra(db: Session, ticket_id:int, extra:dict):
+        rec = db.query(VasTicket).filter_by(id=ticket_id).first()
+        if not rec:
+            raise NotFoundError("Ticket not exist")
+        rec.extra_fields = extra
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    def reject_refund(db: Session, id:int, admin_comment:str):
+        rec = db.query(VasTicket).filter_by(id=id).first()
+        if not rec:
+            raise NotFoundError("Ticket not exist")
+        rec.status = 'rejected'
+        rec.admin_comment = admin_comment
+        db.commit()
+        db.refresh(rec)
+        return rec

+ 19 - 6
app/services/troov_service.py

@@ -4,16 +4,19 @@ import requests
 from typing import List
 from fastapi import Depends
 from app.schemas.troov import TroovRate
+from app.utils.france_slot_api import *
+from app.utils.proxy_utils import load_proxies_from_json
 
-def pop_redis_value_session(redis_client):
+
+def pop_redis_value_token(redis_client):
     lua_script = '''
-local keys = redis.call('keys', 'session:*')
+local keys = redis.call('keys', 'token:*')
 local max_ttl = -1
 local max_key = nil
 
 for _, key in ipairs(keys) do
     local ttl = redis.call('ttl', key)
-    if ttl > max_ttl then
+    if ttl > 0 and ttl > max_ttl then
         max_ttl = ttl
         max_key = key
     end
@@ -51,16 +54,26 @@ def get_rate_by_date(redis_client, date: str) -> List[TroovRate]:
     """
     核心业务逻辑:根据日期返回 Troov 预约信息
     """
+    proxy_pools = ['oxylabs']
+    proxies = []
+    for pp in proxy_pools:
+        proxies = proxies + load_proxies_from_json("data/proxy_pool_config.json", pp)
+    
     result = None
     while True:
-        result = pop_redis_value_session(redis_client)
+        result = pop_redis_value_token(redis_client)
         if not result:
             time.sleep(1)
             continue
         break
-    session_data = result[1]
-    session_dic = json.loads(session_data)
+    body_str = result[1]
+    body = json.loads(body_str)
+    captcha = body.get("token")
     
+    session_dic = troov_create_session_old(random.choice(proxies), captcha)
+    if not session_dic:
+        return None
+    logger.info(f'创建 session 成功: {session_dic}')
     res = fetch_rate(session_dic, date)
     return json.loads(res)
 

+ 67 - 0
app/services/vas_task_service.py

@@ -0,0 +1,67 @@
+# app/services/task_service.py
+from sqlalchemy.orm import Session
+from typing import List
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.models.vas_task import VasTask
+from app.schemas.vas_task import VasTaskCreate
+from datetime import datetime
+
+class VasTaskService:
+
+    def create(db: Session, data: VasTaskCreate):
+        rec = VasTask(**data.dict(), status='pending', created_at=datetime.utcnow())
+        db.add(rec)
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    def get_pending(
+        db: Session,
+        routing_key: str = None,
+        script_version: str = None,
+        limit: int = 50,
+    ):
+        query = db.query(VasTask).filter(
+            VasTask.status == "pending",
+        )
+        
+        if routing_key:
+            query = query.filter(VasTask.routing_key == routing_key)
+
+        if script_version:
+            query = query.filter(VasTask.script_version == script_version)
+
+        return (
+            query
+            .order_by(
+                VasTask.priority.desc(),
+                VasTask.created_at.asc()
+            )
+            .limit(limit)
+            .all()
+        )
+        
+    def get_active_task_by_order_id(db: Session, order_id:str):
+        recs = db.query(VasTask).filter_by(
+            VasTask.status == "pending",
+            VasTask.order_id==order_id,
+            ).all()
+        return recs
+    
+    def return_to_queue(db: Session, id:int):
+        rec = db.query(VasTask).filter_by(id=id).first()
+        if not rec:
+            raise NotFoundError("Task not exist")
+        rec.status = 'pending'
+        db.commit()
+        db.refresh(rec)
+        return rec
+
+    def manual_confirm(db: Session, id:int):
+        rec = db.query(VasTask).filter_by(id=id).first()
+        if not rec:
+            raise NotFoundError("Task not exist")
+        rec.status = 'completed'
+        db.commit()
+        db.refresh(rec)
+        return rec

+ 0 - 50
app/services/visafly_config_service.py

@@ -1,50 +0,0 @@
-from sqlalchemy.orm import Session
-from app.models.visafly_config import VisaflyConfig
-from app.schemas.visafly_config import VisaflyConfigCreate
-from typing import List
-
-class VisaflyConfigService:
-
-    @staticmethod
-    def create(db: Session, obj_in: VisaflyConfigCreate) -> VisaflyConfig:
-        db_obj = VisaflyConfig(**obj_in.dict())
-        db.add(db_obj)
-        db.commit()
-        db.refresh(db_obj)
-        return db_obj
-
-    @staticmethod
-    def get_submission_countries(db: Session) -> List[dict]:
-        rows = db.query(
-            VisaflyConfig.submission_country,
-            VisaflyConfig.submission_country_code
-        ).distinct().all()
-        return [{"country": r[0], "country_code": r[1]} for r in rows]
-
-    @staticmethod
-    def get_cities_by_country(db: Session, country_code: str) -> List[dict]:
-        rows = db.query(
-            VisaflyConfig.submission_city,
-            VisaflyConfig.submission_city_code
-        ).filter(VisaflyConfig.submission_country_code == country_code).distinct().all()
-        return [{"city": r[0], "city_code": r[1]} for r in rows]
-
-    @staticmethod
-    def get_travel_countries_by_city(db: Session, city_code: str) -> List[dict]:
-        rows = db.query(
-            VisaflyConfig.travel_country,
-            VisaflyConfig.travel_country_code,
-            VisaflyConfig.visa_type,
-            VisaflyConfig.provider,
-            VisaflyConfig.field_requirement_type
-        ).filter(VisaflyConfig.submission_city_code == city_code).all()
-
-        return [
-            {
-                "travel_country": r[0],
-                "travel_country_code": r[1],
-                "visa_type": r[2],
-                "provider": r[3],
-                "field_requirement_type": r[4]
-            } for r in rows
-        ]

+ 286 - 0
app/services/webhook_service.py

@@ -0,0 +1,286 @@
+import json
+from sqlalchemy.orm import Session
+from typing import List, Optional
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.models.order import VasOrder
+from app.models.vas_task import VasTask
+from app.models.product import VasProduct
+from app.models.product_routing import VasProductRouting
+from app.models.payment_event import VasPaymentEvent
+from app.models.payment import VasPayment
+from app.models.payment_qr import VasPaymentQR
+from app.schemas.webhook import SMSHelperWebhookPayload, PaymentWebhookOut
+
+class WebhookService:
+    def create_task_if_not_exists(
+        db: Session,
+        order: VasOrder,
+        routing_key: str,
+        script_version: str,
+        config: dict,
+    ):
+        existing = (
+            db.query(VasTask)
+            .filter(
+                VasTask.order_id == order.id,
+                VasTask.routing_key == routing_key,
+                VasTask.script_version == script_version,
+            )
+            .first()
+        )
+
+        if existing:
+            return existing  # 幂等命中,直接返回
+
+        task = VasTask(
+            order_id=order.id,
+            routing_key=routing_key,
+            script_version=script_version,
+            config=config,
+            user_inputs=order.user_inputs,
+            status="pending",
+            priority=10,
+            expire_at=None,
+        )
+
+        db.add(task)
+        return task
+
+    
+    @staticmethod
+    def smshelper_payment_webhook(db: Session, payload: SMSHelperWebhookPayload):
+        """
+        webhook payload 示例:
+        title=【微信】微信支付
+        content=【SM-E5260】个人收款码到账¥0.01
+        """
+
+        title = payload.title
+        content = payload.content
+        webhook_out = PaymentWebhookOut()
+        if "微信" in title:
+            provider = "wechat"
+        elif "支付宝" in title:
+            provider = "alipay"
+
+        device_match = re.search(r"【(.+?)】", content)
+        device_id = device_match.group(1) if device_match else None
+
+        amount_match = re.search(r"¥([\d.]+)", content)
+        if not amount_match:
+            raise BizLogicError("Amount not found in webhook content")
+
+        amount_yuan = Decimal(amount_match.group(1))
+        amount_cent = int(amount_yuan * 100)
+        
+        event = VasPaymentEvent(
+            provider=provider,
+            event_type="payment_received",
+            title=title,
+            content=content,
+            parsed_amount=amount_cent,
+            parsed_currency="CNY",
+            parsed_device=device_id,
+            raw_payload=payload.dict(),
+            status="received"
+        )
+        db.add(event)
+        db.commit()
+        db.refresh(event)
+        
+        payment_qr = (
+            db.query(VasPaymentQR)
+            .filter(
+                VasPaymentQR.provider == provider,
+                VasPaymentQR.device == device_id,
+                VasPaymentQR.is_active == 1
+            )
+            .first()
+        )
+        
+        if not payment_qr:
+            event.status = "failed"
+            event.error_message = "QR not found"
+            db.commit()
+            raise BizLogicError("QR not found")
+
+        payment = (
+            db.query(VasPayment)
+            .filter(
+                VasPayment.provider == provider,
+                VasPayment.amount == amount_cent,
+                VasPayment.qr_id == payment_qr.id,
+                VasPayment.status == "pending"
+            )
+            .order_by(VasPayment.created_at.desc())
+            .first()
+        )
+        
+        if not payment:
+            event.status = "failed"
+            event.error_message = "No matching pending payment"
+            db.commit()
+            raise BizLogicError("Payment not found")
+        webhook_out.payment_id = payment.id
+        if payment.status in ("succeeded", "late_paid"):
+            event.status = "duplicate"
+            event.matched_payment_id = payment.id
+            event.matched_order_id = payment.order_id
+            db.commit()
+            webhook_out.status = True
+            webhook_out.notify = False
+            return webhook_out
+
+        now = datetime.utcnow()
+        if payment.expire_at and now > payment.expire_at:
+            payment.status = "late_paid"
+        else:
+            payment.status = "succeeded"
+            
+        _create_task_if_not_exists(db, order)
+                
+        # ---------- 写入原始 payload ----------
+        payment.provider_payload = {
+            "title": title,
+            "content": content,
+            "device_id": device_id,
+            "received_at": now.isoformat(),
+        }
+
+        order = db.query(VasOrder).filter(VasOrder.id == payment.order_id).first()
+        if order and order.status != "paid":
+            order.status = "paid"
+
+        event.status = "applied"
+        event.matched_payment_id = payment.id
+        event.matched_order_id = payment.order_id
+
+        db.commit()
+        db.refresh(payment)
+        
+        webhook_out.status = True
+        webhook_out.order_id = order.id
+        webhook_out.user_id = order.user_id
+        webhook_out.notify = True
+        
+        return webhook_out
+        
+    @staticmethod
+    def stripe_payment_webhook(db: Session, event):
+        """
+        Stripe webhook handler
+        """
+
+        event_id = event["id"]
+        event_type = event["type"]
+        data = event["data"]["object"]
+        webhook_out = PaymentWebhookOut()
+        # ---------- 1. 幂等(事件级) ----------
+        existed_event = (
+            db.query(VasPaymentEvent)
+            .filter(VasPaymentEvent.provider == "stripe")
+            .filter(VasPaymentEvent.event_id == event_id)
+            .first()
+        )
+        if existed_event:
+            webhook_out.status = True
+            webhook_out.notify = False
+            return webhook_out
+
+        # ---------- 2. 只处理关心的事件 ----------
+        if event_type != "checkout.session.completed":
+            db.add(
+                VasPaymentEvent(
+                    provider="stripe",
+                    event_id=event_id,
+                    event_type=event_type,
+                    payload=event,
+                    created_at=datetime.utcnow(),
+                )
+            )
+            db.commit()
+            webhook_out.status = True
+            webhook_out.notify = False
+            return webhook_out
+
+        # ---------- 3. 解析 metadata ----------
+        metadata = data.get("metadata", {})
+        payment_id = metadata.get("payment_id")
+        order_id = metadata.get("order_id")
+
+        if not payment_id or not order_id:
+            raise BizLogicError("Missing payment_id or order_id in metadata")
+
+        # ---------- 4. 查找 payment(业务级幂等) ----------
+        payment = (
+            db.query(VasPayment)
+            .filter(VasPayment.id == int(payment_id))
+            .first()
+        )
+        if not payment:
+            raise NotFoundError("Payment not found")
+
+        if payment.status == "succeeded":
+            # 已处理过
+            db.add(
+                VasPaymentEvent(
+                    provider="stripe",
+                    event_id=event_id,
+                    event_type=event_type,
+                    payload=event,
+                    payment_id=payment.id,
+                    created_at=datetime.utcnow(),
+                )
+            )
+            db.commit()
+            webhook_out.status = True
+            webhook_out.notify = False
+            return webhook_out
+
+        # ---------- 5. 金额校验 ----------
+        paid_amount = data["amount_total"]  # 单位:cent
+        paid_currency = data["currency"].upper()
+
+        if paid_amount != payment.amount or paid_currency != payment.currency:
+            raise BizLogicError(f"Amount mismatch, expected {payment.amount} {payment.currency}, got {paid_amount} {paid_currency}")
+
+        # ---------- 6. 判断是否超时 ----------
+        now = datetime.utcnow()
+        if payment.expire_at and now > payment.expire_at:
+            payment.status = "late_paid"
+        else:
+            payment.status = "succeeded"
+            
+        _create_task_if_not_exists(db, order)
+
+        payment.provider_payload = event
+        payment.updated_at = now
+
+        # ---------- 7. 更新 order ----------
+        order = db.query(VasOrder).filter(VasOrder.id == order_id).first()
+        if order and order.status != "paid":
+            order.status = "paid"
+            order.updated_at = now
+
+        # ---------- 8. 写 payment_event ----------
+        db.add(
+            VasPaymentEvent(
+                provider="stripe",
+                event_id=event_id,
+                event_type=event_type,
+                payment_id=payment.id,
+                order_id=order_id,
+                payload=event,
+                created_at=now,
+            )
+        )
+
+        db.commit()
+
+        webhook_out.status = True
+        webhook_out.order_id = order.id
+        webhook_out.user_id = order.user_id
+        webhook_out.notify = True
+        return webhook_out
+
+   

+ 26 - 0
app/services/wechat_service.py

@@ -0,0 +1,26 @@
+import json
+import time
+import requests
+from typing import List
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+from app.schemas.wechat import WechatIn
+
+
+class WechatService:
+    def push_to_wechat(payload: WechatIn):
+        """
+        企业微信 WebHook 格式:
+        https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY
+        """
+        url = f"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={payload.api_token}"
+        payload = {"msgtype": "text", "text": {"content": payload.message}}
+
+        response = requests.post(url, json=payload, timeout=10)
+        data = response.json()
+
+        if response.status_code != 200 or data.get("errcode") != 0:
+            # logger.error(f"企业微信推送失败: {response.text}")
+            raise BizLogicError("Wechat push failed")
+
+    
+    

+ 860 - 0
app/utils/france_slot_api.py

@@ -0,0 +1,860 @@
+import time
+import json
+import inspect
+import requests
+import uuid
+import random
+import urllib3
+import asyncio
+import aiohttp
+import logging
+from datetime import datetime, timedelta
+# from book_data_buiilder import troov_dublin_visas_book_data_builder
+
+# 禁止显示 urllib3 的 SSL 警告信息
+urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+logger = logging.getLogger(__name__)
+
+
+TROOV_EMBASSY = {
+    # 都柏林
+    'TroovFr_Dublin_Visas': {
+        'code': 'ambassade-de-france-en-irlande',
+        'name': 'Visas',
+        'teamId': '621540d353069dec25bd0045',
+        'zoneId': '624317926863643fe83c8548',
+        'chooseService': 'Visas',
+        'submitCountry': 'IE',
+        'submitCity': 'DUB',
+        'travelCountry': 'FR',
+        'visaCategory': 'Visas',
+        'website': 'https://consulat.gouv.fr/en/ambassade-de-france-en-irlande/appointment?name=Visas'
+    },
+    # 日本 - 测试环境
+    'TroovFr_Tokyo_Visa': {
+        'code': 'ambassade-de-france-a-tokyo',
+        'name': 'Visa',
+        'teamId': '6238b4dfb1e5a274ff4c0f09',
+        'zoneId': '6242f7463f8ce81dec596054',
+        'chooseService': 'Visa',
+        'submitCountry': 'JP',
+        'submitCity': 'TYO',
+        'travelCountry': 'FR',
+        'visaCategory': 'Visa',
+        'website': 'https://consulat.gouv.fr/en/ambassade-de-france-a-tokyo/appointment?name=Visa'
+    },
+}
+
+class SessionExpiredError(Exception):
+    """会话过期异常"""
+    def __init__(self, message="SESSION EXPIRED"):
+        super().__init__(message)
+
+def check_responsed_session_expired(resp_text: str):
+    """检查响应内容是否包含会话过期标识,如有则抛出异常"""
+    if "SESSION_EXPIRED" in resp_text or "SESSION_NOT_FOUND" in resp_text:
+        raise SessionExpiredError("SESSION EXPIRED OR NOT FOUND")
+
+def is_session_remaining_life_zero(session_dic, max_lifetime_minutes=5):
+    def should_expire(probability=0.6):
+        """以给定概率返回 True(表示失效),否则返回 False"""
+        return random.random() < probability
+
+    now = datetime.utcnow()
+    now_hour, now_minute = now.hour, now.minute
+    session_time = datetime.strptime(session_dic['session_create_at'], "%Y-%m-%dT%H:%M:%S.%fZ")
+    session_hour, session_minute = session_time.hour, session_time.minute
+
+    # 🕒 计算 session 已存活时长(分钟)
+    elapsed_minutes = (now - session_time).total_seconds() / 60
+
+    # ✅ 新增逻辑:session 超过 n 分钟就强制过期
+    if elapsed_minutes >= max_lifetime_minutes:
+        return True
+
+    # 🧠 原逻辑保持不变
+    if (now_minute >= 45 and session_hour == now_hour and 45 <= session_minute < 60) or \
+       (now_minute < 5 and session_hour == now_hour) or \
+       (now_minute >= 5 and now_minute < 45):
+        return False
+
+    expired = should_expire()
+    return expired
+
+    
+async def troov_handshake(embassy, proxy):
+    url = "https://51.254.177.49/api/handshake"
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'origin': 'https://consulat.gouv.fr',
+        'referer': embassy['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-web': 'fr.gouv.consulat'
+    }
+    session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False))
+    try:
+        async with session.head(url, headers=headers, proxy=proxy) as response:
+            if response.status == 200:
+                return (
+                    response.headers.get('X-Gouv-App-Id'),
+                    response.headers.get('X-Gouv-Handshake')
+                )
+            return None
+    finally:
+        await session.close()
+
+async def troov_create_session(proxy, captcha, embassy=TROOV_EMBASSY['TroovFr_Dublin_Visas']):
+    handshake_ret = await troov_handshake(embassy, proxy)
+    if not handshake_ret:
+        logger.error(f'troov_handshake failed')
+        return None
+    x_gouv_app_id, x_gouv_handshake = handshake_ret
+    reservation_session_ret = await troov_make_reservation_session(embassy, proxy, captcha, x_gouv_app_id, x_gouv_handshake)
+    if not reservation_session_ret:
+        logger.error(f'troov_make_reservation_session failed')
+        return None
+    x_gouv_handshake2, session_create_at, session_id = reservation_session_ret
+    session_dic = {
+        'embassy': embassy,
+        'x_gouv_app_id':x_gouv_app_id,
+        'x-csrf-token': x_gouv_handshake2,
+        'session_create_at': session_create_at,
+        'session_id': session_id
+    }
+    status = await troov_update_dynamic_steps(proxy, session_dic)
+    if status:
+        return session_dic
+    logger.error(f'troov_update_dynamic_steps failed')
+    return None
+    
+async def troov_refresh_session(proxy, session_dic):
+    handshake_ret = await troov_handshake(proxy, session_dic['center'])
+    if not handshake_ret:
+        return None
+    x_gouv_app_id, x_gouv_handshake = handshake_ret
+    session_dic['x_gouv_app_id'] = x_gouv_app_id
+    session_dic['x-csrf-token'] = x_gouv_handshake
+    reservation_session_ret = await troov_get_reservation_session(proxy, session_dic)
+    if not reservation_session_ret:
+        return None
+    return session_dic
+    
+async def troov_make_reservation_session(embassy, proxy, capcha_str, handshake_gouv_appid, handshake_gouv_handshake):
+    url = f"https://51.254.177.49/api/team/{embassy['teamId']}/reservations-session"
+    payload = json.dumps({
+        "standaloneServiceName": embassy["name"],
+        "sessionId": None,
+        "captcha": capcha_str
+    })
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'content-type': 'application/json',
+        'origin': f'https://consulat.gouv.fr',
+        'referer': embassy["website"],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-csrf-token': handshake_gouv_handshake,
+        'x-gouv-app-id': handshake_gouv_appid,
+        'x-gouv-web': 'fr.gouv.consulat'
+    }
+    
+    session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False))
+    try:
+        async with session.post(url, headers=headers, data=payload, proxy=proxy) as response:
+            response_text = await response.text()
+            if response.status == 200:
+                response_dic = json.loads(response_text)
+                return response.headers['X-Gouv-Handshake'], response_dic['created_at'], response_dic['_id']
+            logger.error(f'troov_make_reservation_session {response.status}, {response_text}')
+        return None
+    finally:
+        await session.close()
+    
+async def troov_get_reservation_session(proxy, session_dic):
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations-session"
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'origin': 'https://consulat.gouv.fr',
+        'referer': session_dic['embassy']['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat',
+    }
+
+    params = {
+        'sessionId': session_dic['session_id'],
+        'standaloneServiceName': session_dic['embassy']['name']
+    }
+    
+    session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False))
+    try:
+        async with session.get(url, params=params, headers=headers, proxy=proxy) as response:
+            if response.status == 200:
+                return await response.json()
+            else:
+                rtext = await response.text()
+                check_responsed_session_expired(rtext)
+        return None
+    finally:
+        await session.close()
+    
+async def troov_update_dynamic_steps(proxy, session_dic):
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations-session/{session_dic['session_id']}/update-dynamic-steps"
+    payload = json.dumps({
+        "key": "slotsSteps",
+        "steps": [
+            {
+                "stepType": "slotsStep",
+                "name": "Visas",
+                "numberOfSlots": 1,
+                "dynamicStepIndex": 0,
+                "zone_id": session_dic['embassy']['zoneId'],
+                "value": {
+                    "lastSelectedDate": "",
+                    "label": session_dic['embassy']['name'],
+                    "accessibleCalendar": False,
+                    "hasSwitchedCalendar": False,
+                    "slots": {}
+                }
+            }
+        ]
+    })
+    
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'content-type': 'application/json',
+        'origin': f'https://consulat.gouv.fr',
+        'referer': session_dic["embassy"]["website"],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat',
+    }
+    session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False))
+    try:
+        async with session.post(url, headers=headers, data=payload, proxy=proxy) as response:
+            if response.status == 200:
+                return True
+            else:
+                rtext = await response.text()
+                check_responsed_session_expired(rtext)
+                logger.error(f'troov_make_reservation_session {response.status}, {rtext}')
+        return False
+    finally:
+        await session.close()
+    
+async def troov_update_step_value(proxy, session_dic, slot_datetime_list: list):
+    slots_data = {}
+    for slot_datetime in slot_datetime_list:
+        slot_date = slot_datetime['date']
+        slot_time = slot_datetime['time']
+        if slot_date not in slots_data:
+            slots_data[slot_date] = []
+        _ = {
+            "time": slot_time,
+            "rate": "0.00",
+            "capacity": 1,
+            "numberOfApplicants": 1,
+            "date": slot_date
+        }
+        slots_data[slot_date].append(_)
+
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations-session/{session_dic['session_id']}/update-step-value"
+    payload = json.dumps({
+        "key": "slotsStep",
+        "value": {
+            "lastSelectedDate": list(slots_data.keys())[-1],
+            "label": session_dic['embassy']['name'],
+            "accessibleCalendar": False,
+            "hasSwitchedCalendar": False,
+            "slots": slots_data
+        },
+        "stepIndex": 2,
+        "dynamicStepIndex": 0
+    })
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'content-type': 'application/json',
+        'origin': 'https://consulat.gouv.fr',
+        'referer': session_dic['embassy']['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat',
+    }
+            
+    timeout = aiohttp.ClientTimeout(total=10)
+    
+    # 为每个请求创建全新的连接,不使用连接池
+    connector = aiohttp.TCPConnector(ssl=False, limit=1, force_close=True)
+    
+    async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
+        async with session.post(url, headers=headers, data=payload, proxy=proxy) as response:
+            rtext = await response.text()
+            logger.info(f'update-step-value retcode={response.status}')
+            if response.status == 200:
+                logger.info(f'update-step-value retcode={response.status}, text={rtext}')
+                return slot_datetime_list
+            if response.status == 404:
+                check_responsed_session_expired(rtext)
+                response_dic = json.loads(rtext)
+                taken_slots = response_dic.get('message', {}).get('takenSlots', [])
+                taken_slot_datetimes = []
+                for ts in taken_slots:
+                    sdt = {
+                        "date": ts['slotDate'].split('T')[0],
+                        "time": ts['slotDate'].split('T')[1]
+                    }
+                    taken_slot_datetimes.append(sdt)
+                return [x for x in slot_datetime_list if x not in taken_slot_datetimes]
+        return None
+
+    
+async def troov_get_exclude_days(proxy, session_dic):
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations/exclude-days"
+    payload = json.dumps({
+        "session": {
+            session_dic['embassy']['zoneId']: True
+        },
+        "sessionId": session_dic['session_id']
+    })
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'content-type': 'application/json',
+        'origin': f'https://consulat.gouv.fr',
+        'referer': session_dic['embassy']['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat'
+    }
+    
+    session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False))
+    try:
+        async with session.post(url, headers=headers, data=payload, proxy=proxy) as response:
+            if response.status == 200:
+                return await response.json()
+            else:
+                rtext = await response.text()
+                check_responsed_session_expired(rtext)
+        return None
+    finally:
+        await session.close()
+
+async def troov_get_interval(proxy, session_dic):
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations/get-interval?serviceId={session_dic['embassy']['zoneId']}"
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'origin': f'https://consulat.gouv.fr',
+        'referer': session_dic["embassy"]["website"],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat'
+    }
+    async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False)) as session:
+        async with session.get(url, headers=headers, proxy=proxy) as response:
+            if response.status == 200:
+                return await response.json()
+            else:
+                rtext = await response.text()
+                check_responsed_session_expired(rtext)
+    return None
+
+async def troov_get_available_times(proxy, session_dic, date, places=1, capacity=2):
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations/availability?name=Visas&date={date}&places={places}&matching=&maxCapacity={capacity}&sessionId={session_dic['session_id']}"
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'origin': f'https://consulat.gouv.fr',
+        'referer': session_dic['embassy']['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat'
+    }
+    async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False)) as session:
+        async with session.get(url, headers=headers, proxy=proxy) as response:
+            if response.status == 200:
+                return await response.json()
+            else:
+                rtext = await response.text()
+                check_responsed_session_expired(rtext)
+    return None
+    
+# async def troov_book(proxy, session_dic, date, slot, uinfo, captcha):
+#     url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations/family"
+#     book_body = troov_dublin_visas_book_data_builder(session_dic['session_id'], date, slot, uinfo, captcha)
+#     payload = json.dumps(book_body)
+#     headers = {
+#         'accept': 'application/json, text/plain, */*',
+#         'content-type': 'application/json',
+#         'origin': f'https://consulat.gouv.fr',
+#         'referer': session_dic['embassy']['website'],
+#         'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+#         'x-csrf-token': session_dic['x-csrf-token'],
+#         'x-gouv-app-id': session_dic['x_gouv_app_id'],
+#         'x-gouv-web': 'fr.gouv.consulat',
+#     }
+#     session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False))
+#     try:
+#         async with session.post(url, headers=headers, data=payload, proxy=proxy) as response:
+#             if response.status == 200:
+#                 resp_text = await response.text()
+#                 if "qrcode" in resp_text:
+#                     return resp_text
+#             else:
+#                 rtext = await response.text()
+#                 check_responsed_session_expired(rtext)
+#         return None
+#     finally:
+#         await session.close()
+
+async def troov_get_open_days(proxy, session_dic):
+    def get_dates_in_range(date_range):
+        start_date = datetime.strptime(date_range["start"], "%Y-%m-%d")
+        end_date = datetime.strptime(date_range["end"], "%Y-%m-%d")
+
+        dates_list = []
+        current_date = start_date
+        while current_date <= end_date:
+            dates_list.append(current_date.strftime("%Y-%m-%d"))
+            current_date += timedelta(days=1)
+        return dates_list
+
+    # 🚀 并发执行两个请求
+    interval, exclude_days = await asyncio.gather(
+        troov_get_interval(proxy, session_dic),
+        troov_get_exclude_days(proxy, session_dic)
+    )
+    # logger.info(f'interval={interval}, exclude_days={exclude_days}')
+    if interval and exclude_days:
+        all_days = get_dates_in_range(interval)
+        available_dates = [
+            day for day in all_days
+            if day not in exclude_days and datetime.strptime(day, "%Y-%m-%d").weekday() not in (5, 6)
+        ]
+        return available_dates
+
+    return None
+
+async def concurrency_full_coverage_lock(proxies, session_dic, slot_datetime_list, timeout=5):
+    start_time = time.perf_counter()
+    sem = asyncio.Semaphore(len(slot_datetime_list))
+
+    async def one_call(slot_datetime: dict):
+        async with sem:
+            t0 = time.perf_counter()
+            try:
+                result = await asyncio.wait_for(
+                    troov_update_step_value(random.choice(proxies), session_dic, [slot_datetime]),
+                    timeout=timeout
+                )
+                locked = bool(result)
+                status = "locked" if locked else "success"
+
+            except asyncio.TimeoutError:
+                locked = None
+                status = "timeout"
+
+            except SessionExpiredError:  # ⚠️ 不吞掉这个异常,直接向上传递
+                raise
+
+            except Exception as e:
+                locked = None
+                status = f"error: {repr(e)}"
+                print(f'e={e}')
+
+            finally:
+                t1 = time.perf_counter()
+                duration = t1 - t0
+
+            return {
+                "slot_datetime": slot_datetime,
+                "locked": locked,
+                "duration": duration,
+                "status": status
+            }
+
+    try:
+        tasks = [one_call(slot_datetime) for slot_datetime in slot_datetime_list]
+        results = await asyncio.gather(*tasks)
+    except SessionExpiredError:
+        # 🚨 如果任意任务抛出 SessionExpiredError,这里会捕获到
+        raise  # 交由外部处理
+
+    end_time = time.perf_counter()
+    total_time = end_time - start_time
+
+    # 统计结果
+    locked_count = sum(1 for r in results if r["locked"] is True)
+    success_count = sum(1 for r in results if r["locked"] is False)
+    error_count = sum(1 for r in results if r["locked"] is None)
+    avg_task_time = sum(r["duration"] for r in results) / len(results) if results else 0
+    locked_datetimes = [r['slot_datetime'] for r in results if r["locked"] is True]
+    
+    return {
+        "total": len(results),
+        "success": success_count,
+        "locked": locked_count,
+        "error": error_count,
+        "slot_locked": locked_count > 0,
+        "elapsed_time": total_time,
+        "avg_task_time": avg_task_time,
+        "session_dic": session_dic,
+        "locked_datetimes": locked_datetimes
+    }
+
+async def concurrency_lock(proxies, session_dic, slot_datetime_list, num_of_concurrency_size: int, timeout=5):
+    start_time = time.perf_counter()
+    sem = asyncio.Semaphore(num_of_concurrency_size)
+
+    async def one_call(task_id: int):
+        async with sem:
+            t0 = time.perf_counter()
+            try:
+                result = await asyncio.wait_for(
+                    troov_update_step_value(random.choice(proxies), session_dic, slot_datetime_list),
+                    timeout=timeout
+                )
+                locked = bool(result)
+                status = "locked" if locked else "success"
+
+            except asyncio.TimeoutError:
+                locked = None
+                status = "timeout"
+
+            except SessionExpiredError:  # ⚠️ 不吞掉这个异常,直接向上传递
+                raise
+
+            except Exception as e:
+                locked = None
+                status = f"error: {repr(e)}"
+
+            finally:
+                t1 = time.perf_counter()
+                duration = t1 - t0
+
+            return {
+                "id": task_id,
+                "locked": locked,
+                "duration": duration,
+                "status": status
+            }
+
+    try:
+        tasks = [one_call(i) for i in range(num_of_concurrency_size)]
+        results = await asyncio.gather(*tasks)
+    except SessionExpiredError:
+        # 🚨 如果任意任务抛出 SessionExpiredError,这里会捕获到
+        raise  # 交由外部处理
+
+    end_time = time.perf_counter()
+    total_time = end_time - start_time
+
+    # 统计结果
+    locked_count = sum(1 for r in results if r["locked"] is True)
+    success_count = sum(1 for r in results if r["locked"] is False)
+    error_count = sum(1 for r in results if r["locked"] is None)
+    avg_task_time = sum(r["duration"] for r in results) / len(results) if results else 0
+
+    return {
+        "total": len(results),
+        "success": success_count,
+        "locked": locked_count,
+        "error": error_count,
+        "slot_locked": locked_count > 0,
+        "elapsed_time": total_time,
+        "avg_task_time": avg_task_time,
+        "session_dic": session_dic
+    }
+
+    
+
+########################################################!!!同步函数!!!########################################################
+
+def troov_handshake_old(embassy, proxy):
+    url = "https://51.254.177.49/api/handshake"
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'origin': 'https://consulat.gouv.fr',
+        'referer': embassy["website"],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-web': 'fr.gouv.consulat'
+    }
+    proxies = {
+        "http": proxy,
+        "https": proxy
+    }
+    response = requests.head(url, headers=headers, proxies=proxies, verify=False)
+    if response.status_code == 200:
+        return (
+            response.headers.get('X-Gouv-App-Id'),
+            response.headers.get('X-Gouv-Handshake')
+        )
+    return None
+
+def troov_create_session_old(proxy, captcha, embassy=TROOV_EMBASSY["TroovFr_Dublin_Visas"]):
+    handshake_ret = troov_handshake_old(embassy, proxy)
+    if not handshake_ret:
+        return None
+    x_gouv_app_id, x_gouv_handshake = handshake_ret
+    reservation_session_ret = troov_make_reservation_session_old(embassy, proxy, captcha, x_gouv_app_id, x_gouv_handshake)
+    if not reservation_session_ret:
+        return None
+    x_gouv_handshake2, session_create_at, session_id = reservation_session_ret
+    session_dic = {
+        'embassy': embassy,
+        'x_gouv_app_id':x_gouv_app_id,
+        'x-csrf-token': x_gouv_handshake2,
+        'session_create_at': session_create_at,
+        'session_id': session_id
+    }
+    status = troov_update_dynamic_steps_old(proxy, session_dic)
+    if status:
+        return session_dic
+    return None
+
+def troov_refresh_session_old(proxy, session_dic):
+    handshake_ret = troov_handshake_old(session_dic['embassy'], proxy)
+    if not handshake_ret:
+        return None
+    x_gouv_app_id, x_gouv_handshake = handshake_ret
+    session_dic['x_gouv_app_id'] = x_gouv_app_id
+    session_dic['x-csrf-token'] = x_gouv_handshake
+    reservation_session_ret = troov_get_reservation_session_old(proxy, session_dic)
+    if not reservation_session_ret:
+        return None
+    return session_dic
+
+def troov_make_reservation_session_old(embassy, proxy, capcha_str, handshake_gouv_appid, handshake_gouv_handshake):
+    url = f"https://51.254.177.49/api/team/{embassy['teamId']}/reservations-session"
+    payload = json.dumps({
+        "standaloneServiceName": embassy['name'],
+        "sessionId": None,
+        "captcha": capcha_str
+    })
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'content-type': 'application/json',
+        'origin': f'https://consulat.gouv.fr',
+        'referer': embassy['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-csrf-token': handshake_gouv_handshake,
+        'x-gouv-app-id': handshake_gouv_appid,
+        'x-gouv-web': 'fr.gouv.consulat'
+    }
+    proxies = {
+        "http": proxy,
+        "https": proxy
+    }
+    response = requests.post(url, headers=headers, data=payload, proxies=proxies, verify=False)
+    if response.status_code == 200:
+        response_dic = response.json()
+        return response.headers['X-Gouv-Handshake'], response_dic['created_at'], response_dic['_id']
+    logger.error(f'troov_make_reservation_session_old {response.status_code}, {response.text}')
+    return None
+
+def troov_update_dynamic_steps_old(proxy, session_dic):
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations-session/{session_dic['session_id']}/update-dynamic-steps"
+    payload = json.dumps({
+        "key": "slotsSteps",
+        "steps": [
+            {
+                "stepType": "slotsStep",
+                "name": session_dic['embassy']['name'],
+                "numberOfSlots": 1,
+                "dynamicStepIndex": 0,
+                "zone_id": session_dic['embassy']['zoneId'],
+                "value": {
+                    "lastSelectedDate": "",
+                    "label": session_dic['embassy']['name'],
+                    "accessibleCalendar": False,
+                    "hasSwitchedCalendar": False,
+                    "slots": {}
+                }
+            }
+        ]
+    })
+    
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'content-type': 'application/json',
+        'origin': f'https://consulat.gouv.fr',
+        'referer': session_dic['embassy']['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat',
+    }
+    proxies = {
+        "http": proxy,
+        "https": proxy
+    }
+    response = requests.post(url, headers=headers, data=payload, proxies=proxies, verify=False)
+    if response.status_code == 200:
+        return True
+    else:
+        rtext = response.text
+        check_responsed_session_expired(rtext)
+    return False
+
+def troov_get_reservation_session_old(proxy, session_dic):
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations-session"
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'origin': 'https://consulat.gouv.fr',
+        'referer': session_dic['embassy']['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat',
+    }
+
+    params = {
+        'sessionId': session_dic['session_id'],
+        'standaloneServiceName': session_dic['embassy']['name']
+    }
+    proxies = {
+        "http": proxy,
+        "https": proxy
+    }
+    response  = requests.get(url, params=params, headers=headers, proxies=proxies, verify=False)
+    if response.status_code == 200:
+        return response.json()
+    else:
+        rtext =  response.text
+        check_responsed_session_expired(rtext)
+    return None
+
+def troov_get_interval_old(proxy, session_dic):
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations/get-interval?serviceId={session_dic['embassy']['zoneId']}"
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'origin': f'https://consulat.gouv.fr',
+        'referer': session_dic['embassy']['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat'
+    }
+    proxies = {
+        "http": proxy,
+        "https": proxy
+    }
+    response = requests.get(url, headers=headers, proxies=proxies, verify=False)
+    if response.status_code == 200:
+        return response.json()
+    else:
+        rtext = response.text
+        check_responsed_session_expired(rtext)
+    return None
+
+def troov_get_exclude_days_old(proxy, session_dic):
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations/exclude-days"
+    payload = json.dumps({
+        "session": {
+            session_dic['embassy']['zoneId']: True
+        },
+        "sessionId": session_dic['session_id']
+    })
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'content-type': 'application/json',
+        'origin': f'https://consulat.gouv.fr',
+        'referer': session_dic['embassy']['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat'
+    }
+    proxies = {
+        "http": proxy,
+        "https": proxy
+    }
+    response = requests.post(url, headers=headers, data=payload, proxies=proxies, verify=False)
+    if response.status_code == 200:
+        return response.json()
+    else:
+        rtext = response.text
+        check_responsed_session_expired(rtext)
+    return None
+
+def troov_get_open_days_old(proxy, session_dic):
+    def get_dates_in_range(date_range):
+        start_date = datetime.strptime(date_range["start"], "%Y-%m-%d")
+        end_date = datetime.strptime(date_range["end"], "%Y-%m-%d")
+        
+        current_date = start_date
+        dates_list = []
+        while current_date <= end_date:
+            dates_list.append(current_date.strftime("%Y-%m-%d"))
+            current_date += timedelta(days=1)
+        
+        return dates_list
+    interval = troov_get_interval_old(proxy, session_dic)
+    exclude_days = troov_get_exclude_days_old(proxy, session_dic)
+    # exclude_days = ['2025-05-31']
+    if interval and exclude_days:
+        all_days = get_dates_in_range(interval)
+        available_dates = [
+            day for day in all_days 
+            if day not in exclude_days and datetime.strptime(day, "%Y-%m-%d").weekday() not in (5, 6)
+        ]
+        return available_dates
+    return None
+
+def troov_get_available_times_old(proxy, session_dic, date, places=1, capacity=2):
+    url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations/availability?name=Visas&date={date}&places={places}&matching=&maxCapacity={capacity}&sessionId={session_dic['session_id']}"
+    headers = {
+        'accept': 'application/json, text/plain, */*',
+        'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+        'origin': f'https://consulat.gouv.fr',
+        'referer': session_dic['embassy']['website'],
+        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+        'x-gouv-app-id': session_dic['x_gouv_app_id'],
+        'x-gouv-web': 'fr.gouv.consulat'
+    }
+    proxies = {
+        "http": proxy,
+        "https": proxy
+    }
+    response = requests.get(url, headers=headers, proxies=proxies, verify=False)
+    if response.status_code == 200:
+        return response.json()
+    else:
+        rtext = response.text
+        check_responsed_session_expired(rtext)
+    return None
+
+# def troov_book_old(proxy, session_dic, date, slot, uinfo, captcha):
+#     try:
+#         url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations/family"
+#         book_body = troov_dublin_visas_book_data_builder(session_dic['session_id'], date, slot, uinfo, captcha)
+#         payload = json.dumps(book_body)
+#         headers = {
+#             'accept': 'application/json, text/plain, */*',
+#             'content-type': 'application/json',
+#             'origin': f'https://consulat.gouv.fr',
+#             'referer': session_dic['embassy']['website'],
+#             'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
+#             'x-csrf-token': session_dic['x-csrf-token'],
+#             'x-gouv-app-id': session_dic['x_gouv_app_id'],
+#             'x-gouv-web': 'fr.gouv.consulat',
+#         }
+#         proxy_config = {
+#             "http": proxy,
+#             "https": proxy
+#         }
+#         response = requests.post(url, headers=headers, data=payload, verify=False, proxies=proxy_config)
+#         resp_text = response.text
+#         logger.info(f'book code={response.status_code}, text={response.text}')
+#         if response.status_code == 200:
+#             if "qrcode" in resp_text:
+#                 return resp_text
+#         else:
+#             check_responsed_session_expired(resp_text)
+#     except Exception as e:
+#         logger.error(f"proxy={proxy}, session_dic={session_dic}, troov_book_old exception: {e}")
+#     return None

+ 56 - 0
app/utils/proxy_utils.py

@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+import asyncio
+import aiohttp
+import time
+import json
+from collections import Counter
+import statistics
+from typing import List, Dict, Any
+
+def build_proxy_url(item: Dict[str, Any]) -> str:
+    """
+    将 JSON 中的代理条目(scheme/server/port/username/password)转换为
+    aiohttp 可接受的 proxy URL,例如:
+      http://user:pass@host:port
+      http://host:port
+    """
+    scheme = item.get("scheme", "http")
+    host = item.get("server") or item.get("host") or item.get("ip")
+    port = item.get("port")
+    user = item.get("username") or item.get("user")
+    pwd = item.get("password") or item.get("pass")
+    if not host or not port:
+        return None
+    if user:
+        return f"{scheme}://{user}:{pwd}@{host}:{port}"
+    else:
+        return f"{scheme}://{host}:{port}"
+
+
+def load_proxies_from_json(path: str, pool_name) -> List[str]:
+    proxies: List[str] = []
+    try:
+        with open(path, "r", encoding="utf-8") as f:
+            pools = json.load(f)
+        arr = pools[pool_name]
+        if not isinstance(arr, list):
+            print(f"{path} does not contain a JSON array.")
+            return []
+        for entry in arr:
+            if entry.get('enable', True):
+                try:
+                    p = build_proxy_url(entry)
+                    if p:
+                        proxies.append(p)
+                except Exception:
+                    continue
+    except FileNotFoundError:
+        print(f"Proxy file {path} not found. Continuing without proxies.")
+    except Exception as e:
+        print(f"Failed to load proxies from {path}: {e}")
+    return proxies
+
+def select_good_iplist(proxies):
+    pass
+    
+    

+ 22 - 0
app/utils/redis_utils.py

@@ -0,0 +1,22 @@
+import json
+import pytz
+import resource
+from redis.asyncio import Redis
+from datetime import datetime, timedelta
+
+
+def redis_qpush(redis_client, qname: str, data: dict, max_len: int = 30):
+    """向队列右侧推入数据,并限制队列最大长度"""
+    data_string = json.dumps(data)
+    pipe = redis_client.pipeline()
+    pipe.rpush(qname, data_string)
+    pipe.ltrim(qname, -max_len, -1)  # 只保留右侧 max_len 个元素
+    pipe.execute()
+
+def redis_qpop(redis_client, qname:str, timeout: int = 5):
+    message = redis_client.blpop(qname, timeout=timeout)
+    if message is None:
+        return None  # 队列为空,直接返回
+    message_string = message[1]
+    data = json.loads(message_string)
+    return data

+ 31 - 0
app/utils/response.py

@@ -0,0 +1,31 @@
+# app/utils/response.py
+from app.schemas.common import ApiResponse
+from typing import Any
+from pydantic import BaseModel
+
+def _to_serializable(data: Any):
+    """自动将 ORM / Pydantic / list 转换为可序列化对象"""
+    if isinstance(data, BaseModel):
+        return data.dict()
+    if hasattr(data, "__table__"):  # SQLAlchemy ORM
+        return {
+            c.name: getattr(data, c.name)
+            for c in data.__table__.columns
+        }
+    if isinstance(data, list):
+        return [_to_serializable(i) for i in data]
+    return data
+
+def success(data=None, message: str = "success"):
+    return ApiResponse(
+        code=0,
+        message=message,
+        data=_to_serializable(data)
+    )
+
+def fail(message: str, code: int = 1):
+    return ApiResponse(
+        code=code,
+        message=message,
+        data=None
+    )

+ 9 - 0
app/utils/validation_utils.py

@@ -0,0 +1,9 @@
+from jsonschema import validate, ValidationError
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
+
+def validate_user_inputs(schema_json: dict, user_inputs: dict):
+    print(f'schema_json={schema_json}, user_inputs={user_inputs}')
+    try:
+        validate(instance=user_inputs, schema=schema_json)
+    except ValidationError as e:
+        raise BizLogicError(f"inputs validation failed, error: {e.message}, path: {list(e.path)}")