Bladeren bron

feat: update

jerry 5 maanden geleden
bovenliggende
commit
40e7b4c854
75 gewijzigde bestanden met toevoegingen van 3908 en 678 verwijderingen
  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
 DATABASE_URL=mysql://root:GqLLL7Bofj0WaaOpp.0@visafly.top:3306/book_user_info?charset=utf8mb4
 REDIS_URL=redis://:STEs2x6ML0U1HlpE9SojM6YU7QPhqzY8@45.137.220.138:6379/0
 REDIS_URL=redis://:STEs2x6ML0U1HlpE9SojM6YU7QPhqzY8@45.137.220.138:6379/0
 API_TOKEN=7x9EjFpmv7GjZc6AfVeqxuUBANpqkpkHAtxJM7CAW5oZhs0nEyCJBy39N4XXs5hgfYWXw3jFrcgXqQ42HAx9Qvwtk9vC2GvKBbWz
 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 time
+import uuid
+import json
 import requests
 import requests
 from typing import List
 from typing import List
 from app.core.logger import logger
 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 fastapi.responses import RedirectResponse
 from sqlalchemy.orm import Session
 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.redis import get_redis_client
 from app.core.database import get_db
 from app.core.database import get_db
+from app.core.auth import get_current_user
 from redis.asyncio import Redis
 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.troov import TroovRate
 from app.schemas.sms import ShortMessageDetail
 from app.schemas.sms import ShortMessageDetail
 from app.schemas.configuration import ConfigurationCreate, ConfigurationUpdate, ConfigurationOut
 from app.schemas.configuration import ConfigurationCreate, ConfigurationUpdate, ConfigurationOut
 from app.schemas.email_authorizations import EmailContent, EmailAuthorizationCreate, EmailAuthorizationUpdate, EmailAuthorizationOut
 from app.schemas.email_authorizations import EmailContent, EmailAuthorizationCreate, EmailAuthorizationUpdate, EmailAuthorizationOut
 from app.schemas.card import CardCreate, CardOut
 from app.schemas.card import CardCreate, CardOut
 from app.schemas.task import TaskCreate, TaskOut, TaskUpdate
 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.auto_booking import AutoBookingCreate, AutoBookingOut
 from app.schemas.http_session import HttpSessionCreate, HttpSessionUpdate,HttpSessionOut
 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.configuration_service import ConfigurationService
 from app.services.troov_service import get_rate_by_date
 from app.services.troov_service import get_rate_by_date
 from app.services.sms_service import save_short_message, query_short_message
 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.seaweedfs_service import SeaweedFSService
 from app.services.auto_booking_service import AutoBookingService
 from app.services.auto_booking_service import AutoBookingService
 from app.services.http_session_service import HttpSessionService
 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()
 public_router = APIRouter()
 # 受保护路由
 # 受保护路由
 protected_router = APIRouter()
 protected_router = APIRouter()
 
 
-@public_router.get("/ping", summary="心跳检测", tags=["测试接口"])
+@protected_router.get("/ping", summary="心跳检测", tags=["测试接口"])
 def ping():
 def ping():
     return {"message": "pong"}
     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(
 def sms_upload(
     phone: str = Query(..., description="手机号"),
     phone: str = Query(..., description="手机号"),
     message: 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())
     received_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
     msg = save_short_message(redis_client, phone, message, received_at, max_ttl)
     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(
 def sms_download(
     phone: str = Query(..., description="手机号"),
     phone: str = Query(..., description="手机号"),
     keyword: 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"),
 def troov_rate(date: str = Query(..., description="查询的日期, 格式: YYYY-MM-DD"),
                redis_client: Redis = Depends(get_redis_client)):
                redis_client: Redis = Depends(get_redis_client)):
     # 调用 service 层获取数据
     # 调用 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)):
 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)):
 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)):
 def dynamic_config_get_by_key(config_key: str, db: Session = Depends(get_db)):
     config = ConfigurationService.get_by_key(db, config_key)
     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)):
 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)
     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)):
 def dynamic_config_delete_by_key(config_key: str, db: Session = Depends(get_db)):
     config = ConfigurationService.delete_by_key(db, config_key)
     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(
 def http_session_create(
     data: HttpSessionCreate,
     data: HttpSessionCreate,
     db: Session = Depends(get_db)
     db: Session = Depends(get_db)
 ):
 ):
     logger.info(f"[Create HttpSession] sid={data.session_id}")
     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(
 def http_session_delete_by_sid(
     session_id: str = Query(...),
     session_id: str = Query(...),
     db: Session = Depends(get_db)
     db: Session = Depends(get_db)
 ):
 ):
     logger.info(f"[Delete HttpSession] sid={session_id}")
     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(
 def http_session_update_by_sid(
     session_id: str = Query(...),
     session_id: str = Query(...),
     data: HttpSessionUpdate = Body(...),
     data: HttpSessionUpdate = Body(...),
     db: Session = Depends(get_db)
     db: Session = Depends(get_db)
 ):
 ):
     logger.info(f"[Update HttpSession] sid={session_id}")
     logger.info(f"[Update HttpSession] sid={session_id}")
-
     obj = HttpSessionService.update_by_sid(db, session_id, data)
     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(
 def http_session_get_by_sid(
     session_id: str = Query(...),
     session_id: str = Query(...),
     db: Session = Depends(get_db)
     db: Session = Depends(get_db)
 ):
 ):
     logger.info(f"[Get HttpSession] sid={session_id}")
     logger.info(f"[Get HttpSession] sid={session_id}")
-
     obj = HttpSessionService.get_by_sid(db, 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)):
 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)):
 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)):
 def email_authorizations_get_by_id(id: int, db: Session = Depends(get_db)):
     email_auth = EmailAuthorizationService.get_by_id(db, id)
     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)):
 def email_authorizations_update_by_id(id: int, data: EmailAuthorizationUpdate, db: Session = Depends(get_db)):
     updated = EmailAuthorizationService.update(db, id, data)
     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)):
 def email_authorizations_delete_by_id(id: int, db: Session = Depends(get_db)):
     deleted = EmailAuthorizationService.delete(db, id)
     deleted = EmailAuthorizationService.delete(db, id)
-    if not deleted:
-        raise HTTPException(status_code=404, detail=f"ID={id} 的邮箱记录不存在")
     return deleted
     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)):
 def email_authorizations_get_by_email(email: str, db: Session = Depends(get_db)):
     email_auth = EmailAuthorizationService.get_by_email(db, email)
     email_auth = EmailAuthorizationService.get_by_email(db, email)
-    if not email_auth:
-        raise HTTPException(status_code=404, detail=f"邮箱 {email} 不存在")
     return email_auth
     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(
 def email_authorizations_fetch_email(
     email: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     email: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     sender: str = Query(..., description="发件人邮箱账号或者名字"),
     sender: str = Query(..., description="发件人邮箱账号或者名字"),
@@ -243,8 +224,6 @@ def email_authorizations_fetch_email(
     db: Session = Depends(get_db)
     db: Session = Depends(get_db)
 ):
 ):
     auth = EmailAuthorizationService.get_by_email(db, email)
     auth = EmailAuthorizationService.get_by_email(db, email)
-    if not auth:
-        raise HTTPException(status_code=404, detail=f"未找到邮箱授权记录: {email}")
     result = EmailAuthorizationService.fetch_email_authorizations(
     result = EmailAuthorizationService.fetch_email_authorizations(
         auth,
         auth,
         sender=sender,
         sender=sender,
@@ -255,11 +234,9 @@ def email_authorizations_fetch_email(
         expiry=expiry,
         expiry=expiry,
         only_text=True
         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(
 def email_authorizations_fetch_email_from_topn(
     email: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     email: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     sender: str = Query(..., description="发件人邮箱账号或者名字"),
     sender: str = Query(..., description="发件人邮箱账号或者名字"),
@@ -270,8 +247,6 @@ def email_authorizations_fetch_email_from_topn(
     db: Session = Depends(get_db)
     db: Session = Depends(get_db)
 ):
 ):
     auth = EmailAuthorizationService.get_by_email(db, email)
     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(
     result = EmailAuthorizationService.fetch_email_authorizations_from_top_n(
         auth,
         auth,
         sender=sender,
         sender=sender,
@@ -281,11 +256,9 @@ def email_authorizations_fetch_email_from_topn(
         top=top,
         top=top,
         only_text=True
         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(
 def email_authorizations_forward_email(
     emailAccount: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     emailAccount: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     forwardTo: 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)
     db: Session = Depends(get_db)
 ):
 ):
     auth = EmailAuthorizationService.get_by_email(db, emailAccount)
     auth = EmailAuthorizationService.get_by_email(db, emailAccount)
-    if not auth:
-        raise HTTPException(status_code=404, detail=f"未找到邮箱授权记录: {emailAccount}")
     result = EmailAuthorizationService.forward_first_matching_email(
     result = EmailAuthorizationService.forward_first_matching_email(
         auth,
         auth,
         forward_to = forwardTo,
         forward_to = forwardTo,
@@ -306,11 +277,9 @@ def email_authorizations_forward_email(
         subject_keywords = subjectKeywords,
         subject_keywords = subjectKeywords,
         body_keywords = bodyKeywords
         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(
 def email_authorizations_send_email(
     emailAccount: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     emailAccount: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     sendTo: str = Query(..., description="收件人邮箱账号"),
     sendTo: str = Query(..., description="收件人邮箱账号"),
@@ -320,8 +289,6 @@ def email_authorizations_send_email(
     db: Session = Depends(get_db)
     db: Session = Depends(get_db)
 ):
 ):
     auth = EmailAuthorizationService.get_by_email(db, emailAccount)
     auth = EmailAuthorizationService.get_by_email(db, emailAccount)
-    if not auth:
-        raise HTTPException(status_code=404, detail=f"未找到邮箱授权记录: {emailAccount}")
     result = EmailAuthorizationService.send_email(
     result = EmailAuthorizationService.send_email(
         auth,
         auth,
         send_to = sendTo,
         send_to = sendTo,
@@ -329,11 +296,9 @@ def email_authorizations_send_email(
         content_type = contentType,
         content_type = contentType,
         content = content.body
         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(
 def email_authorizations_send_email_bulk(
     emailAccount: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     emailAccount: str = Query(..., description="收件邮箱账号, 格式: xxx@xxx.xxx"),
     sendTo: str = Query(..., description="收件人邮箱账号,多个用逗号隔开"),
     sendTo: str = Query(..., description="收件人邮箱账号,多个用逗号隔开"),
@@ -343,8 +308,6 @@ def email_authorizations_send_email_bulk(
     db: Session = Depends(get_db)
     db: Session = Depends(get_db)
 ):
 ):
     auth = EmailAuthorizationService.get_by_email(db, emailAccount)
     auth = EmailAuthorizationService.get_by_email(db, emailAccount)
-    if not auth:
-        raise HTTPException(status_code=404, detail=f"未找到邮箱授权记录: {emailAccount}")
     result = EmailAuthorizationService.send_email_bulk(
     result = EmailAuthorizationService.send_email_bulk(
         auth,
         auth,
         send_to = sendTo,
         send_to = sendTo,
@@ -352,10 +315,7 @@ def email_authorizations_send_email_bulk(
         content_type = contentType,
         content_type = contentType,
         content = content.body
         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=["文件管理"])
 @protected_router.post("/resource/pdf", summary="上传pdf文件", tags=["文件管理"])
 def resource_upload_pdf(pdf: UploadFile = File(...)):
 def resource_upload_pdf(pdf: UploadFile = File(...)):
@@ -407,43 +367,33 @@ def resource_delete_image(fid: str):
         raise HTTPException(status_code=404, detail="图片不存在或删除失败")
         raise HTTPException(status_code=404, detail="图片不存在或删除失败")
     return {"success": True, "fid": fid}
     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(
 def short_url_generate(
     data: ShortUrlCreate,
     data: ShortUrlCreate,
     db: Session = Depends(get_db),
     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)
     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)):
 def task_create(data: TaskCreate, db: Session = Depends(get_db)):
     """创建任务"""
     """创建任务"""
     return TaskService.create(db, data)
     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(
 def task_get_pending(
     page: int = Query(0, description="第几页"),
     page: int = Query(0, description="第几页"),
     size: int = Query(10, description="分页大小"),
     size: int = Query(10, description="分页大小"),
@@ -451,106 +401,48 @@ def task_get_pending(
     db: Session = Depends(get_db),
     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(
 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(
 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(
 def cards_publish(
     data: CardCreate = Body(...),
     data: CardCreate = Body(...),
     db: Session = Depends(get_db)
     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(
 def cards_view_paginated(
     page: int = Query(0, description="第几页"),
     page: int = Query(0, description="第几页"),
     size: int = Query(10, description="分页大小"),
     size: int = Query(10, description="分页大小"),
     culture: str = Query("english", description="语言, 可设置 chinese, english"),
     culture: str = Query("english", description="语言, 可设置 chinese, english"),
     db: Session = Depends(get_db)
     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(
 def cards_view_paginated2(
     keywords: str = Query("", description="查询的关键词,多个关键词用逗号隔开"),
     keywords: str = Query("", description="查询的关键词,多个关键词用逗号隔开"),
     page: int = Query(0, description="第几页"),
     page: int = Query(0, description="第几页"),
@@ -559,24 +451,30 @@ def cards_view_paginated2(
     db: Session = Depends(get_db)
     db: Session = Depends(get_db)
 ):
 ):
     keyword_list = [k.strip() for k in keywords.split(",") if k.strip()]
     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)
 @protected_router.post("/autobooking", summary="创建自动预定订单", tags=["自动预定订单管理接口"], response_model=AutoBookingOut)
 def autobooking_create(data: AutoBookingCreate, db: Session = Depends(get_db)):
 def autobooking_create(data: AutoBookingCreate, db: Session = Depends(get_db)):
     return AutoBookingService.create(db, data)
     return AutoBookingService.create(db, data)
 
 
-
 @protected_router.post("/autobooking/create-by-ai", summary="用自然语言创建自动预定订单(底层使用chatgpt)", tags=["自动预定订单管理接口"])
 @protected_router.post("/autobooking/create-by-ai", summary="用自然语言创建自动预定订单(底层使用chatgpt)", tags=["自动预定订单管理接口"])
 def autobooking_create_by_ai():
 def autobooking_create_by_ai():
     # TODO: 这里可以对接 GPT 解析自然语言生成结构化 AutoBooking 数据
     # TODO: 这里可以对接 GPT 解析自然语言生成结构化 AutoBooking 数据
     return {"message": "AI 自动创建暂未实现"}
     return {"message": "AI 自动创建暂未实现"}
 
 
-
 @protected_router.post("/autobooking/batch", summary="批量查询多个自动预定订单信息", tags=["自动预定订单管理接口"])
 @protected_router.post("/autobooking/batch", summary="批量查询多个自动预定订单信息", tags=["自动预定订单管理接口"])
 def autobooking_get_by_ids(ids: List[int] = Body(...), db: Session = Depends(get_db)):
 def autobooking_get_by_ids(ids: List[int] = Body(...), db: Session = Depends(get_db)):
     return AutoBookingService.batch_get_by_ids(db, ids)
     return AutoBookingService.batch_get_by_ids(db, ids)
 
 
-
 @protected_router.get("/autobooking", summary="分页查询所有的自动预定订单信息", tags=["自动预定订单管理接口"], response_model=List[AutoBookingOut])
 @protected_router.get("/autobooking", summary="分页查询所有的自动预定订单信息", tags=["自动预定订单管理接口"], response_model=List[AutoBookingOut])
 def autobooking_get_paginated(
 def autobooking_get_paginated(
     tech_provider: str = Query("", description="签证网站技术提供商"),
     tech_provider: str = Query("", description="签证网站技术提供商"),
@@ -587,7 +485,6 @@ def autobooking_get_paginated(
 ):
 ):
     return AutoBookingService.get_paginated(db, tech_provider, keyword, page, size)
     return AutoBookingService.get_paginated(db, tech_provider, keyword, page, size)
 
 
-
 @protected_router.get("/autobooking/{id:int}", summary="根据id查询自动预定订单详情", tags=["自动预定订单管理接口"], response_model=AutoBookingOut)
 @protected_router.get("/autobooking/{id:int}", summary="根据id查询自动预定订单详情", tags=["自动预定订单管理接口"], response_model=AutoBookingOut)
 def autobooking_get_by_id(id: int, db: Session = Depends(get_db)):
 def autobooking_get_by_id(id: int, db: Session = Depends(get_db)):
     result = AutoBookingService.get_by_id(db, id)
     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="未找到订单")
         raise HTTPException(status_code=404, detail="未找到订单")
     return result
     return result
 
 
-
 @protected_router.delete("/autobooking/{id:int}", summary="根据id删除自动预定订单", tags=["自动预定订单管理接口"])
 @protected_router.delete("/autobooking/{id:int}", summary="根据id删除自动预定订单", tags=["自动预定订单管理接口"])
 def autobooking_delete_by_id(id: int, db: Session = Depends(get_db)):
 def autobooking_delete_by_id(id: int, db: Session = Depends(get_db)):
     ok = AutoBookingService.delete_by_id(db, id)
     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="删除失败或记录不存在")
         raise HTTPException(status_code=404, detail="删除失败或记录不存在")
     return {"success": True, "id": id}
     return {"success": True, "id": id}
 
 
-
 @protected_router.put("/autobooking/{id:int}", summary="根据id更新自动预定订单信息", tags=["自动预定订单管理接口"], response_model=AutoBookingOut)
 @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)):
 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)
     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="更新失败或记录不存在")
         raise HTTPException(status_code=404, detail="更新失败或记录不存在")
     return result
     return result
 
 
-
 @protected_router.get("/autobooking/statistics", summary="统计自动预定订单信息", tags=["自动预定订单管理接口"])
 @protected_router.get("/autobooking/statistics", summary="统计自动预定订单信息", tags=["自动预定订单管理接口"])
 def autobooking_statistics(
 def autobooking_statistics(
     tech_provider: str = Query("", description="签证网站技术提供商"),
     tech_provider: str = Query("", description="签证网站技术提供商"),
@@ -619,7 +513,6 @@ def autobooking_statistics(
 ):
 ):
     return AutoBookingService.statistics(db, tech_provider)
     return AutoBookingService.statistics(db, tech_provider)
 
 
-
 @protected_router.get("/autobooking/pending", summary="获取未处理的自动预定订单信息列表", tags=["自动预定订单管理接口"], response_model=List[AutoBookingOut])
 @protected_router.get("/autobooking/pending", summary="获取未处理的自动预定订单信息列表", tags=["自动预定订单管理接口"], response_model=List[AutoBookingOut])
 def autobooking_pending(
 def autobooking_pending(
     tech_provider: str = Query("", description="签证网站技术提供商"),
     tech_provider: str = Query("", description="签证网站技术提供商"),
@@ -627,88 +520,180 @@ def autobooking_pending(
 ):
 ):
     return AutoBookingService.get_pending(db, tech_provider)
     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)
     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)
     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)
     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)
     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 import Depends, HTTPException, status
 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
 from app.core.config import settings
 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(
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             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):
 class Settings(BaseSettings):
     app_name: str = "MyApp"
     app_name: str = "MyApp"
-    debug: bool = True
+    debug: bool = False
     database_url: str
     database_url: str
     redis_url: str
     redis_url: str
     api_token: str
     api_token: str
+    openai_api_key: str
+    stripe_api_key: str
     
     
     class Config:
     class Config:
         env_file = ".env"
         env_file = ".env"
 
 
 settings = Settings()
 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.middleware.cors import CORSMiddleware
 from fastapi.openapi.utils import get_openapi
 from fastapi.openapi.utils import get_openapi
 from fastapi.security import HTTPBearer
 from fastapi.security import HTTPBearer
@@ -6,9 +7,42 @@ from fastapi.security import HTTPBearer
 from app.api import router
 from app.api import router
 from app.core.auth import verify_token
 from app.core.auth import verify_token
 from app.core.config import settings
 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 = 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(可选)
 # 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
     long_url: HttpUrl
     created_at: datetime
     created_at: datetime
 
 
+class ShortUrlCreate(BaseModel):
+    long_url: HttpUrl
+
+class ShortUrlOut(ShortUrlBase):
     class Config:
     class Config:
         orm_mode = True
         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 sqlalchemy.orm import Session
 from typing import List, Optional
 from typing import List, Optional
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
 from app.models.configuration import Configuration
 from app.models.configuration import Configuration
 from app.schemas.configuration import ConfigurationCreate, ConfigurationUpdate
 from app.schemas.configuration import ConfigurationCreate, ConfigurationUpdate
 
 
@@ -7,6 +8,9 @@ from app.schemas.configuration import ConfigurationCreate, ConfigurationUpdate
 class ConfigurationService:
 class ConfigurationService:
     @staticmethod
     @staticmethod
     def create(db: Session, config_in: ConfigurationCreate) -> Configuration:
     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_obj = Configuration(**config_in.dict())
         db.add(db_obj)
         db.add(db_obj)
         db.commit()
         db.commit()
@@ -19,13 +23,16 @@ class ConfigurationService:
 
 
     @staticmethod
     @staticmethod
     def get_by_key(db: Session, config_key: str) -> Optional[Configuration]:
     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
     @staticmethod
     def update_by_key(db: Session, config_key: str, config_in: ConfigurationUpdate) -> Optional[Configuration]:
     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()
         db_obj = db.query(Configuration).filter(Configuration.config_key == config_key).first()
         if not db_obj:
         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():
         for field, value in config_in.dict(exclude_unset=True).items():
             setattr(db_obj, field, value)
             setattr(db_obj, field, value)
         db.add(db_obj)
         db.add(db_obj)
@@ -37,7 +44,7 @@ class ConfigurationService:
     def delete_by_key(db: Session, config_key: str) -> Optional[Configuration]:
     def delete_by_key(db: Session, config_key: str) -> Optional[Configuration]:
         db_obj = db.query(Configuration).filter(Configuration.config_key == config_key).first()
         db_obj = db.query(Configuration).filter(Configuration.config_key == config_key).first()
         if not db_obj:
         if not db_obj:
-            return None
+            raise NotFoundError(f"Config Key '{config_key}' not exist")
         db.delete(db_obj)
         db.delete(db_obj)
         db.commit()
         db.commit()
         return db_obj
         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 email.header import decode_header
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 from typing import List, Optional
 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.models.email_authorizations import EmailAuthorization
 from app.schemas.email_authorizations import EmailAuthorizationCreate, EmailAuthorizationUpdate
 from app.schemas.email_authorizations import EmailAuthorizationCreate, EmailAuthorizationUpdate
 
 
@@ -25,14 +27,23 @@ class EmailAuthorizationService:
 
 
     @staticmethod
     @staticmethod
     def get_by_id(db: Session, id: int) -> Optional[EmailAuthorization]:
     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
     @staticmethod
     def get_by_email(db: Session, email: str) -> Optional[EmailAuthorization]:
     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
     @staticmethod
     def create(db: Session, obj_in: EmailAuthorizationCreate) -> EmailAuthorization:
     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_obj = EmailAuthorization(**obj_in.dict(exclude_unset=True))
         db.add(db_obj)
         db.add(db_obj)
         db.commit()
         db.commit()
@@ -43,7 +54,7 @@ class EmailAuthorizationService:
     def update(db: Session, id: int, obj_in: EmailAuthorizationUpdate) -> Optional[EmailAuthorization]:
     def update(db: Session, id: int, obj_in: EmailAuthorizationUpdate) -> Optional[EmailAuthorization]:
         db_obj = db.query(EmailAuthorization).filter(EmailAuthorization.id == id).first()
         db_obj = db.query(EmailAuthorization).filter(EmailAuthorization.id == id).first()
         if not db_obj:
         if not db_obj:
-            return None
+            raise NotFoundError("Email authorization not found")
         for field, value in obj_in.dict(exclude_unset=True).items():
         for field, value in obj_in.dict(exclude_unset=True).items():
             setattr(db_obj, field, value)
             setattr(db_obj, field, value)
         db.add(db_obj)
         db.add(db_obj)
@@ -55,7 +66,7 @@ class EmailAuthorizationService:
     def delete(db: Session, id: int) -> Optional[EmailAuthorization]:
     def delete(db: Session, id: int) -> Optional[EmailAuthorization]:
         db_obj = db.query(EmailAuthorization).filter(EmailAuthorization.id == id).first()
         db_obj = db.query(EmailAuthorization).filter(EmailAuthorization.id == id).first()
         if not db_obj:
         if not db_obj:
-            return None
+            raise NotFoundError("Email authorization not found")
         db.delete(db_obj)
         db.delete(db_obj)
         db.commit()
         db.commit()
         return db_obj
         return db_obj
@@ -161,7 +172,7 @@ class EmailAuthorizationService:
             if not received_headers:
             if not received_headers:
                 return None
                 return None
             for i, header in enumerate(received_headers, 1):
             for i, header in enumerate(received_headers, 1):
-                print(f"  [{i}] {header}")
+                logger.debug(f"  [{i}] {header}")
             last_received = received_headers[-1]
             last_received = received_headers[-1]
             if ";" not in last_received:
             if ";" not in last_received:
                 return None
                 return None
@@ -171,112 +182,109 @@ class EmailAuthorizationService:
                 return None
                 return None
             return datetime.fromtimestamp(email.utils.mktime_tz(dt_tuple), tz=timezone.utc)
             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
                     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
     @staticmethod
@@ -326,7 +334,7 @@ class EmailAuthorizationService:
                 res, msg_data = mail.fetch(email_id, "(RFC822)")
                 res, msg_data = mail.fetch(email_id, "(RFC822)")
                 if res != "OK" or not msg_data:
                 if res != "OK" or not msg_data:
                     if debug:
                     if debug:
-                        print(f"[WARN] 邮件 ID={email_id.decode()} 获取失败")
+                        logger.debug(f"[WARN] 邮件 ID={email_id.decode()} 获取失败")
                     continue
                     continue
 
 
                 msg_bytes = None
                 msg_bytes = None
@@ -336,14 +344,14 @@ class EmailAuthorizationService:
 
 
                 if not msg_bytes:
                 if not msg_bytes:
                     if debug:
                     if debug:
-                        print(f"[WARN] 邮件 ID={email_id.decode()} 无正文")
+                        logger.debug(f"[WARN] 邮件 ID={email_id.decode()} 无正文")
                     continue
                     continue
 
 
                 msg = email.message_from_bytes(msg_bytes)
                 msg = email.message_from_bytes(msg_bytes)
                 messages.append(msg)
                 messages.append(msg)
 
 
             if debug:
             if debug:
-                print(f"[DEBUG] 成功解析邮件数: {len(messages)}")
+                logger.debug(f"[DEBUG] 成功解析邮件数: {len(messages)}")
 
 
             for msg in messages:
             for msg in messages:
           
           
@@ -352,11 +360,11 @@ class EmailAuthorizationService:
                 msg_to = msg.get("To", "")
                 msg_to = msg.get("To", "")
                 if sender.lower() not in msg_from.lower():
                 if sender.lower() not in msg_from.lower():
                     if debug:
                     if debug:
-                        print("发件人不匹配")
+                        logger.debug("发件人不匹配")
                     continue
                     continue
                 if recipient.lower() not in msg_to.lower():
                 if recipient.lower() not in msg_to.lower():
                     if debug:
                     if debug:
-                        print("收件人不匹配")
+                        logger.debug("收件人不匹配")
                     continue
                     continue
 
 
                 # 匹配主题
                 # 匹配主题
@@ -443,11 +451,11 @@ class EmailAuthorizationService:
                 msg_to = msg.get("To", "")
                 msg_to = msg.get("To", "")
                 if sender.lower() not in msg_from.lower():
                 if sender.lower() not in msg_from.lower():
                     if debug:
                     if debug:
-                        print("发件人不匹配")
+                        logger.debug("发件人不匹配")
                     continue
                     continue
                 if recipient.lower() not in msg_to.lower():
                 if recipient.lower() not in msg_to.lower():
                     if debug:
                     if debug:
-                        print("收件人不匹配")
+                        logger.debug("收件人不匹配")
                     continue
                     continue
 
 
                 # 匹配主题
                 # 匹配主题
@@ -537,46 +545,44 @@ class EmailAuthorizationService:
             content_type: str,
             content_type: str,
             content: 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
             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 发送邮件
     # 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 sqlalchemy.orm import Session
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
 from app.models.http_session import HttpSession
 from app.models.http_session import HttpSession
 from app.schemas.http_session import HttpSessionCreate, HttpSessionUpdate
 from app.schemas.http_session import HttpSessionCreate, HttpSessionUpdate
 from typing import Optional
 from typing import Optional
@@ -15,22 +16,25 @@ class HttpSessionService:
 
 
     @staticmethod
     @staticmethod
     def get_by_sid(db: Session, session_id: str) -> Optional[HttpSession]:
     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
     @staticmethod
     def delete_by_sid(db: Session, session_id: str) -> bool:
     def delete_by_sid(db: Session, session_id: str) -> bool:
         obj = db.query(HttpSession).filter(HttpSession.session_id == session_id).first()
         obj = db.query(HttpSession).filter(HttpSession.session_id == session_id).first()
         if not obj:
         if not obj:
-            return False
+            raise NotFoundError("Session not found")
         db.delete(obj)
         db.delete(obj)
         db.commit()
         db.commit()
-        return True
+        return obj
 
 
     @staticmethod
     @staticmethod
     def update_by_sid(db: Session, session_id: str, data: HttpSessionUpdate):
     def update_by_sid(db: Session, session_id: str, data: HttpSessionUpdate):
         obj = db.query(HttpSession).filter(HttpSession.session_id == session_id).first()
         obj = db.query(HttpSession).filter(HttpSession.session_id == session_id).first()
         if not obj:
         if not obj:
-            return None
+            raise NotFoundError("Session not found")
         for k, v in data.dict().items():
         for k, v in data.dict().items():
             if v is not None:
             if v is not None:
                 setattr(obj, k, v)
                 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 string
 import random
 import random
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
 from app.models.short_url import ShortUrl
 from app.models.short_url import ShortUrl
 
 
 
 
@@ -17,7 +18,7 @@ class ShortUrlService:
         # 检查是否已经存在相同的长链接
         # 检查是否已经存在相同的长链接
         existing = db.query(ShortUrl).filter(ShortUrl.long_url == long_url).first()
         existing = db.query(ShortUrl).filter(ShortUrl.long_url == long_url).first()
         if existing:
         if existing:
-            return existing
+            raise BizLogicError("Short url already exist")
 
 
         # 生成唯一 short_key
         # 生成唯一 short_key
         short_key = ShortUrlService.generate_short_key()
         short_key = ShortUrlService.generate_short_key()
@@ -34,4 +35,6 @@ class ShortUrlService:
     def get_long_url(db: Session, short_key: str) -> str:
     def get_long_url(db: Session, short_key: str) -> str:
         """通过短 key 获取原始长链接"""
         """通过短 key 获取原始长链接"""
         record = db.query(ShortUrl).filter(ShortUrl.short_key == short_key).first()
         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 time
 import requests
 import requests
 from typing import List
 from typing import List
-from fastapi import Depends
 from app.schemas.sms import ShortMessageDetail
 from app.schemas.sms import ShortMessageDetail
 
 
 
 

+ 6 - 2
app/services/task_service.py

@@ -1,6 +1,7 @@
 import json
 import json
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 from typing import List, Optional
 from typing import List, Optional
+from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
 from app.models.task import Task
 from app.models.task import Task
 from app.schemas.task import TaskCreate, TaskUpdate
 from app.schemas.task import TaskCreate, TaskUpdate
 
 
@@ -20,13 +21,16 @@ class TaskService:
 
 
     @staticmethod
     @staticmethod
     def get_by_id(db: Session, task_id: int) -> Optional[Task]:
     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
     @staticmethod
     def update(db: Session, task_id: int, obj_in: TaskUpdate) -> Optional[Task]:
     def update(db: Session, task_id: int, obj_in: TaskUpdate) -> Optional[Task]:
         db_obj = db.query(Task).filter(Task.id == task_id).first()
         db_obj = db.query(Task).filter(Task.id == task_id).first()
         if not db_obj:
         if not db_obj:
-            return None
+            raise NotFoundError("Task not exist")
 
 
         if obj_in.result is not None:
         if obj_in.result is not None:
             db_obj.result = obj_in.result
             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 typing import List
 from fastapi import Depends
 from fastapi import Depends
 from app.schemas.troov import TroovRate
 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 = '''
     lua_script = '''
-local keys = redis.call('keys', 'session:*')
+local keys = redis.call('keys', 'token:*')
 local max_ttl = -1
 local max_ttl = -1
 local max_key = nil
 local max_key = nil
 
 
 for _, key in ipairs(keys) do
 for _, key in ipairs(keys) do
     local ttl = redis.call('ttl', key)
     local ttl = redis.call('ttl', key)
-    if ttl > max_ttl then
+    if ttl > 0 and ttl > max_ttl then
         max_ttl = ttl
         max_ttl = ttl
         max_key = key
         max_key = key
     end
     end
@@ -51,16 +54,26 @@ def get_rate_by_date(redis_client, date: str) -> List[TroovRate]:
     """
     """
     核心业务逻辑:根据日期返回 Troov 预约信息
     核心业务逻辑:根据日期返回 Troov 预约信息
     """
     """
+    proxy_pools = ['oxylabs']
+    proxies = []
+    for pp in proxy_pools:
+        proxies = proxies + load_proxies_from_json("data/proxy_pool_config.json", pp)
+    
     result = None
     result = None
     while True:
     while True:
-        result = pop_redis_value_session(redis_client)
+        result = pop_redis_value_token(redis_client)
         if not result:
         if not result:
             time.sleep(1)
             time.sleep(1)
             continue
             continue
         break
         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)
     res = fetch_rate(session_dic, date)
     return json.loads(res)
     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)}")