skill-service 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- skill_service/__init__.py +2 -0
- skill_service/api/__init__.py +2 -0
- skill_service/api/authz.py +16 -0
- skill_service/api/middleware/__init__.py +2 -0
- skill_service/api/middleware/skill_auth.py +125 -0
- skill_service/api/routes/__init__.py +28 -0
- skill_service/api/routes/admin.py +103 -0
- skill_service/api/routes/auth.py +74 -0
- skill_service/api/routes/locations.py +36 -0
- skill_service/api/routes/payments.py +181 -0
- skill_service/api/routes/portal.py +37 -0
- skill_service/api/routes/recharge.py +66 -0
- skill_service/api/routes/search.py +106 -0
- skill_service/api/routes/subscriptions.py +179 -0
- skill_service/api/routes/tokens.py +67 -0
- skill_service/api/routes/wallet.py +90 -0
- skill_service/app.py +62 -0
- skill_service/config/__init__.py +2 -0
- skill_service/config/settings.py +66 -0
- skill_service/db.py +72 -0
- skill_service/integrations/__init__.py +2 -0
- skill_service/integrations/modash/__init__.py +2 -0
- skill_service/integrations/modash/client.py +119 -0
- skill_service/integrations/modash/types.py +189 -0
- skill_service/integrations/payments/alipay_notify.py +68 -0
- skill_service/integrations/payments/alipay_qr.py +79 -0
- skill_service/integrations/payments/base.py +23 -0
- skill_service/integrations/payments/config_status.py +98 -0
- skill_service/integrations/payments/mock_provider.py +33 -0
- skill_service/integrations/payments/notify_urls.py +12 -0
- skill_service/integrations/payments/pem_util.py +12 -0
- skill_service/integrations/payments/providers.py +174 -0
- skill_service/integrations/payments/stripe_checkout.py +65 -0
- skill_service/integrations/payments/stripe_notify.py +70 -0
- skill_service/integrations/payments/stripe_subscription.py +88 -0
- skill_service/integrations/payments/wechat_v3.py +83 -0
- skill_service/integrations/payments/wechat_v3_notify.py +100 -0
- skill_service/mcp/__init__.py +2 -0
- skill_service/mcp/match.py +51 -0
- skill_service/mcp/models/__init__.py +2 -0
- skill_service/mcp/models/common.py +13 -0
- skill_service/mcp/models/get_location_ids.py +12 -0
- skill_service/mcp/models/search_influencers.py +88 -0
- skill_service/mcp/server.py +35 -0
- skill_service/mcp/tools/__init__.py +10 -0
- skill_service/mcp/tools/common.py +21 -0
- skill_service/mcp/tools/get_location_ids.py +48 -0
- skill_service/mcp/tools/search_influencers.py +113 -0
- skill_service/portal/static/checkout.html +137 -0
- skill_service/portal/static/common.css +154 -0
- skill_service/portal/static/index.html +21 -0
- skill_service/portal/static/login.html +38 -0
- skill_service/portal/static/mock-checkout.html +37 -0
- skill_service/portal/static/recharge.html +12 -0
- skill_service/portal/static/subscription.html +330 -0
- skill_service/portal/static/tokens.html +138 -0
- skill_service/services/__init__.py +2 -0
- skill_service/services/admin_service.py +184 -0
- skill_service/services/auth_service.py +105 -0
- skill_service/services/influencer_search_executor.py +59 -0
- skill_service/services/influencer_search_filters.py +75 -0
- skill_service/services/location_service.py +28 -0
- skill_service/services/payment_checkout_service.py +104 -0
- skill_service/services/payment_security.py +29 -0
- skill_service/services/recharge_service.py +232 -0
- skill_service/services/search_service.py +129 -0
- skill_service/services/session_service.py +115 -0
- skill_service/services/subscription_errors.py +19 -0
- skill_service/services/subscription_service.py +1524 -0
- skill_service/services/token_service.py +101 -0
- skill_service/services/wallet_service.py +127 -0
- skill_service-0.2.0.dist-info/METADATA +73 -0
- skill_service-0.2.0.dist-info/RECORD +75 -0
- skill_service-0.2.0.dist-info/WHEEL +4 -0
- skill_service-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from fastapi import HTTPException, Request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_current_account(request: Request) -> dict:
|
|
5
|
+
account = getattr(request.state, "account", None) or {}
|
|
6
|
+
if not account.get("account_id"):
|
|
7
|
+
raise HTTPException(status_code=401, detail="skill token required")
|
|
8
|
+
return account
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def require_roles(request: Request, *roles: str) -> dict:
|
|
12
|
+
account = get_current_account(request)
|
|
13
|
+
if account.get("role") not in roles:
|
|
14
|
+
raise HTTPException(status_code=403, detail="insufficient role")
|
|
15
|
+
return account
|
|
16
|
+
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
from starlette.responses import JSONResponse, Response
|
|
8
|
+
|
|
9
|
+
from skill_service.config.settings import settings
|
|
10
|
+
from skill_service.db import verify_skill_token
|
|
11
|
+
from skill_service.services.session_service import session_service
|
|
12
|
+
from skill_service.services.token_service import token_service
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SkillAuthMiddleware(BaseHTTPMiddleware):
|
|
16
|
+
"""Auth gate for protected skill endpoints with DB-backed token checks."""
|
|
17
|
+
|
|
18
|
+
skill_only_prefixes = (
|
|
19
|
+
"/mcp",
|
|
20
|
+
"/api/v1/search",
|
|
21
|
+
"/api/v1/wallet",
|
|
22
|
+
"/api/v1/account/status",
|
|
23
|
+
)
|
|
24
|
+
protected_prefixes = (
|
|
25
|
+
*skill_only_prefixes,
|
|
26
|
+
"/api/v1/tokens",
|
|
27
|
+
"/api/v1/recharge",
|
|
28
|
+
"/api/v1/subscriptions",
|
|
29
|
+
"/api/v1/admin",
|
|
30
|
+
"/api/v1/portal",
|
|
31
|
+
)
|
|
32
|
+
session_allowed_prefixes = (
|
|
33
|
+
"/api/v1/tokens",
|
|
34
|
+
"/api/v1/recharge",
|
|
35
|
+
"/api/v1/subscriptions",
|
|
36
|
+
"/api/v1/admin",
|
|
37
|
+
"/api/v1/portal",
|
|
38
|
+
)
|
|
39
|
+
portal_session_get_paths = frozenset({
|
|
40
|
+
"/api/v1/wallet/balance",
|
|
41
|
+
"/api/v1/account/status",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
public_prefixes = (
|
|
45
|
+
"/portal",
|
|
46
|
+
"/health",
|
|
47
|
+
"/api/v1/auth/register",
|
|
48
|
+
"/api/v1/auth/login",
|
|
49
|
+
"/api/v1/payments",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
53
|
+
path = request.url.path
|
|
54
|
+
if path == "/api/v1/recharge/packages" and request.method.upper() == "GET":
|
|
55
|
+
return await call_next(request)
|
|
56
|
+
if path == "/api/v1/subscriptions/plans" and request.method.upper() == "GET":
|
|
57
|
+
return await call_next(request)
|
|
58
|
+
if any(path == p or path.startswith(f"{p}/") for p in self.public_prefixes):
|
|
59
|
+
return await call_next(request)
|
|
60
|
+
if not any(path == p or path.startswith(f"{p}/") for p in self.protected_prefixes):
|
|
61
|
+
return await call_next(request)
|
|
62
|
+
if request.method == "OPTIONS":
|
|
63
|
+
return await call_next(request)
|
|
64
|
+
|
|
65
|
+
auth = request.headers.get("authorization") or request.headers.get("Authorization")
|
|
66
|
+
if not auth:
|
|
67
|
+
return self._token_required_response()
|
|
68
|
+
parts = auth.split()
|
|
69
|
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
70
|
+
return self._token_required_response()
|
|
71
|
+
|
|
72
|
+
token = parts[1].strip()
|
|
73
|
+
_ = hashlib.sha256(token.encode("utf-8")).hexdigest()[:8]
|
|
74
|
+
|
|
75
|
+
if token.startswith(settings.session_token_prefix):
|
|
76
|
+
session_ok = any(path == p or path.startswith(f"{p}/") for p in self.session_allowed_prefixes)
|
|
77
|
+
if not session_ok and not (
|
|
78
|
+
request.method.upper() == "GET" and path in self.portal_session_get_paths
|
|
79
|
+
):
|
|
80
|
+
return self._token_required_response(
|
|
81
|
+
error_code="SKILL_TOKEN_REQUIRED",
|
|
82
|
+
message="Session token cannot access this endpoint",
|
|
83
|
+
)
|
|
84
|
+
account = await session_service.verify_session(token)
|
|
85
|
+
if not account:
|
|
86
|
+
return self._session_required_response()
|
|
87
|
+
request.state.account = account
|
|
88
|
+
return await call_next(request)
|
|
89
|
+
|
|
90
|
+
if not token.startswith(settings.skill_token_prefix):
|
|
91
|
+
return self._token_required_response()
|
|
92
|
+
|
|
93
|
+
account = await verify_skill_token(token)
|
|
94
|
+
if not account:
|
|
95
|
+
logger.warning("[SkillAuth] Invalid or expired token for path={}", path)
|
|
96
|
+
return self._token_required_response(error_code="TOKEN_REQUIRED", message="Invalid skill token")
|
|
97
|
+
if account.get("token_id"):
|
|
98
|
+
await token_service.touch_token_usage(account["token_id"])
|
|
99
|
+
request.state.account = account
|
|
100
|
+
return await call_next(request)
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _token_required_response(error_code: str = "TOKEN_REQUIRED", message: str = "Skill token required") -> Response:
|
|
104
|
+
payload: dict[str, Any] = {
|
|
105
|
+
"code": 401,
|
|
106
|
+
"message": message,
|
|
107
|
+
"data": {
|
|
108
|
+
"errorCode": error_code,
|
|
109
|
+
"portalUrl": "https://skill.deinai.ai",
|
|
110
|
+
"tokenCreateUrl": "https://skill.deinai.ai/settings/tokens",
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
return JSONResponse(status_code=401, content=payload)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _session_required_response() -> Response:
|
|
117
|
+
payload: dict[str, Any] = {
|
|
118
|
+
"code": 401,
|
|
119
|
+
"message": "Session expired or invalid",
|
|
120
|
+
"data": {
|
|
121
|
+
"errorCode": "SESSION_REQUIRED",
|
|
122
|
+
"loginUrl": "https://skill.deinai.ai/login",
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
return JSONResponse(status_code=401, content=payload)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
|
|
3
|
+
from .admin import router as admin_router
|
|
4
|
+
from .auth import router as auth_router
|
|
5
|
+
from .locations import router as locations_router
|
|
6
|
+
from .payments import router as payments_router
|
|
7
|
+
from .portal import router as portal_router
|
|
8
|
+
from .recharge import router as recharge_router
|
|
9
|
+
from .search import router as search_router
|
|
10
|
+
from .subscriptions import router as subscriptions_router
|
|
11
|
+
from .tokens import router as token_router
|
|
12
|
+
from .wallet import router as wallet_router
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_api_router() -> APIRouter:
|
|
16
|
+
router = APIRouter(prefix="/api/v1")
|
|
17
|
+
router.include_router(auth_router)
|
|
18
|
+
router.include_router(token_router)
|
|
19
|
+
router.include_router(wallet_router)
|
|
20
|
+
router.include_router(search_router)
|
|
21
|
+
router.include_router(locations_router)
|
|
22
|
+
router.include_router(recharge_router)
|
|
23
|
+
router.include_router(subscriptions_router)
|
|
24
|
+
router.include_router(payments_router)
|
|
25
|
+
router.include_router(portal_router)
|
|
26
|
+
router.include_router(admin_router)
|
|
27
|
+
return router
|
|
28
|
+
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from skill_service.api.authz import require_roles
|
|
7
|
+
from skill_service.integrations.payments.config_status import payment_config_status
|
|
8
|
+
from skill_service.services.admin_service import admin_service
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CreditPackageCreateRequest(BaseModel):
|
|
14
|
+
code: str = Field(min_length=1, max_length=32)
|
|
15
|
+
credits: int = Field(gt=0)
|
|
16
|
+
price_cents: int = Field(gt=0)
|
|
17
|
+
list_price_cents: int | None = Field(default=None, gt=0)
|
|
18
|
+
title: str | None = Field(default=None, max_length=120)
|
|
19
|
+
description: str | None = Field(default=None, max_length=500)
|
|
20
|
+
is_active: bool = True
|
|
21
|
+
effective_from: datetime | None = None
|
|
22
|
+
effective_to: datetime | None = None
|
|
23
|
+
reason: str | None = Field(default=None, max_length=200)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CreditPackageUpdateRequest(BaseModel):
|
|
27
|
+
credits: int | None = Field(default=None, gt=0)
|
|
28
|
+
price_cents: int | None = Field(default=None, gt=0)
|
|
29
|
+
list_price_cents: int | None = Field(default=None, gt=0)
|
|
30
|
+
title: str | None = Field(default=None, max_length=120)
|
|
31
|
+
description: str | None = Field(default=None, max_length=500)
|
|
32
|
+
is_active: bool | None = None
|
|
33
|
+
effective_from: datetime | None = None
|
|
34
|
+
effective_to: datetime | None = None
|
|
35
|
+
reason: str | None = Field(default=None, max_length=200)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConsumeRuleUpdateRequest(BaseModel):
|
|
39
|
+
unit_type: str | None = Field(default=None, max_length=32)
|
|
40
|
+
unit_price: int | None = Field(default=None, gt=0)
|
|
41
|
+
min_charge: int | None = Field(default=None, ge=0)
|
|
42
|
+
max_charge: int | None = Field(default=None, ge=0)
|
|
43
|
+
is_active: bool | None = None
|
|
44
|
+
effective_from: datetime | None = None
|
|
45
|
+
effective_to: datetime | None = None
|
|
46
|
+
reason: str | None = Field(default=None, max_length=200)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/credit-packages")
|
|
50
|
+
async def list_credit_packages(request: Request):
|
|
51
|
+
require_roles(request, "ops", "admin")
|
|
52
|
+
packages = await admin_service.list_credit_packages()
|
|
53
|
+
return {"code": 0, "message": "success", "data": {"packages": packages}}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.post("/credit-packages")
|
|
57
|
+
async def create_credit_package(payload: CreditPackageCreateRequest, request: Request):
|
|
58
|
+
account = require_roles(request, "admin")
|
|
59
|
+
created = await admin_service.create_credit_package(payload.model_dump(), operator=account)
|
|
60
|
+
return {"code": 0, "message": "success", "data": {"package": created}}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.patch("/credit-packages/{package_id}")
|
|
64
|
+
async def update_credit_package(package_id: str, payload: CreditPackageUpdateRequest, request: Request):
|
|
65
|
+
account = require_roles(request, "admin")
|
|
66
|
+
updated = await admin_service.update_credit_package(package_id, payload.model_dump(exclude_none=True), operator=account)
|
|
67
|
+
if not updated:
|
|
68
|
+
raise HTTPException(status_code=404, detail="credit package not found")
|
|
69
|
+
return {"code": 0, "message": "success", "data": {"package": updated}}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.get("/consume-rules")
|
|
73
|
+
async def list_consume_rules(request: Request):
|
|
74
|
+
require_roles(request, "ops", "admin")
|
|
75
|
+
rules = await admin_service.list_consume_rules()
|
|
76
|
+
return {"code": 0, "message": "success", "data": {"rules": rules}}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.patch("/consume-rules/{feature}")
|
|
80
|
+
async def update_consume_rule(feature: str, payload: ConsumeRuleUpdateRequest, request: Request):
|
|
81
|
+
account = require_roles(request, "admin")
|
|
82
|
+
updated = await admin_service.update_consume_rule(feature, payload.model_dump(exclude_none=True), operator=account)
|
|
83
|
+
if not updated:
|
|
84
|
+
raise HTTPException(status_code=404, detail="consume rule not found")
|
|
85
|
+
return {"code": 0, "message": "success", "data": {"rule": updated}}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.get("/payment/status")
|
|
89
|
+
async def payment_status(request: Request):
|
|
90
|
+
require_roles(request, "ops", "admin")
|
|
91
|
+
return {"code": 0, "message": "success", "data": payment_config_status()}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@router.get("/config-change-logs")
|
|
95
|
+
async def list_config_change_logs(
|
|
96
|
+
request: Request,
|
|
97
|
+
limit: int = Query(default=100, ge=1, le=200),
|
|
98
|
+
offset: int = Query(default=0, ge=0),
|
|
99
|
+
):
|
|
100
|
+
require_roles(request, "ops", "admin")
|
|
101
|
+
logs = await admin_service.list_config_change_logs(limit=limit, offset=offset)
|
|
102
|
+
return {"code": 0, "message": "success", "data": {"logs": logs}}
|
|
103
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from pydantic import BaseModel, EmailStr, Field
|
|
2
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
3
|
+
|
|
4
|
+
from skill_service.services.auth_service import auth_service
|
|
5
|
+
from skill_service.services.session_service import session_service
|
|
6
|
+
|
|
7
|
+
router = APIRouter(tags=["auth"])
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RegisterRequest(BaseModel):
|
|
11
|
+
email: EmailStr
|
|
12
|
+
password: str = Field(min_length=8, max_length=128)
|
|
13
|
+
display_name: str | None = Field(default=None, max_length=64)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoginRequest(BaseModel):
|
|
17
|
+
email: EmailStr
|
|
18
|
+
password: str = Field(min_length=8, max_length=128)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.post("/auth/register")
|
|
22
|
+
async def register(payload: RegisterRequest):
|
|
23
|
+
try:
|
|
24
|
+
account = await auth_service.register(
|
|
25
|
+
email=payload.email,
|
|
26
|
+
password=payload.password,
|
|
27
|
+
display_name=payload.display_name,
|
|
28
|
+
)
|
|
29
|
+
except Exception as e:
|
|
30
|
+
raise HTTPException(status_code=400, detail=f"register failed: {e}")
|
|
31
|
+
return {
|
|
32
|
+
"code": 0,
|
|
33
|
+
"message": "success",
|
|
34
|
+
"data": {
|
|
35
|
+
"accountId": account.account_id,
|
|
36
|
+
"email": account.email,
|
|
37
|
+
"displayName": account.display_name,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("/auth/login")
|
|
43
|
+
async def login(payload: LoginRequest):
|
|
44
|
+
account = await auth_service.login(email=payload.email, password=payload.password)
|
|
45
|
+
if not account:
|
|
46
|
+
raise HTTPException(status_code=401, detail="invalid credentials")
|
|
47
|
+
role = await auth_service.get_account_role(account.account_id)
|
|
48
|
+
session = await session_service.create_session(
|
|
49
|
+
account_id=account.account_id,
|
|
50
|
+
email=account.email,
|
|
51
|
+
display_name=account.display_name,
|
|
52
|
+
role=role,
|
|
53
|
+
)
|
|
54
|
+
return {
|
|
55
|
+
"code": 0,
|
|
56
|
+
"message": "success",
|
|
57
|
+
"data": {
|
|
58
|
+
"accountId": account.account_id,
|
|
59
|
+
"email": account.email,
|
|
60
|
+
"displayName": account.display_name,
|
|
61
|
+
"role": role,
|
|
62
|
+
"sessionToken": session.session_token,
|
|
63
|
+
"expiresAt": session.expires_at.isoformat(),
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.post("/auth/logout")
|
|
69
|
+
async def logout(request: Request):
|
|
70
|
+
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
|
71
|
+
parts = auth.split()
|
|
72
|
+
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
73
|
+
await session_service.revoke_by_token(parts[1].strip())
|
|
74
|
+
return {"code": 0, "message": "success", "data": {"loggedOut": True}}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
from skill_service.api.authz import get_current_account
|
|
5
|
+
from skill_service.mcp.models.get_location_ids import GetLocationsData, GetLocationsResponse
|
|
6
|
+
from skill_service.mcp.models.search_influencers import PlatformName
|
|
7
|
+
from skill_service.mcp.tools.get_location_ids import _validate_platform
|
|
8
|
+
from skill_service.services.location_service import location_service
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/search", tags=["search"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ResolveLocationsRequest(BaseModel):
|
|
14
|
+
platform: PlatformName
|
|
15
|
+
location_text: list[str] = Field(min_length=1, alias="locationText")
|
|
16
|
+
|
|
17
|
+
model_config = {"populate_by_name": True}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.post("/locations")
|
|
21
|
+
async def resolve_locations(payload: ResolveLocationsRequest, request: Request) -> GetLocationsResponse:
|
|
22
|
+
account = get_current_account(request)
|
|
23
|
+
if account.get("token_type") == "session":
|
|
24
|
+
return GetLocationsResponse(
|
|
25
|
+
code=401,
|
|
26
|
+
message="Skill token required",
|
|
27
|
+
data=GetLocationsData(locationIds=[]),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
platform_value = _validate_platform(payload.platform)
|
|
31
|
+
cleaned = [s.strip() for s in payload.location_text if s and s.strip()]
|
|
32
|
+
if not cleaned:
|
|
33
|
+
return GetLocationsResponse(code=0, message="success", data=GetLocationsData(locationIds=[]))
|
|
34
|
+
|
|
35
|
+
location_ids = await location_service.resolve_location_ids(platform_value, cleaned)
|
|
36
|
+
return GetLocationsResponse(code=0, message="success", data=GetLocationsData(locationIds=location_ids))
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from fastapi import APIRouter, Header, Request
|
|
2
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from skill_service.integrations.payments.alipay_notify import parse_alipay_notify, parse_form_body, verify_alipay_rsa2
|
|
8
|
+
from skill_service.integrations.payments.stripe_notify import (
|
|
9
|
+
StripeWebhookEvent,
|
|
10
|
+
parse_checkout_payment_completed,
|
|
11
|
+
parse_stripe_webhook,
|
|
12
|
+
)
|
|
13
|
+
from skill_service.integrations.payments.wechat_v3_notify import parse_wechat_v3_notify, verify_wechat_v3_signature
|
|
14
|
+
from skill_service.services.payment_security import verify_alipay_notify, verify_wechat_notify
|
|
15
|
+
from skill_service.services.recharge_service import recharge_service
|
|
16
|
+
from skill_service.services.subscription_service import (
|
|
17
|
+
_invoice_subscription_id,
|
|
18
|
+
subscription_service,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
router = APIRouter(tags=["payments"])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.post("/payments/wechat/notify")
|
|
25
|
+
async def wechat_notify(
|
|
26
|
+
request: Request,
|
|
27
|
+
wechatpay_signature: str | None = Header(default=None, alias="Wechatpay-Signature"),
|
|
28
|
+
wechatpay_timestamp: str | None = Header(default=None, alias="Wechatpay-Timestamp"),
|
|
29
|
+
wechatpay_nonce: str | None = Header(default=None, alias="Wechatpay-Nonce"),
|
|
30
|
+
x_wechat_signature: str | None = Header(default=None, alias="X-Wechat-Signature"),
|
|
31
|
+
):
|
|
32
|
+
raw_body = (await request.body()).decode("utf-8")
|
|
33
|
+
try:
|
|
34
|
+
payload = json.loads(raw_body) if raw_body else {}
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
return JSONResponse(status_code=400, content={"code": "FAIL", "message": "invalid json"})
|
|
37
|
+
|
|
38
|
+
if wechatpay_signature and wechatpay_timestamp and wechatpay_nonce:
|
|
39
|
+
if not verify_wechat_v3_signature(
|
|
40
|
+
timestamp=wechatpay_timestamp,
|
|
41
|
+
nonce=wechatpay_nonce,
|
|
42
|
+
body=raw_body,
|
|
43
|
+
signature=wechatpay_signature,
|
|
44
|
+
):
|
|
45
|
+
return JSONResponse(status_code=401, content={"code": "FAIL", "message": "invalid signature"})
|
|
46
|
+
elif not verify_wechat_notify(payload, x_wechat_signature or ""):
|
|
47
|
+
return JSONResponse(status_code=401, content={"code": "FAIL", "message": "invalid signature"})
|
|
48
|
+
|
|
49
|
+
parsed = parse_wechat_v3_notify(payload)
|
|
50
|
+
if not parsed:
|
|
51
|
+
logger.info("[Wechat notify] ignored event: {}", payload.get("event_type"))
|
|
52
|
+
return JSONResponse(content={"code": "SUCCESS", "message": "成功"})
|
|
53
|
+
|
|
54
|
+
ok = await recharge_service.mark_order_paid(
|
|
55
|
+
order_no=parsed["order_no"],
|
|
56
|
+
provider_txn_id=parsed["provider_txn_id"],
|
|
57
|
+
channel="wechat",
|
|
58
|
+
notify_payload=parsed["raw"],
|
|
59
|
+
)
|
|
60
|
+
if not ok:
|
|
61
|
+
return JSONResponse(status_code=404, content={"code": "FAIL", "message": "order not found"})
|
|
62
|
+
return JSONResponse(content={"code": "SUCCESS", "message": "成功"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.post("/payments/alipay/notify")
|
|
66
|
+
async def alipay_notify(
|
|
67
|
+
request: Request,
|
|
68
|
+
x_alipay_signature: str | None = Header(default=None, alias="X-Alipay-Signature"),
|
|
69
|
+
):
|
|
70
|
+
raw = await request.body()
|
|
71
|
+
content_type = (request.headers.get("content-type") or "").lower()
|
|
72
|
+
|
|
73
|
+
if "application/json" in content_type:
|
|
74
|
+
try:
|
|
75
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
return PlainTextResponse("fail", status_code=400)
|
|
78
|
+
if not verify_alipay_notify(payload, x_alipay_signature or ""):
|
|
79
|
+
return PlainTextResponse("fail", status_code=401)
|
|
80
|
+
order_no = str(payload.get("order_no") or payload.get("out_trade_no") or "")
|
|
81
|
+
provider_txn_id = str(payload.get("trade_no") or payload.get("transactionId") or "")
|
|
82
|
+
notify_payload = payload
|
|
83
|
+
else:
|
|
84
|
+
form = parse_form_body(raw)
|
|
85
|
+
if not form:
|
|
86
|
+
try:
|
|
87
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
88
|
+
form = {k: str(v) for k, v in payload.items()}
|
|
89
|
+
except Exception:
|
|
90
|
+
return PlainTextResponse("fail", status_code=400)
|
|
91
|
+
if not verify_alipay_rsa2(form):
|
|
92
|
+
return PlainTextResponse("fail", status_code=401)
|
|
93
|
+
parsed = parse_alipay_notify(form)
|
|
94
|
+
if not parsed:
|
|
95
|
+
return PlainTextResponse("success")
|
|
96
|
+
order_no = parsed["order_no"]
|
|
97
|
+
provider_txn_id = parsed["provider_txn_id"]
|
|
98
|
+
notify_payload = parsed["raw"]
|
|
99
|
+
|
|
100
|
+
if not order_no or not provider_txn_id:
|
|
101
|
+
return PlainTextResponse("fail", status_code=400)
|
|
102
|
+
|
|
103
|
+
ok = await recharge_service.mark_order_paid(
|
|
104
|
+
order_no=order_no,
|
|
105
|
+
provider_txn_id=provider_txn_id,
|
|
106
|
+
channel="alipay",
|
|
107
|
+
notify_payload=notify_payload if isinstance(notify_payload, dict) else {},
|
|
108
|
+
)
|
|
109
|
+
return PlainTextResponse("success" if ok else "fail", status_code=200 if ok else 404)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.post("/payments/stripe/notify")
|
|
113
|
+
async def stripe_notify(
|
|
114
|
+
request: Request,
|
|
115
|
+
stripe_signature: str | None = Header(default=None, alias="Stripe-Signature"),
|
|
116
|
+
):
|
|
117
|
+
raw = await request.body()
|
|
118
|
+
try:
|
|
119
|
+
event = parse_stripe_webhook(raw, stripe_signature or "")
|
|
120
|
+
except ValueError as e:
|
|
121
|
+
logger.warning("[Stripe notify] invalid webhook: {}", e)
|
|
122
|
+
return JSONResponse(status_code=400, content={"error": str(e)})
|
|
123
|
+
|
|
124
|
+
handled = await _dispatch_stripe_event(event)
|
|
125
|
+
if handled is None:
|
|
126
|
+
return JSONResponse(content={"received": True})
|
|
127
|
+
if not handled:
|
|
128
|
+
return JSONResponse(status_code=404, content={"error": "not found"})
|
|
129
|
+
return JSONResponse(content={"received": True})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _dispatch_stripe_event(event: StripeWebhookEvent) -> bool | None:
|
|
133
|
+
et = event.event_type
|
|
134
|
+
payload = event.payload
|
|
135
|
+
|
|
136
|
+
if et == "checkout.session.completed":
|
|
137
|
+
if payload.get("mode") == "subscription":
|
|
138
|
+
ok = await subscription_service.activate_from_checkout_session(payload)
|
|
139
|
+
return ok
|
|
140
|
+
parsed = parse_checkout_payment_completed(payload)
|
|
141
|
+
if not parsed:
|
|
142
|
+
return None
|
|
143
|
+
return await recharge_service.mark_order_paid(
|
|
144
|
+
order_no=parsed["order_no"],
|
|
145
|
+
provider_txn_id=parsed["provider_txn_id"],
|
|
146
|
+
channel="stripe",
|
|
147
|
+
notify_payload=parsed["raw"],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if et == "checkout.session.expired":
|
|
151
|
+
if payload.get("mode") == "subscription":
|
|
152
|
+
return await subscription_service.handle_checkout_session_terminal(payload, terminal_status="expired")
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
if et == "checkout.session.async_payment_failed":
|
|
156
|
+
if payload.get("mode") == "subscription":
|
|
157
|
+
return await subscription_service.handle_checkout_session_terminal(payload, terminal_status="failed")
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
if et == "invoice.paid":
|
|
161
|
+
return await subscription_service.handle_invoice_paid(payload)
|
|
162
|
+
|
|
163
|
+
if et == "invoice.payment_failed":
|
|
164
|
+
sub_id = _invoice_subscription_id(payload) or payload.get("subscription")
|
|
165
|
+
order_no = None
|
|
166
|
+
metadata = payload.get("metadata") or {}
|
|
167
|
+
if metadata.get("order_no"):
|
|
168
|
+
order_no = str(metadata["order_no"])
|
|
169
|
+
if order_no:
|
|
170
|
+
await subscription_service.mark_subscription_order_terminal(order_no=order_no, status="failed")
|
|
171
|
+
if sub_id:
|
|
172
|
+
await subscription_service.handle_subscription_updated({"id": str(sub_id), "status": "past_due"})
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
if et == "customer.subscription.updated":
|
|
176
|
+
return await subscription_service.handle_subscription_updated(payload)
|
|
177
|
+
|
|
178
|
+
if et == "customer.subscription.deleted":
|
|
179
|
+
return await subscription_service.handle_subscription_deleted(payload)
|
|
180
|
+
|
|
181
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from uuid import uuid4
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
4
|
+
|
|
5
|
+
from skill_service.config.settings import settings
|
|
6
|
+
from skill_service.services.recharge_service import recharge_service
|
|
7
|
+
|
|
8
|
+
router = APIRouter(tags=["portal"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.post("/portal/mock-pay/{order_no}")
|
|
12
|
+
async def portal_mock_pay(order_no: str, request: Request):
|
|
13
|
+
if settings.payment_mode != "mock":
|
|
14
|
+
raise HTTPException(status_code=403, detail="mock pay only available in PAYMENT_MODE=mock")
|
|
15
|
+
|
|
16
|
+
account = getattr(request.state, "account", None) or {}
|
|
17
|
+
account_id = account.get("account_id")
|
|
18
|
+
if not account_id:
|
|
19
|
+
return {"code": 401, "message": "login required", "data": {"errorCode": "SESSION_REQUIRED"}}
|
|
20
|
+
|
|
21
|
+
order = await recharge_service.get_order(account_id=account_id, order_no=order_no)
|
|
22
|
+
if not order:
|
|
23
|
+
raise HTTPException(status_code=404, detail="order not found")
|
|
24
|
+
if order["status"] == "paid":
|
|
25
|
+
return {"code": 0, "message": "already paid", "data": {"orderNo": order_no, "status": "paid"}}
|
|
26
|
+
|
|
27
|
+
channel = order.get("channel") or "wechat"
|
|
28
|
+
txn_id = f"mock_portal_{uuid4().hex[:12]}"
|
|
29
|
+
ok = await recharge_service.mark_order_paid(
|
|
30
|
+
order_no=order_no,
|
|
31
|
+
provider_txn_id=txn_id,
|
|
32
|
+
channel=str(channel),
|
|
33
|
+
notify_payload={"source": "portal_mock_pay", "orderNo": order_no},
|
|
34
|
+
)
|
|
35
|
+
if not ok:
|
|
36
|
+
raise HTTPException(status_code=400, detail="payment failed")
|
|
37
|
+
return {"code": 0, "message": "success", "data": {"orderNo": order_no, "status": "paid"}}
|