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.
Files changed (75) hide show
  1. skill_service/__init__.py +2 -0
  2. skill_service/api/__init__.py +2 -0
  3. skill_service/api/authz.py +16 -0
  4. skill_service/api/middleware/__init__.py +2 -0
  5. skill_service/api/middleware/skill_auth.py +125 -0
  6. skill_service/api/routes/__init__.py +28 -0
  7. skill_service/api/routes/admin.py +103 -0
  8. skill_service/api/routes/auth.py +74 -0
  9. skill_service/api/routes/locations.py +36 -0
  10. skill_service/api/routes/payments.py +181 -0
  11. skill_service/api/routes/portal.py +37 -0
  12. skill_service/api/routes/recharge.py +66 -0
  13. skill_service/api/routes/search.py +106 -0
  14. skill_service/api/routes/subscriptions.py +179 -0
  15. skill_service/api/routes/tokens.py +67 -0
  16. skill_service/api/routes/wallet.py +90 -0
  17. skill_service/app.py +62 -0
  18. skill_service/config/__init__.py +2 -0
  19. skill_service/config/settings.py +66 -0
  20. skill_service/db.py +72 -0
  21. skill_service/integrations/__init__.py +2 -0
  22. skill_service/integrations/modash/__init__.py +2 -0
  23. skill_service/integrations/modash/client.py +119 -0
  24. skill_service/integrations/modash/types.py +189 -0
  25. skill_service/integrations/payments/alipay_notify.py +68 -0
  26. skill_service/integrations/payments/alipay_qr.py +79 -0
  27. skill_service/integrations/payments/base.py +23 -0
  28. skill_service/integrations/payments/config_status.py +98 -0
  29. skill_service/integrations/payments/mock_provider.py +33 -0
  30. skill_service/integrations/payments/notify_urls.py +12 -0
  31. skill_service/integrations/payments/pem_util.py +12 -0
  32. skill_service/integrations/payments/providers.py +174 -0
  33. skill_service/integrations/payments/stripe_checkout.py +65 -0
  34. skill_service/integrations/payments/stripe_notify.py +70 -0
  35. skill_service/integrations/payments/stripe_subscription.py +88 -0
  36. skill_service/integrations/payments/wechat_v3.py +83 -0
  37. skill_service/integrations/payments/wechat_v3_notify.py +100 -0
  38. skill_service/mcp/__init__.py +2 -0
  39. skill_service/mcp/match.py +51 -0
  40. skill_service/mcp/models/__init__.py +2 -0
  41. skill_service/mcp/models/common.py +13 -0
  42. skill_service/mcp/models/get_location_ids.py +12 -0
  43. skill_service/mcp/models/search_influencers.py +88 -0
  44. skill_service/mcp/server.py +35 -0
  45. skill_service/mcp/tools/__init__.py +10 -0
  46. skill_service/mcp/tools/common.py +21 -0
  47. skill_service/mcp/tools/get_location_ids.py +48 -0
  48. skill_service/mcp/tools/search_influencers.py +113 -0
  49. skill_service/portal/static/checkout.html +137 -0
  50. skill_service/portal/static/common.css +154 -0
  51. skill_service/portal/static/index.html +21 -0
  52. skill_service/portal/static/login.html +38 -0
  53. skill_service/portal/static/mock-checkout.html +37 -0
  54. skill_service/portal/static/recharge.html +12 -0
  55. skill_service/portal/static/subscription.html +330 -0
  56. skill_service/portal/static/tokens.html +138 -0
  57. skill_service/services/__init__.py +2 -0
  58. skill_service/services/admin_service.py +184 -0
  59. skill_service/services/auth_service.py +105 -0
  60. skill_service/services/influencer_search_executor.py +59 -0
  61. skill_service/services/influencer_search_filters.py +75 -0
  62. skill_service/services/location_service.py +28 -0
  63. skill_service/services/payment_checkout_service.py +104 -0
  64. skill_service/services/payment_security.py +29 -0
  65. skill_service/services/recharge_service.py +232 -0
  66. skill_service/services/search_service.py +129 -0
  67. skill_service/services/session_service.py +115 -0
  68. skill_service/services/subscription_errors.py +19 -0
  69. skill_service/services/subscription_service.py +1524 -0
  70. skill_service/services/token_service.py +101 -0
  71. skill_service/services/wallet_service.py +127 -0
  72. skill_service-0.2.0.dist-info/METADATA +73 -0
  73. skill_service-0.2.0.dist-info/RECORD +75 -0
  74. skill_service-0.2.0.dist-info/WHEEL +4 -0
  75. skill_service-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,2 @@
1
+ """Standalone Creator SKILL service package."""
2
+
@@ -0,0 +1,2 @@
1
+ """HTTP API package."""
2
+
@@ -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,2 @@
1
+ """HTTP middlewares."""
2
+
@@ -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"}}