diff --git a/.env.example b/.env.example index fbfd5863..6faffedd 100644 --- a/.env.example +++ b/.env.example @@ -117,6 +117,17 @@ WINDUP_CORS_ORIGIN_REGEX= # 管理员邮箱白名单,逗号分隔;生产环境在私有 .env 中填写真实邮箱 WINDUP_ADMIN_EMAILS=admin@example.com +# ── 计费(易支付网关) ───────────────────────────────────────────── +# 商户 ID 与密钥。api_url 是网关的站点根地址(下单拼 /submit.php、查询拼 /api.php), +# 必须指向真实网关域名——留空会回落占位域名 pay.example.com,下单将不可用。 +WINDUP_BILL_EPAY_PID=0 +WINDUP_BILL_EPAY_PRIVATE_KEY= +WINDUP_BILL_EPAY_PUBLIC_KEY= +WINDUP_BILL_EPAY_API_URL=https://pay.example.com +# 异步/同步回调地址,指向部署后端的可公网访问域名 +WINDUP_BILL_EPAY_NOTIFY_URL= +WINDUP_BILL_EPAY_RETURN_URL= + # ── MQ(Redis Stream 轻量消息队列) ── # 本地/Compose 须同时起 web + worker,否则生成任务会一直 PENDING、邮件不会发出。 # docker compose up -d backend worker diff --git a/.gitignore b/.gitignore index 8757e93e..56c69fe6 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ htmlcov/ # 本地数据库初始化脚本 init.sql +.npm-cache/ diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 74aec2f1..ea49cb91 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -24,6 +24,7 @@ from windup_app.server.project.model import Project # noqa: F401 from windup_app.server.project.service import service as project_service from windup_app.server.quota import model as quota_model # noqa: F401 +from windup_app.server.bill import model as bill_model # noqa: F401 from windup_app.server.sensitive_word.model import SensitiveWord # noqa: F401 from windup_app.server.sensitive_word.seed import seed_sensitive_words from windup_app.server.sensitive_word.service import ( @@ -46,6 +47,8 @@ from windup_app.web.api.pixel_perfect import router as pixel_perfect_router from windup_app.web.api.project import router as project_router from windup_app.web.api.quota import router as quota_router +from windup_app.web.api.bill import router as bill_router +from windup_app.web.api.bill_notify import router as bill_notify_router from windup_app.web.api.render3d import router as render3d_router from windup_app.web.api.workflow_run import router as workflow_run_router from windup_app.web.handler.exception_handlers import register_exception_handlers @@ -116,6 +119,79 @@ async def _lifespan(app: FastAPI): session.close() print_banner() from windup_app.web.api.generation import event_bus + from windup_app.server.bill.service import service as bill_service + import asyncio + import time + from windup_framework.db.redis import get_redis + + # 关单重试间隔(秒):unknown 结果延后重试的间隔 + _CLOSE_RETRY_DELAY = 600 + + async def _bill_pending_close_worker(): + """Polling bill:pending_close zset for expired orders. + + bump-then-process:取出 order_no 后先把 score 推到 now+600s,只有终态 + (closed/paid/skipped)才 zrem。崩溃最多延迟 600s 重试不丢单,兼治多 + 实例重复处理(score>now 不会被再次选出)。每单处理体包 to_thread, + 同步 httpx + DB 不再阻塞事件循环。 + """ + logger = logging.getLogger("windup.bill.worker.pending_close") + logger.info("Bill pending close worker started.") + while True: + try: + now_ts = int(time.time()) + redis_client = get_redis() + orders = redis_client.zrangebyscore("bill:pending_close", 0, now_ts) + for order_no in orders: + if isinstance(order_no, bytes): + order_no = order_no.decode("utf-8") + # 先 bump score,防止崩溃丢单与多实例重复处理 + redis_client.zadd( + "bill:pending_close", + {order_no: now_ts + _CLOSE_RETRY_DELAY}, + ) + try: + result = await asyncio.to_thread( + _close_one_order, order_no + ) + if result in ("closed", "paid", "skipped"): + redis_client.zrem("bill:pending_close", order_no) + except Exception as e: + logger.error(f"Error closing order {order_no}: {e}") + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in bill pending close worker: {e}") + await asyncio.sleep(10) + + def _close_one_order(order_no: str) -> str: + """在线程池中处理单个订单的关单逻辑。""" + with SessionLocal() as db_session: + result = bill_service.close_expired_orders(db_session, order_no) + db_session.commit() + return result + + async def _subscription_expired_worker(): + """Polling expired subscriptions.""" + logger = logging.getLogger("windup.bill.worker.subscription") + logger.info("Subscription expiration worker started.") + while True: + try: + await asyncio.to_thread(_process_subscriptions_once) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error processing expired subscriptions: {e}") + await asyncio.sleep(60) + + def _process_subscriptions_once() -> None: + """在线程池中处理一轮订阅过期。""" + with SessionLocal() as db_session: + bill_service.process_expired_subscriptions(db_session) + db_session.commit() + + bill_close_task = asyncio.create_task(_bill_pending_close_worker()) + sub_expire_task = asyncio.create_task(_subscription_expired_worker()) sse_subscriber = RedisTaskEventSubscriber( lambda project_id, task_id, event, data: event_bus.publish( @@ -131,6 +207,9 @@ async def _lifespan(app: FastAPI): try: yield finally: + bill_close_task.cancel() + sub_expire_task.cancel() + # await asyncio.gather(bill_close_task, sub_expire_task, return_exceptions=True) sensitive_word_subscriber.stop() sse_subscriber.stop() @@ -177,6 +256,8 @@ def health() -> dict[str, str]: app.include_router(generation_router) app.include_router(quota_router) app.include_router(admin_quota_router) + app.include_router(bill_router) + app.include_router(bill_notify_router) app.include_router(render3d_router) app.include_router(agent_router) app.include_router(action_preset_router) diff --git a/backend/packages/app/src/windup_app/server/bill/__init__.py b/backend/packages/app/src/windup_app/server/bill/__init__.py new file mode 100644 index 00000000..b7ca59cb --- /dev/null +++ b/backend/packages/app/src/windup_app/server/bill/__init__.py @@ -0,0 +1,44 @@ +"""支付与账单领域模块。""" + +from windup_app.server.bill.epay import EpayProvider +from windup_app.server.bill.interface import BillService +from windup_app.server.bill.model import ( + Order, + PaymentEvent, + Product, + Refund, + Subscription, +) +from windup_app.server.bill.provider import ( + PaymentProvider, + ProviderCreateParams, + ProviderCreateResult, + ProviderNotifyResult, + ProviderQueryResult, + get_provider, + register_provider, +) +from windup_app.server.bill.service import ( + SqlAlchemyBillService, + service, +) + +__all__ = [ + "BillService", + "SqlAlchemyBillService", + "service", + "Product", + "Order", + "PaymentEvent", + "Subscription", + "Refund", + "PaymentProvider", + "ProviderCreateParams", + "ProviderCreateResult", + "ProviderNotifyResult", + "ProviderQueryResult", + "EpayProvider", + "register_provider", + "get_provider", +] + diff --git a/backend/packages/app/src/windup_app/server/bill/epay.py b/backend/packages/app/src/windup_app/server/bill/epay.py new file mode 100644 index 00000000..ad5ead53 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/bill/epay.py @@ -0,0 +1,217 @@ +"""易支付 (Epay) 支付提供商实现。 + +支持支付宝和微信支付等渠道的即时到账接口,使用参数签名进行身份认证与数据防篡改校验。 +""" + +import hashlib +from typing import Any +import urllib.parse + +import httpx + +from windup_app.server.bill.provider import ( + PaymentProvider, + ProviderCreateParams, + ProviderCreateResult, + ProviderNotifyResult, + ProviderQueryResult, + register_provider, +) +from windup_common.enums.bill import PaymentProviderType +from windup_framework.config.bill import settings as bill_settings + +# 未配置 WINDUP_BILL_EPAY_API_URL 时的回落地址(测试环境)。 +# 生产部署必须配置真实网关,否则下单不可用。 +_FALLBACK_API_URL = "https://pay.example.com" + + +class EpayProvider(PaymentProvider): + """易支付通道实现。""" + + def __init__( + self, + pid: int | None = None, + key: str | None = None, + notify_url: str | None = None, + return_url: str | None = None, + api_url: str | None = None, + ) -> None: + self.pid = pid if pid is not None else bill_settings.epay_pid + self.key = key if key is not None else (bill_settings.epay_private_key or bill_settings.epay_public_key) + self.notify_url = notify_url if notify_url is not None else bill_settings.epay_notify_url + self.return_url = return_url if return_url is not None else bill_settings.epay_return_url + resolved_api_url = api_url if api_url is not None else (bill_settings.epay_api_url or _FALLBACK_API_URL) + self.api_url = resolved_api_url.rstrip("/") + + @staticmethod + def sign_params(params: dict[str, Any], key: str) -> str: + """根据易支付签名规则生成签名。 + + 规则: + 1. 筛选出所有非空且不为 sign、sign_type 的键值对; + 2. 按照字典序升序对键进行排序; + 3. 用 key1=val1&key2=val2 拼接成待签名字符串; + 4. 待签名字符串末尾拼接 key 密钥(即 string + key); + 5. 对完整拼接字符串进行 MD5 哈希计算,输出小写 32 位 hex。 + """ + filtered_items = [ + (k, str(v)) + for k, v in params.items() + if k not in ("sign", "sign_type") and v is not None and str(v) != "" + ] + filtered_items.sort(key=lambda x: x[0]) + sign_str = "&".join(f"{k}={v}" for k, v in filtered_items) + return hashlib.md5((sign_str + key).encode("utf-8")).hexdigest() + + def create_order(self, params: ProviderCreateParams) -> ProviderCreateResult: + """构建易支付收银台跳转 URL。""" + # 金额转换为元,保留两位小数 + money_yuan = f"{params.amount_fen / 100:.2f}" + data = { + "pid": self.pid, + "type": params.channel, + "out_trade_no": params.order_no, + "notify_url": params.notify_url or self.notify_url, + "return_url": params.return_url or self.return_url, + "name": params.name or f"Order_{params.order_no}", + "money": money_yuan, + "clientip": params.client_ip, + } + sign = self.sign_params(data, self.key) + data["sign"] = sign + data["sign_type"] = "MD5" + + query_string = urllib.parse.urlencode(data) + pay_url = f"{self.api_url}/submit.php?{query_string}" + return ProviderCreateResult( + order_no=params.order_no, + pay_url=pay_url, + provider_trade_no=None, + raw_data=data, + ) + + def verify_notify(self, payload: dict[str, Any]) -> ProviderNotifyResult: + """验证易支付异步回调通知。 + + :raises ValueError: 签名不匹配或缺少关键参数。 + """ + sign_received = payload.get("sign") + if not sign_received: + raise ValueError("Missing sign parameter in notify payload") + + calculated_sign = self.sign_params(payload, self.key) + if calculated_sign.lower() != str(sign_received).lower(): + raise ValueError("Invalid epay notify signature") + + order_no = str(payload.get("out_trade_no", "")) + provider_trade_no = str(payload.get("trade_no", "")) + trade_status = str(payload.get("trade_status", "")) + + # 金额由字符串转换为分 + money_str = payload.get("money") + amount_fen = int(round(float(money_str) * 100)) if money_str is not None else 0 + + return ProviderNotifyResult( + order_no=order_no, + provider_trade_no=provider_trade_no, + amount_fen=amount_fen, + status=trade_status, + raw_payload=payload, + ) + + def query_order(self, order_no: str) -> ProviderQueryResult: + """主动向上游网关查询订单支付结果。 + + 各易支付网关 ``api.php?act=order`` 响应契约不完全一致(字段名 money/status/ + trade_status 混用),因此只在能明确判定时返回 paid/unpaid/not_found, + 其余一律 unknown —— 调用方在 unknown 下不得迁移订单终态。 + """ + params = { + "act": "order", + "pid": self.pid, + "out_trade_no": order_no, + } + params["sign"] = self.sign_params(params, self.key) + params["sign_type"] = "MD5" + try: + with httpx.Client(timeout=5.0) as client: + resp = client.get( + f"{self.api_url}/api.php", + params={k: v for k, v in params.items() if k != "act"}, + ) + if resp.status_code != 200: + return self._unknown_result(order_no, {"http_status": resp.status_code}) + try: + data = resp.json() + except ValueError: + return self._unknown_result(order_no, {"body": resp.text[:500]}) + except Exception as exc: + return self._unknown_result(order_no, {"error": repr(exc)}) + + code = data.get("code") + if code not in (0, 1, "0", "1"): + # 网关返回业务错误码:-1 等通常代表订单不存在,但各网关语义不一,统一 not_found + # 之外的错误(签名失败、系统异常)无法区分,保守返回 unknown。 + return self._unknown_result(order_no, data) + if code in (0, "0"): + return ProviderQueryResult( + order_no=order_no, + provider_trade_no=None, + amount_fen=0, + status="not_found", + raw_data=data, + ) + + trade_status = str(data.get("trade_status") or data.get("status") or "").upper() + provider_trade_no = data.get("trade_no") + money = data.get("money") + amount_fen = int(round(float(money) * 100)) if money is not None else 0 + + if trade_status in ("TRADE_SUCCESS", "SUCCESS") or data.get("status") == 1: + return ProviderQueryResult( + order_no=order_no, + provider_trade_no=str(provider_trade_no) if provider_trade_no else None, + amount_fen=amount_fen, + status="paid", + raw_data=data, + ) + if trade_status in ("TRADE_PENDING", "WAIT_BUYER_PAY", "UNPAY", "NOTPAY", "WAIT"): + return ProviderQueryResult( + order_no=order_no, + provider_trade_no=str(provider_trade_no) if provider_trade_no else None, + amount_fen=amount_fen, + status="unpaid", + raw_data=data, + ) + return self._unknown_result(order_no, data) + + @staticmethod + def _unknown_result(order_no: str, raw: dict[str, Any]) -> ProviderQueryResult: + return ProviderQueryResult( + order_no=order_no, + provider_trade_no=None, + amount_fen=0, + status="unknown", + raw_data=raw, + ) + + def close_order(self, order_no: str) -> bool: + """尝试向上游易支付网关发起关单(尽力而为)。""" + # 易支付通用接口:部分对接网关提供 api.php?act=orders 或关单接口,若失败返回 False + try: + with httpx.Client(timeout=5.0) as client: + data = { + "act": "close", + "pid": self.pid, + "out_trade_no": order_no, + } + data["sign"] = self.sign_params(data, self.key) + data["sign_type"] = "MD5" + resp = client.post(f"{self.api_url}/api.php", data=data) + return resp.status_code == 200 + except Exception: + return False + + +# 注册默认 Epay 单例 +register_provider(PaymentProviderType.EPAY, EpayProvider()) diff --git a/backend/packages/app/src/windup_app/server/bill/interface.py b/backend/packages/app/src/windup_app/server/bill/interface.py new file mode 100644 index 00000000..c99ae601 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/bill/interface.py @@ -0,0 +1,46 @@ +"""支付与订阅领域服务抽象接口。 + +定义账单管理、订单创建、异步通知处理、主动查询及过期处理的抽象。 +""" + +from abc import ABC, abstractmethod +from typing import Literal + +from sqlalchemy.orm import Session + + +class BillService(ABC): + """支付与订阅业务服务抽象。""" + + @abstractmethod + def create_order( + self, + session: Session, + user_id: int, + product_id: int | None, + amount_fen: int | None, + channel: str, + ): + """创建支付订单。""" + + @abstractmethod + def handle_notify(self, session: Session, payload: dict): + """处理支付回调异步通知。""" + + @abstractmethod + def query_order(self, session: Session, order_no: str): + """查询订单详情(必要时向上游渠道补偿查询)。""" + + @abstractmethod + def close_expired_orders( + self, session: Session, order_no: str + ) -> Literal["closed", "paid", "retry", "skipped"]: + """关闭超时未支付的订单。 + + 先向上游核实支付结果:确认未支付才迁移 CLOSED;上游已支付的补发货; + 结果不确定时保持 PENDING 并返回 "retry",由调用方延后重试。 + """ + + @abstractmethod + def process_expired_subscriptions(self, session: Session): + """处理已到期的订阅,清零当期批次并轮转待生效订阅。""" diff --git a/backend/packages/app/src/windup_app/server/bill/model.py b/backend/packages/app/src/windup_app/server/bill/model.py new file mode 100644 index 00000000..a1734711 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/bill/model.py @@ -0,0 +1,404 @@ +"""支付与账单领域模型。 + +与数据库表一一对应,字段名与列名保持一致。 + +ORM 模型 +-------- + +:: + + windup_product 商品配置表(充值快捷档位与订阅套餐) + windup_order 订单表(充值与订阅支付记录) + windup_payment_event 支付回调事件审计表 + windup_subscription 用户会员订阅记录表 + windup_refund 退款记录表 +""" + +from datetime import datetime, timezone + +from sqlalchemy import ( + BigInteger, + DateTime, + Index, + Integer, + JSON, + SmallInteger, + String, + Text, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from windup_common.enums.bill import ( + OrderStatus, + PaymentEventType, + PaymentProviderType, + ProductType, + SubscriptionStatus, +) +from windup_framework.db import Base + + +class Product(Base): + """商品配置表。 + + 存储充值快捷商品档位及月度订阅配置 (PLUS/PRO/MAX)。 + """ + + __tablename__ = "windup_product" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + type: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + default=ProductType.TOP_UP, + ) # 1=TOP_UP, 2=SUBSCRIPTION + tier: Mapped[int | None] = mapped_column( + SmallInteger, + nullable=True, + ) # 订阅档位: 1=PLUS, 2=PRO, 3=MAX (充值商品为 NULL) + name: Mapped[str] = mapped_column( + String(64), + nullable=False, + ) + description: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + ) + amount_fen: Mapped[int] = mapped_column( + Integer, + nullable=False, + ) # 金额(分) + credits: Mapped[int] = mapped_column( + Integer, + nullable=False, + ) # 应赠送/获得积分 + original_amount_fen: Mapped[int | None] = mapped_column( + Integer, + nullable=True, + ) # 原价(分),用于前端划线价展示 + badge: Mapped[str | None] = mapped_column( + String(32), + nullable=True, + ) # 促销标签如 "热卖", "9.4折" + sort_order: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + ) + is_active: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + default=1, + ) # 1=上架, 0=下架 + extra_meta: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + default=dict, + ) + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + def __init__(self, *args, **kwargs): + kwargs.setdefault("sort_order", 0) + kwargs.setdefault("is_active", 1) + kwargs.setdefault("extra_meta", {}) + super().__init__(*args, **kwargs) + + +class Order(Base): + """订单表。 + + 记录商户订单号、实付金额、应得积分、商品快照、支付渠道及状态。 + """ + + __tablename__ = "windup_order" + __table_args__ = ( + Index("ix_order_user_status", "user_id", "status"), + ) + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + order_no: Mapped[str] = mapped_column( + String(64), + unique=True, + index=True, + nullable=False, + ) # 商户系统内部唯一订单号 + user_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + nullable=False, + index=True, + ) + product_id: Mapped[int | None] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + nullable=True, + ) # 关联商品ID,自定义金额充值时可为空 + product_type: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + default=ProductType.TOP_UP, + ) # 1=TOP_UP, 2=SUBSCRIPTION + product_snapshot: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + default=dict, + ) # 下单时商品快照 (name, amount_fen, credits, tier 等) + amount_fen: Mapped[int] = mapped_column( + Integer, + nullable=False, + ) # 订单实付金额(分) + credits: Mapped[int] = mapped_column( + Integer, + nullable=False, + ) # 应赠送/获得积分 + channel: Mapped[str] = mapped_column( + String(32), + nullable=False, + default="alipay", + ) # 支付方式: "alipay", "wxpay" 等 + provider: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + default=PaymentProviderType.EPAY, + ) # 1=EPAY, 2=STRIPE + provider_trade_no: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + index=True, + ) # 支付平台交易号 + status: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + default=OrderStatus.PENDING, + ) # 0=PENDING, 1=PAID, 2=CLOSED, 3=REFUNDING, 4=REFUNDED + pay_url: Mapped[str | None] = mapped_column( + Text, + nullable=True, + ) # 支付跳转 URL 或二维码串 + expire_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) # 订单超时关闭时间(默认下单后 15 分钟) + paid_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) # 支付完成时间 + closed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) # 订单关闭时间 + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + def __init__(self, *args, **kwargs): + kwargs.setdefault("status", OrderStatus.PENDING) + kwargs.setdefault("product_type", ProductType.TOP_UP) + kwargs.setdefault("product_snapshot", {}) + kwargs.setdefault("channel", "alipay") + kwargs.setdefault("provider", PaymentProviderType.EPAY) + super().__init__(*args, **kwargs) + + +class PaymentEvent(Base): + """支付通知事件表。 + + 无条件持久化外部支付平台的回调与通知原始报文,用于审计对账。 + """ + + __tablename__ = "windup_payment_event" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + order_no: Mapped[str | None] = mapped_column( + String(64), + nullable=True, + index=True, + ) + provider_trade_no: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + index=True, + ) + event_type: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + default=PaymentEventType.PAY_NOTIFY, + ) # 1=PAY_NOTIFY, 2=REFUND_NOTIFY + provider: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + default=PaymentProviderType.EPAY, + ) # 1=EPAY, 2=STRIPE + raw_payload: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + default=dict, + ) # 原始报文 + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + def __init__(self, *args, **kwargs): + kwargs.setdefault("event_type", PaymentEventType.PAY_NOTIFY) + kwargs.setdefault("provider", PaymentProviderType.EPAY) + kwargs.setdefault("raw_payload", {}) + super().__init__(*args, **kwargs) + + +class Subscription(Base): + """订阅会员表。 + + 记录用户的订阅等级 (tier) 及账期起止时间 (period_start, period_end)。 + """ + + __tablename__ = "windup_subscription" + __table_args__ = ( + Index("ix_subscription_user_status", "user_id", "status"), + ) + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + user_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + nullable=False, + index=True, + ) + order_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + nullable=False, + ) # 关联支付订单ID + tier: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + ) # 1=PLUS, 2=PRO, 3=MAX + status: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + default=SubscriptionStatus.ACTIVE, + ) # 0=PENDING_START, 1=ACTIVE, 2=EXPIRED + period_start: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) # 本期起算时间 + period_end: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + ) # 本期截至时间 + credits_granted: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + ) # 本期发放积分 + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + def __init__(self, *args, **kwargs): + kwargs.setdefault("status", SubscriptionStatus.ACTIVE) + kwargs.setdefault("credits_granted", 0) + super().__init__(*args, **kwargs) + + +class Refund(Base): + """退款表。 + + 记录退款申请与状态。 + """ + + __tablename__ = "windup_refund" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + refund_no: Mapped[str] = mapped_column( + String(64), + unique=True, + index=True, + nullable=False, + ) # 退款编号 + order_no: Mapped[str] = mapped_column( + String(64), + nullable=False, + index=True, + ) # 原订单号 + user_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + nullable=False, + index=True, + ) + amount_fen: Mapped[int] = mapped_column( + Integer, + nullable=False, + ) # 退款金额(分) + reason: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + ) # 退款原因 + status: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + default=0, + ) # 0=PENDING, 1=SUCCESS, 2=FAILED + provider_refund_no: Mapped[str | None] = mapped_column( + String(128), + nullable=True, + ) # 支付通道退款流水号 + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + def __init__(self, *args, **kwargs): + kwargs.setdefault("status", 0) + super().__init__(*args, **kwargs) diff --git a/backend/packages/app/src/windup_app/server/bill/provider.py b/backend/packages/app/src/windup_app/server/bill/provider.py new file mode 100644 index 00000000..eb89d968 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/bill/provider.py @@ -0,0 +1,104 @@ +"""支付提供商策略接口与注册表。 + +定义统一支付网关抽象及标准化 DTO。 +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Literal + +from windup_common.enums.bill import PaymentProviderType + + +@dataclass +class ProviderCreateParams: + """创建支付订单入参。""" + + order_no: str + amount_fen: int + channel: str + name: str = "" + notify_url: str = "" + return_url: str = "" + client_ip: str = "127.0.0.1" + extra_params: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ProviderCreateResult: + """创建支付订单返回结果。""" + + order_no: str + pay_url: str + provider_trade_no: str | None = None + raw_data: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ProviderNotifyResult: + """支付回调通知解析结果。""" + + order_no: str + provider_trade_no: str + amount_fen: int + status: str + raw_payload: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ProviderQueryResult: + """主动查询上游订单结果。 + + status 四态:paid(已支付,金额带回应由调用方校验)、unpaid(明确的未支付)、 + not_found(上游查无此单)、unknown(网关不可达 / 响应不可解析 / 契约不符)。 + unknown 语义下调用方不得迁移任何终态,只能保持可重试状态。 + """ + + order_no: str + provider_trade_no: str | None + amount_fen: int + status: Literal["paid", "unpaid", "not_found", "unknown"] + raw_data: dict[str, Any] = field(default_factory=dict) + + +class PaymentProvider(ABC): + """支付提供商抽象基类。""" + + @abstractmethod + def create_order(self, params: ProviderCreateParams) -> ProviderCreateResult: + """向上游支付通道发起统一下单或生成支付跳转链接。""" + + @abstractmethod + def verify_notify(self, payload: dict[str, Any]) -> ProviderNotifyResult: + """验证异步回调通知签名并解析标准化支付结果。""" + + @abstractmethod + def query_order(self, order_no: str) -> ProviderQueryResult: + """向上游支付通道主动查询订单支付结果。""" + + @abstractmethod + def close_order(self, order_no: str) -> bool: + """向上游支付通道发起关闭订单请求。""" + + +_PROVIDERS: dict[PaymentProviderType | int, PaymentProvider] = {} + + +def register_provider( + provider_type: PaymentProviderType | int, + provider: PaymentProvider, +) -> None: + """注册支付提供商实例。""" + _PROVIDERS[provider_type] = provider + + +def get_provider( + provider_type: PaymentProviderType | int, +) -> PaymentProvider: + """根据提供商类型获取对应的支付提供商实例。 + + :raises KeyError: 如果指定的支付提供商尚未注册。 + """ + if provider_type not in _PROVIDERS: + raise KeyError(f"Payment provider not registered: {provider_type}") + return _PROVIDERS[provider_type] diff --git a/backend/packages/app/src/windup_app/server/bill/service.py b/backend/packages/app/src/windup_app/server/bill/service.py new file mode 100644 index 00000000..7af0e8f3 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/bill/service.py @@ -0,0 +1,542 @@ +"""支付与订阅领域服务实现。 + +实现订单创建、支付回调验签处理、订单查询与超时关闭、订阅到期与批次轮转。 + +全局锁序:订单 → 用户 → 订阅 → 账户 → 批次。发货路径(`_fulfill_order`) +以用户行为串行化起点(订阅排期的空结果集上 FOR UPDATE 锁不住任何行, +而用户行必然存在),quota 服务内部统一先锁账户再锁批次。 +""" + +from datetime import datetime, timedelta, timezone +import logging +import secrets +from typing import Any, Literal + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from windup_app.server.bill.interface import BillService +from windup_app.server.bill.model import ( + Order, + PaymentEvent, + Product, + Subscription, +) +from windup_app.server.bill.provider import ( + ProviderCreateParams, + get_provider, +) +from windup_app.server.quota.interface import QuotaService +from windup_app.server.quota.model import ( + CreditAccount, + CreditBatch, + CreditTransaction, +) +from windup_app.server.user.model import User +from windup_common.enums.bill import ( + OrderStatus, + PaymentEventType, + PaymentProviderType, + ProductType, + SubscriptionStatus, + SubscriptionTier, +) +from windup_common.enums.biz_code import BizCode +from windup_common.enums.quota import BillingMode, CreditReason +from windup_common.exceptions import BizException +from windup_framework.db.redis import get_redis + +logger = logging.getLogger("windup.bill.service") + +# 易支付"支付成功"状态值。签名只负责认证,发货前必须核对业务状态。 +_SUCCESS_STATUSES = frozenset({"TRADE_SUCCESS", "SUCCESS"}) + +# 订单过期后上游结果仍 unknown 时的强制关单时限(小时)。 +_FORCE_CLOSE_GRACE_HOURS = 24 + + +class SqlAlchemyBillService(BillService): + """基于 SQLAlchemy 的账单与订阅服务实现。""" + + def __init__(self, quota_service: QuotaService | None = None) -> None: + self._quota_service = quota_service + + @property + def quota_service(self) -> QuotaService: + """获取积分领域服务单例。""" + if self._quota_service is None: + from windup_app.server.quota.service import service as default_quota_service + + return default_quota_service + return self._quota_service + + def create_order( + self, + session: Session, + user_id: int, + product_id: int | None, + amount_fen: int | None, + channel: str = "alipay", + ) -> Order: + """创建支付订单并生成支付跳转链接。""" + if product_id is not None: + product = session.get(Product, product_id) + if product is None or product.is_active != 1: + raise BizException("商品不存在或已下架", code=BizCode.BAD_REQUEST) + order_amount_fen = product.amount_fen + credits = product.credits + product_type = product.type + product_name = product.name + product_snapshot = { + "id": product.id, + "name": product.name, + "type": product.type, + "tier": product.tier, + "amount_fen": product.amount_fen, + "credits": product.credits, + } + else: + if amount_fen is None: + raise BizException("缺少充值金额", code=BizCode.BAD_REQUEST) + if amount_fen < 500: + raise BizException("自定义充值金额不能低于5元", code=BizCode.BAD_REQUEST) + if amount_fen % 100 != 0: + raise BizException("自定义充值金额必须为整元", code=BizCode.BAD_REQUEST) + order_amount_fen = amount_fen + credits = (amount_fen + 7) // 8 + product_type = int(ProductType.TOP_UP) + product_name = f"充值 {amount_fen // 100} 元" + product_snapshot = { + "name": product_name, + "amount_fen": amount_fen, + "credits": credits, + } + + now = datetime.now(timezone.utc) + expire_at = now + timedelta(minutes=15) + order_no = f"ORD{now.strftime('%Y%m%d%H%M%S')}{secrets.token_hex(4).upper()}" + + order = Order( + order_no=order_no, + user_id=user_id, + product_id=product_id, + product_type=product_type, + product_snapshot=product_snapshot, + amount_fen=order_amount_fen, + credits=credits, + channel=channel or "alipay", + provider=PaymentProviderType.EPAY, + status=OrderStatus.PENDING, + expire_at=expire_at, + ) + + # 调用支付通道获取支付链接 + provider = get_provider(PaymentProviderType.EPAY) + create_params = ProviderCreateParams( + order_no=order_no, + amount_fen=order_amount_fen, + channel=channel or "alipay", + name=product_name, + ) + provider_res = provider.create_order(create_params) + order.pay_url = provider_res.pay_url + + session.add(order) + session.flush() + + # 写入 Redis 延迟关单 ZSET + try: + redis_client = get_redis() + redis_client.zadd("bill:pending_close", {order.order_no: expire_at.timestamp()}) + except Exception as exc: + logger.warning("Failed to enqueue order %s to redis: %s", order.order_no, exc) + + return order + + def handle_notify(self, session: Session, payload: dict[str, Any]) -> Order: + """处理支付回调异步通知。""" + # 1. 审计落库:无条件持久化原始回调报文 + order_no_raw = payload.get("out_trade_no") + provider_trade_no_raw = payload.get("trade_no") + event = PaymentEvent( + order_no=str(order_no_raw) if order_no_raw else None, + provider_trade_no=str(provider_trade_no_raw) if provider_trade_no_raw else None, + event_type=PaymentEventType.PAY_NOTIFY, + provider=PaymentProviderType.EPAY, + raw_payload=payload, + ) + session.add(event) + session.flush() + + # 2. 验签与结果解析 + provider = get_provider(PaymentProviderType.EPAY) + notify_res = provider.verify_notify(payload) + + # 3. 业务状态校验:签名合法只代表通知来自网关,不代表支付成功 + if str(notify_res.status).upper() not in _SUCCESS_STATUSES: + logger.warning( + "[WINDUP] 非成功支付通知被拒绝 | order_no=%s trade_status=%s", + notify_res.order_no, + notify_res.status, + ) + raise BizException( + f"非成功支付通知(trade_status={notify_res.status})", + code=BizCode.BAD_REQUEST, + ) + + # 4. 行锁查询订单 + order = session.scalar( + select(Order) + .where(Order.order_no == notify_res.order_no) + .with_for_update() + ) + if order is None: + raise BizException("订单不存在", code=BizCode.NOT_FOUND) + + if order.amount_fen != notify_res.amount_fen: + raise BizException("订单金额不匹配", code=BizCode.BAD_REQUEST) + + # 5. 幂等检查 + if order.status == OrderStatus.PAID: + logger.info("Order %s already paid, skipping duplicate notify", order.order_no) + return order + + if order.status in (OrderStatus.REFUNDING, OrderStatus.REFUNDED): + logger.warning( + "Order %s status is %s, ignoring notify", + order.order_no, + order.status, + ) + return order + + if order.status == OrderStatus.CLOSED: + # 迟到的成功回调:网关侧已扣款,本地曾按未支付关单。复活并发货, + # 否则用户已付款却拿不到积分/订阅。 + logger.warning( + "[WINDUP] CLOSED 订单收到成功回调,复活并发货 | order_no=%s", + order.order_no, + ) + + self._fulfill_order(session, order, notify_res.provider_trade_no) + return order + + def _fulfill_order( + self, session: Session, order: Order, provider_trade_no: str + ) -> None: + """订单置 PAID 并发放积分/订阅。 + + 调用方必须已持有订单行锁。入口先锁用户行:两个并发回调各自锁住不同 + 订单行互不阻塞,而订阅排期查询可能落在空结果集上(FOR UPDATE 锁不住 + 任何行),用户行是唯一稳定的串行化点。锁序:订单(已持)→ 用户 → + 订阅 → 账户 → 批次。 + """ + now = datetime.now(timezone.utc) + + user = session.scalar( + select(User).where(User.id == order.user_id).with_for_update() + ) + if user is None: + # 用户已注销:订单事实仍要落地,但无从发货,留 ERROR 给运营 + order.status = OrderStatus.PAID + order.paid_at = now + order.provider_trade_no = provider_trade_no + logger.error( + "[WINDUP] 已支付订单的用户不存在,跳过发货 | order_no=%s user_id=%s", + order.order_no, + order.user_id, + ) + session.flush() + return + + order.status = OrderStatus.PAID + order.paid_at = now + order.provider_trade_no = provider_trade_no + session.flush() + + if order.product_type == ProductType.TOP_UP: + self._ensure_credit_account(session, order.user_id) + self.quota_service.credit( + session, + user_id=order.user_id, + amount=order.credits, + reason=int(CreditReason.TOP_UP), + ref_id=order.order_no, + ) + elif order.product_type == ProductType.SUBSCRIPTION: + tier = ( + order.product_snapshot.get("tier") + if order.product_snapshot + else SubscriptionTier.PLUS + ) + if tier is None: + tier = SubscriptionTier.PLUS + + # 查询当前用户是否存在有效的活跃订阅(用户锁内,排期安全) + latest_active = session.scalar( + select(Subscription) + .where( + Subscription.user_id == order.user_id, + Subscription.status.in_( + [SubscriptionStatus.ACTIVE, SubscriptionStatus.PENDING_START] + ), + Subscription.period_end > now, + ) + .order_by(Subscription.period_end.desc()) + .with_for_update() + ) + + if latest_active is not None: + sub_status = SubscriptionStatus.PENDING_START + sub_start = latest_active.period_end + sub_end = sub_start + timedelta(days=30) + else: + sub_status = SubscriptionStatus.ACTIVE + sub_start = now + sub_end = now + timedelta(days=30) + + sub = Subscription( + user_id=order.user_id, + order_id=order.id, + tier=tier, + status=sub_status, + period_start=sub_start, + period_end=sub_end, + credits_granted=order.credits, + ) + session.add(sub) + session.flush() + + if sub_status == SubscriptionStatus.ACTIVE: + self._ensure_credit_account(session, order.user_id) + self.quota_service.credit( + session, + user_id=order.user_id, + amount=order.credits, + reason=int(CreditReason.SUBSCRIPTION), + ref_id=f"sub:{sub.id}", + expires_at=sub_end, + ) + + def query_order(self, session: Session, order_no: str) -> Order | None: + """查询订单详情,回调丢失时向上游补偿查询。 + + 本地 PENDING/CLOSED 的订单向上游核实:上游已支付的补发货(回调丢失 + 或被迟到关闭),其余返回本地行。PAID/REFUNDING/REFUNDED 不查上游。 + """ + order = session.scalar(select(Order).where(Order.order_no == order_no)) + if order is None: + return None + if order.status not in (OrderStatus.PENDING, OrderStatus.CLOSED): + return order + + try: + provider = get_provider(order.provider) + query_res = provider.query_order(order_no) + except Exception as exc: + logger.warning("Failed to query order %s on provider: %s", order_no, exc) + return order + + if query_res.status != "paid": + return order + + if query_res.amount_fen != order.amount_fen: + logger.error( + "[WINDUP] 上游支付金额与订单不符,拒绝补偿发货 | order_no=%s local=%s upstream=%s", + order_no, + order.amount_fen, + query_res.amount_fen, + ) + return order + + # 锁单复核:回调可能与本查询并发,PAID 幂等由 _fulfill_order 前的复核保证 + locked = session.scalar( + select(Order).where(Order.order_no == order_no).with_for_update() + ) + if locked is not None and locked.status in (OrderStatus.PENDING, OrderStatus.CLOSED): + if locked.status == OrderStatus.CLOSED: + logger.warning( + "[WINDUP] 上游查询发现 CLOSED 订单已支付,复活并发货 | order_no=%s", + order_no, + ) + self._fulfill_order(session, locked, query_res.provider_trade_no or "") + session.flush() + return locked + return order + + def close_expired_orders( + self, session: Session, order_no: str + ) -> Literal["closed", "paid", "retry", "skipped"]: + """关闭超时未支付的订单。 + + 先无锁向上游核实(HTTP 不持锁,不阻塞并发的回调事务),确认上游 + 未支付才迁移 CLOSED;上游已支付的转发货;结果不确定时保留 PENDING + 返回 "retry",由调用方延后重试,防止"用户已付款但本地已关单"。 + + 返回值: + - "closed": 已确认未支付,本地置 CLOSED + - "paid": 上游已支付,已补发货 + - "retry": 上游结果不确定(网关超时/契约不符),订单保持 PENDING + - "skipped": 订单非 PENDING(已 PAID/CLOSED/退款中),无需处理 + """ + # 1. 无锁预读:非 PENDING 直接跳过 + order = session.scalar( + select(Order).where(Order.order_no == order_no) + ) + if order is None or order.status != OrderStatus.PENDING: + return "skipped" + + # 2. 无锁向上游核实支付结果 + try: + provider = get_provider(order.provider) + query_res = provider.query_order(order_no) + except Exception as exc: + logger.warning("Failed to query order %s on provider: %s", order_no, exc) + query_res = None + + # 3. 锁单复核(回调可能在此期间已把它置为 PAID) + order = session.scalar( + select(Order).where(Order.order_no == order_no).with_for_update() + ) + if order is None or order.status != OrderStatus.PENDING: + return "skipped" + + now = datetime.now(timezone.utc) + + if query_res is not None and query_res.status == "paid": + if query_res.amount_fen != order.amount_fen: + logger.error( + "[WINDUP] 上游支付金额与订单不符,保持 PENDING 待运营处理 | order_no=%s local=%s upstream=%s", + order_no, + order.amount_fen, + query_res.amount_fen, + ) + return "retry" + self._fulfill_order(session, order, query_res.provider_trade_no or "") + session.flush() + return "paid" + + if query_res is not None and query_res.status in ("unpaid", "not_found"): + order.status = OrderStatus.CLOSED + order.closed_at = now + session.flush() + try: + provider.close_order(order_no) + except Exception as exc: + logger.warning("Failed to close order %s on provider: %s", order_no, exc) + return "closed" + + # unknown:过期超过宽限期仍无法核实上游时强关,留 ERROR 供运营追款 + if now > order.expire_at + timedelta(hours=_FORCE_CLOSE_GRACE_HOURS): + logger.error( + "[WINDUP] 订单上游结果持续未知超过 %s 小时,强制关单 | order_no=%s", + _FORCE_CLOSE_GRACE_HOURS, + order_no, + ) + order.status = OrderStatus.CLOSED + order.closed_at = now + session.flush() + return "closed" + + return "retry" + + def process_expired_subscriptions(self, session: Session) -> int: + """处理已到期的订阅,清零当期批次并轮转待生效订阅。""" + now = datetime.now(timezone.utc) + expired_subs = session.scalars( + select(Subscription) + .where( + Subscription.status == SubscriptionStatus.ACTIVE, + Subscription.period_end <= now, + ) + .with_for_update() + ).all() + + count = 0 + for sub in expired_subs: + count += 1 + # 锁序:先账户后批次(与 quota 服务的 reserve/capture/release 一致)。 + # 旧实现批次→账户与 reserve 的账户→批次构成 AB-BA 死锁环。 + account = session.scalar( + select(CreditAccount) + .where(CreditAccount.user_id == sub.user_id) + .with_for_update() + ) + + # 1. 查找对应的订阅积分批次并清零可用余额(锁内复核 remaining>0, + # 与 reserve 的惰性过期互斥,两边谁先清零谁写 expire 流水) + batch = session.scalar( + select(CreditBatch) + .where( + CreditBatch.user_id == sub.user_id, + CreditBatch.source_type == int(CreditReason.SUBSCRIPTION), + CreditBatch.source_ref == f"sub:{sub.id}", + CreditBatch.is_exhausted == 0, + ) + .with_for_update() + ) + if batch is not None and batch.remaining > 0: + expired_amount = batch.remaining + batch.remaining = 0 + batch.is_exhausted = 1 if batch.frozen == 0 else 0 + + # 同步扣减账户可用余额 + if account is not None: + account.balance -= expired_amount + txn = CreditTransaction( + user_id=sub.user_id, + delta=-expired_amount, + reason=int(CreditReason.SUBSCRIPTION_EXPIRE), + billing_mode=BillingMode.PREPAID, + balance_after=account.balance, + ref_id=f"expire:sub:{sub.id}", + ) + session.add(txn) + + sub.status = SubscriptionStatus.EXPIRED + + # 2. 查找是否有排队的待生效订阅 (PENDING_START) + next_sub = session.scalar( + select(Subscription) + .where( + Subscription.user_id == sub.user_id, + Subscription.status == SubscriptionStatus.PENDING_START, + ) + .order_by(Subscription.period_start.asc(), Subscription.id.asc()) + .with_for_update() + ) + if next_sub is not None: + next_sub.status = SubscriptionStatus.ACTIVE + self._ensure_credit_account(session, next_sub.user_id) + self.quota_service.credit( + session, + user_id=next_sub.user_id, + amount=next_sub.credits_granted, + reason=int(CreditReason.SUBSCRIPTION), + ref_id=f"sub:{next_sub.id}", + expires_at=next_sub.period_end, + ) + + session.flush() + return count + + def _ensure_credit_account(self, session: Session, user_id: int) -> CreditAccount: + """确保用户积分账户存在。""" + account = session.scalar( + select(CreditAccount) + .where(CreditAccount.user_id == user_id) + .with_for_update() + ) + if account is None: + account = CreditAccount( + user_id=user_id, + balance=0, + frozen=0, + total_earned=0, + total_spent=0, + ) + session.add(account) + session.flush() + return account + + +service = SqlAlchemyBillService() diff --git a/backend/packages/app/src/windup_app/server/quota/interface.py b/backend/packages/app/src/windup_app/server/quota/interface.py index 767df396..3e49188a 100644 --- a/backend/packages/app/src/windup_app/server/quota/interface.py +++ b/backend/packages/app/src/windup_app/server/quota/interface.py @@ -84,6 +84,8 @@ def credit( amount: int, reason: int, ref_id: str | None = None, + *, + expires_at: datetime | None = None, ) -> None: """入账:增加可用余额与累计获得。""" diff --git a/backend/packages/app/src/windup_app/server/quota/model.py b/backend/packages/app/src/windup_app/server/quota/model.py index 5207ba1d..2e8f6e98 100644 --- a/backend/packages/app/src/windup_app/server/quota/model.py +++ b/backend/packages/app/src/windup_app/server/quota/model.py @@ -13,6 +13,8 @@ windup_invite_code 邀请码 windup_invite_record 邀请记录 windup_token_usage Token 用量记录 + windup_credit_batch 积分批次(有效期与来源追踪) + windup_credit_freeze_alloc 冻结分配明细(任务冻结对应批次) """ from dataclasses import dataclass, field @@ -21,6 +23,7 @@ from sqlalchemy import ( BigInteger, DateTime, + Index, Integer, SmallInteger, String, @@ -212,6 +215,89 @@ class InviteRecord(Base): ) +class CreditBatch(Base): + """积分批次表。按来源类型独立存放,支持有效期管理与先进先出/订阅优先消耗。""" + + __tablename__ = "windup_credit_batch" + __table_args__ = ( + Index( + "ix_credit_batch_user_exhausted_expires", + "user_id", + "is_exhausted", + "expires_at", + ), + ) + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + user_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + nullable=False, + index=True, + ) + source_type: Mapped[int] = mapped_column(SmallInteger, nullable=False) + source_ref: Mapped[str | None] = mapped_column(String(64), nullable=True) + total: Mapped[int] = mapped_column(Integer, nullable=False) + remaining: Mapped[int] = mapped_column(Integer, nullable=False) + frozen: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + is_exhausted: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + def __init__(self, *args, **kwargs): + kwargs.setdefault("frozen", 0) + kwargs.setdefault("is_exhausted", 0) + super().__init__(*args, **kwargs) + + +class CreditFreezeAlloc(Base): + """任务冻结分配明细。记录每笔生成任务从哪些批次分别冻结了多少积分。""" + + __tablename__ = "windup_credit_freeze_alloc" + __table_args__ = ( + Index("ix_credit_freeze_alloc_ref_status", "freeze_ref", "status"), + ) + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + freeze_ref: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + batch_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + nullable=False, + ) + amount: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[int] = mapped_column( + SmallInteger, nullable=False, default=1 + ) # 1=FROZEN, 2=CAPTURED, 3=RELEASED + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + def __init__(self, *args, **kwargs): + kwargs.setdefault("status", 1) + super().__init__(*args, **kwargs) + + # class TokenUsage(Base): # """Token 用量记录。""" # __tablename__ = "windup_token_usage" diff --git a/backend/packages/app/src/windup_app/server/quota/service.py b/backend/packages/app/src/windup_app/server/quota/service.py index c79d37a8..7a98dea2 100644 --- a/backend/packages/app/src/windup_app/server/quota/service.py +++ b/backend/packages/app/src/windup_app/server/quota/service.py @@ -18,7 +18,7 @@ from datetime import datetime, timedelta, timezone from typing import Literal -from sqlalchemy import func, or_, select, update +from sqlalchemy import case, func, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -30,6 +30,8 @@ from windup_app.server.quota.model import ( CreditAccount, CreditAccountView, + CreditBatch, + CreditFreezeAlloc, CreditRedemptionCode, CreditTransaction, CreditTransactionView, @@ -163,6 +165,27 @@ def get_account(self, session: Session, user_id: int) -> CreditAccountView | Non ) return _to_account_view(account) if account else None + def get_active_batches(self, session: Session, user_id: int): + """查询用户当前未过期且有余额的批次,按来源分类统计。""" + now = _now() + batches = session.scalars( + select(CreditBatch) + .where( + CreditBatch.user_id == user_id, + CreditBatch.remaining > 0, + or_(CreditBatch.expires_at.is_(None), CreditBatch.expires_at > now), + ) + ).all() + + perpetual_credits = sum(b.remaining for b in batches if b.expires_at is None) + expiring_credits = sum(b.remaining for b in batches if b.expires_at is not None) + + return { + "perpetual_credits": perpetual_credits, + "expiring_credits": expiring_credits, + "batches": batches + } + def _get_account_for_update(self, session: Session, user_id: int) -> CreditAccount: """SELECT ... FOR UPDATE 锁定账户行。""" account = session.scalar( @@ -174,6 +197,27 @@ def _get_account_for_update(self, session: Session, user_id: int) -> CreditAccou raise BizException("积分账户不存在", code=BizCode.NOT_FOUND) return account + def _get_or_create_account_for_update( + self, session: Session, user_id: int + ) -> CreditAccount: + """SELECT ... FOR UPDATE 锁定账户行,不存在则创建。""" + account = session.scalar( + select(CreditAccount) + .where(CreditAccount.user_id == user_id) + .with_for_update() + ) + if account is None: + account = CreditAccount( + user_id=user_id, + balance=0, + frozen=0, + total_earned=0, + total_spent=0, + ) + session.add(account) + session.flush() + return account + def _write_txn( self, session: Session, @@ -208,12 +252,49 @@ def _txn_for( # -- 预付费:冻结 / 扣减 / 解冻 ---------------------------------------- + def _expire_stale_batches(self, session: Session, user_id: int, account: CreditAccount) -> None: + """惰性过期:清零已过有效期但余额未清的批次。 + + 订阅批次依赖 60s 轮询 worker 清零,但 worker 与任意一次 reserve 之间没有 + 原子屏障——不做这一步,过期额度会先通过账户余额检查,再因批次查询排除 + 过期批次而落进 legacy fallback,被转成无期限积分。 + """ + now = _now() + stale_batches = session.scalars( + select(CreditBatch) + .where( + CreditBatch.user_id == user_id, + CreditBatch.remaining > 0, + CreditBatch.expires_at.is_not(None), + CreditBatch.expires_at <= now, + ) + .with_for_update() + ).all() + for batch in stale_batches: + expired_amount = batch.remaining + batch.remaining = 0 + batch.is_exhausted = 1 if batch.frozen == 0 else 0 + account.balance -= expired_amount + # ref 与 worker 的 expire:sub:{id} 不同源,UniqueConstraint 保证幂等 + self._write_txn( + session, + user_id, + -expired_amount, + CreditReason.SUBSCRIPTION_EXPIRE, + BillingMode.PREPAID, + account.balance, + f"lazy:batch:{batch.id}", + ) + def reserve_credit( self, session: Session, user_id: int, amount: int, ref_id: str ) -> None: """预付费冻结:balance -= amount, frozen += amount。""" account = self._get_account_for_update(session, user_id) + # 批次扣减前先清掉已过期未清零的批次,保证余额检查与批次视图一致 + self._expire_stale_batches(session, user_id, account) + if account.balance < amount: raise BizException( f"积分不足(需要 {amount},当前 {account.balance})", @@ -224,6 +305,73 @@ def reserve_credit( account.frozen += amount session.flush() + # 批次扣减:SUBSCRIPTION 来源优先 -> expires_at 升序(快过期的先用,nulls last) -> create_at 升序 (FIFO) + now = _now() + batches = session.scalars( + select(CreditBatch) + .where( + CreditBatch.user_id == user_id, + CreditBatch.remaining > 0, + or_(CreditBatch.expires_at.is_(None), CreditBatch.expires_at > now), + ) + .order_by( + case((CreditBatch.source_type == int(CreditReason.SUBSCRIPTION), 0), else_=1), + case((CreditBatch.expires_at.is_(None), 1), else_=0), + CreditBatch.expires_at.asc(), + CreditBatch.create_at.asc(), + CreditBatch.id.asc(), + ) + .with_for_update() + ).all() + + remaining_to_reserve = amount + for batch in batches: + if remaining_to_reserve <= 0: + break + deduct = min(batch.remaining, remaining_to_reserve) + batch.remaining -= deduct + batch.frozen += deduct + batch.is_exhausted = 1 if (batch.remaining == 0 and batch.frozen == 0) else 0 + remaining_to_reserve -= deduct + + alloc = CreditFreezeAlloc( + freeze_ref=ref_id, + batch_id=batch.id, + amount=deduct, + status=1, # 1=FROZEN + ) + session.add(alloc) + + # 兼容旧数据/测试环境未预置 batch 的情况 + if remaining_to_reserve > 0: + logger.warning( + "[WINDUP] 积分批次不足,回退 legacy 批次 | user_id=%s gap=%s ref_id=%s", + user_id, + remaining_to_reserve, + ref_id, + ) + legacy_batch = CreditBatch( + user_id=user_id, + source_type=int(CreditReason.REGISTER_GIFT), + source_ref="legacy_balance", + total=remaining_to_reserve, + remaining=0, + frozen=remaining_to_reserve, + is_exhausted=0, + ) + session.add(legacy_batch) + session.flush() + + alloc = CreditFreezeAlloc( + freeze_ref=ref_id, + batch_id=legacy_batch.id, + amount=remaining_to_reserve, + status=1, + ) + session.add(alloc) + + session.flush() + self._write_txn( session, user_id, @@ -254,6 +402,10 @@ def capture_credit( 若 actual_amount < frozen_amount,差额退回 balance。 同一 ``ref_id`` 已有 CAPTURED 流水则跳过(MQ 重投幂等)。 + + 差额返还按各分配批次的 ``expires_at`` 拆分:冻结跨越了批次有效期时 + (任务冻结于到期前、结算于到期清理后),过期部分只解除冻结并记过期 + 流水,不回可用余额,避免复活已过期的订阅额度。 """ account = self._get_account_for_update(session, user_id) if self._txn_for(session, ref_id, int(CreditReason.CAPTURED)) is not None: @@ -271,10 +423,48 @@ def capture_credit( # 实际消耗 account.total_spent += actual_amount - # 差额退回 - refund = frozen_amount - actual_amount - if refund > 0: - account.balance += refund + # 批次结算:查找 freeze_ref == ref_id and status == 1 + allocs = session.scalars( + select(CreditFreezeAlloc) + .where( + CreditFreezeAlloc.freeze_ref == ref_id, + CreditFreezeAlloc.status == 1, + ) + .order_by(CreditFreezeAlloc.id.asc()) + .with_for_update() + ).all() + + refundable = 0 + expired_refund = 0 + remaining_actual = actual_amount + for alloc in allocs: + batch = session.scalar( + select(CreditBatch) + .where(CreditBatch.id == alloc.batch_id) + .with_for_update() + ) + alloc_amt = alloc.amount + if batch is not None: + # 扣减 batch 上的 frozen + batch.frozen -= alloc_amt + # 实际扣减 + deduct = min(alloc_amt, remaining_actual) + remaining_actual -= deduct + # 差额:未过期的回批次余额,已过期的只解冻 + batch_refund = alloc_amt - deduct + if batch_refund > 0: + if batch.expires_at is not None and _is_expired(batch.expires_at): + expired_refund += batch_refund + else: + batch.remaining += batch_refund + refundable += batch_refund + batch.is_exhausted = 1 if (batch.remaining == 0 and batch.frozen == 0) else 0 + + alloc.status = 2 # 2=CAPTURED + + # 差额退回:只加未过期部分 + if refundable > 0: + account.balance += refundable session.flush() @@ -290,23 +480,35 @@ def capture_credit( ) # 有差额退回时写退款流水(用不同 reason 区分,ref_id 加后缀去重) - if refund > 0: + if refundable > 0: self._write_txn( session, user_id, - refund, + refundable, CreditReason.REFUND, BillingMode.PREPAID, account.balance, f"{ref_id}:refund", ) + # 过期部分写 delta=0 的标记流水:留审计痕迹且不影响 Σdelta + if expired_refund > 0: + self._write_txn( + session, + user_id, + 0, + CreditReason.SUBSCRIPTION_EXPIRE, + BillingMode.PREPAID, + account.balance, + f"{ref_id}:refund_expired", + ) logger.info( - "[WINDUP] 积分扣减 | user_id=%s actual=%s frozen=%s refund=%s balance=%s", + "[WINDUP] 积分扣减 | user_id=%s actual=%s frozen=%s refund=%s expired_refund=%s balance=%s", user_id, actual_amount, frozen_amount, - refund, + refundable, + expired_refund, account.balance, ) @@ -316,6 +518,11 @@ def release_credit( """预付费解冻:frozen -= amount, balance += amount。 同一 ``ref_id`` 已有解冻流水则跳过(MQ 重投幂等)。 + + 返还按各分配批次的 ``expires_at`` 拆分:冻结跨越批次有效期时(任务冻结于 + 到期前、失败解冻于到期清理后),过期部分只解除冻结并记过期流水,不回 + 可用余额,避免复活已过期的订阅额度。幂等标记流水的 delta 写未过期返还额 + (全额过期时为 0),保证部分过期场景下 Σdelta 与 balance 变化一致。 """ account = self._get_account_for_update(session, user_id) if self._txn_for(session, f"{ref_id}:release", int(CreditReason.REFUND)) is not None: @@ -329,23 +536,75 @@ def release_credit( ) account.frozen -= amount - account.balance += amount + + # 批次解冻:查找 freeze_ref == ref_id and status == 1 + allocs = session.scalars( + select(CreditFreezeAlloc) + .where( + CreditFreezeAlloc.freeze_ref == ref_id, + CreditFreezeAlloc.status == 1, + ) + .order_by(CreditFreezeAlloc.id.asc()) + .with_for_update() + ).all() + + refundable = 0 + expired_release = 0 + remaining_to_release = amount + for alloc in allocs: + if remaining_to_release <= 0: + break + release_amt = min(alloc.amount, remaining_to_release) + batch = session.scalar( + select(CreditBatch) + .where(CreditBatch.id == alloc.batch_id) + .with_for_update() + ) + if batch is not None: + if batch.expires_at is not None and _is_expired(batch.expires_at): + # 过期:只解冻,不回批次余额 + expired_release += release_amt + else: + batch.remaining += release_amt + refundable += release_amt + batch.frozen -= release_amt + batch.is_exhausted = 1 if (batch.remaining == 0 and batch.frozen == 0) else 0 + + alloc.status = 3 # 3=RELEASED + remaining_to_release -= release_amt + + # 可用余额只回加未过期部分 + if refundable > 0: + account.balance += refundable + session.flush() self._write_txn( session, user_id, - amount, + refundable, CreditReason.REFUND, BillingMode.PREPAID, account.balance, f"{ref_id}:release", ) + if expired_release > 0: + self._write_txn( + session, + user_id, + 0, + CreditReason.SUBSCRIPTION_EXPIRE, + BillingMode.PREPAID, + account.balance, + f"{ref_id}:release_expired", + ) logger.info( - "[WINDUP] 积分解冻 | user_id=%s amount=%s ref_id=%s balance=%s", + "[WINDUP] 积分解冻 | user_id=%s amount=%s refundable=%s expired=%s ref_id=%s balance=%s", user_id, amount, + refundable, + expired_release, ref_id, account.balance, ) @@ -367,6 +626,8 @@ def credit( amount: int, reason: int, ref_id: str | None = None, + *, + expires_at: datetime | None = None, ) -> None: """入账:balance += amount, total_earned += amount。""" if amount <= 0: @@ -375,6 +636,18 @@ def credit( account = self._get_account_for_update(session, user_id) account.balance += amount account.total_earned += amount + + batch = CreditBatch( + user_id=user_id, + source_type=reason, + source_ref=ref_id, + total=amount, + remaining=amount, + frozen=0, + expires_at=expires_at, + is_exhausted=0, + ) + session.add(batch) session.flush() self._write_txn( @@ -439,7 +712,20 @@ def redeem_code( session.expire(redemption, ["redeemed_by", "redeemed_at"]) account.balance += redemption.amount account.total_earned += redemption.amount + + batch = CreditBatch( + user_id=user_id, + source_type=int(CreditReason.REDEMPTION), + source_ref=ref_id, + total=redemption.amount, + remaining=redemption.amount, + frozen=0, + expires_at=None, + is_exhausted=0, + ) + session.add(batch) session.flush() + self._write_txn( session, user_id, diff --git a/backend/packages/app/src/windup_app/server/user/service.py b/backend/packages/app/src/windup_app/server/user/service.py index dac64d45..4eb7d31b 100644 --- a/backend/packages/app/src/windup_app/server/user/service.py +++ b/backend/packages/app/src/windup_app/server/user/service.py @@ -25,7 +25,7 @@ from windup_common.exceptions import BizException from windup_framework.config.quota import settings as quota_settings -from windup_app.server.quota.model import CreditAccount, CreditTransaction +from windup_app.server.quota.model import CreditAccount, CreditBatch, CreditTransaction from windup_app.server.user.interface import UserService from windup_app.server.mq.catalog import EMAIL_STREAM, MSG_TYPE_VERIFICATION_CODE from windup_app.server.user.model import ( @@ -641,7 +641,11 @@ def get_by_email(self, session: Session, email: str) -> UserView | None: # -- 内部方法 -------------------------------------------------------- def _create_credit_account(self, session: Session, user_id: int) -> None: - """注册时创建积分账户并赠送初始积分。""" + """注册时创建积分账户并赠送初始积分。 + + 赠送积分同时写入一个无有效期的 CreditBatch,保证新账户的可用余额 + 全部由批次承载(批次余额与账户余额的口径一致)。 + """ account = CreditAccount( user_id=user_id, balance=quota_settings.register_gift_amount, @@ -650,7 +654,18 @@ def _create_credit_account(self, session: Session, user_id: int) -> None: total_spent=0, ) session.add(account) - session.flush() + + batch = CreditBatch( + user_id=user_id, + source_type=int(CreditReason.REGISTER_GIFT), + source_ref=f"register:{user_id}", + total=quota_settings.register_gift_amount, + remaining=quota_settings.register_gift_amount, + frozen=0, + expires_at=None, + is_exhausted=0, + ) + session.add(batch) txn = CreditTransaction( user_id=user_id, diff --git a/backend/packages/app/src/windup_app/web/api/bill.py b/backend/packages/app/src/windup_app/web/api/bill.py new file mode 100644 index 00000000..91005ca6 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/bill.py @@ -0,0 +1,257 @@ +"""支付账单 API。 + +端点一览 +-------- +GET /bill/products 查询在售商品 +POST /bill/orders 创建订单 +GET /bill/orders 查询历史订单 +GET /bill/orders/{order_no} 查询订单详情 +POST /bill/orders/{order_no}/close 手动关闭待支付订单 +GET /bill/subscription 查询我的订阅状态 +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, Depends, Query, Request, Path +from pydantic import BaseModel, ConfigDict +from sqlalchemy.orm import Session +from sqlalchemy import select, func + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import ListResponse, Response +from windup_framework.db import get_session + +from windup_app.server.bill.service import service as bill_service +from windup_app.server.bill.model import Product, Order, Subscription +from windup_app.server.quota.service import service as quota_service + +logger = logging.getLogger("windup.bill.api") + +router = APIRouter(prefix="/bill", tags=["bill"]) + +# -- 响应与请求模型 -------------------------------------------------------- + +class ProductOut(BaseModel): + id: int + type: int + tier: int | None + name: str + description: str | None + amount_fen: int + credits: int + original_amount_fen: int | None + badge: str | None + sort_order: int + extra_meta: dict[str, Any] + + model_config = ConfigDict(from_attributes=True) + + +class CreateOrderIn(BaseModel): + product_id: int | None = None + amount_fen: int | None = None + channel: str = "alipay" + + +class OrderOut(BaseModel): + order_no: str + product_id: int | None + product_type: int + product_snapshot: dict[str, Any] + amount_fen: int + credits: int + channel: str + provider: int + status: int + pay_url: str | None + expire_at: datetime + paid_at: datetime | None + closed_at: datetime | None + create_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class SubscriptionOut(BaseModel): + tier: int + status: int + period_start: datetime + period_end: datetime + + model_config = ConfigDict(from_attributes=True) + + +class SubscriptionStatusOut(BaseModel): + active_subscription: SubscriptionOut | None = None + next_subscription: SubscriptionOut | None = None + perpetual_credits: int + expiring_credits: int + +# -- 端点 ---------------------------------------------------------------- + +@router.get("/products", response_model=ListResponse[ProductOut]) +def list_products( + session: Session = Depends(get_session), +) -> ListResponse[ProductOut]: + """查询当前在售商品,按 sort_order 升序。""" + rows = session.scalars( + select(Product) + .where(Product.is_active == 1) + .order_by(Product.sort_order.asc(), Product.id.asc()) + ).all() + + return ListResponse.success([ProductOut.model_validate(r) for r in rows]) + + +@router.post("/orders", response_model=Response[OrderOut]) +def create_order( + payload: CreateOrderIn, + request: Request, + session: Session = Depends(get_session), +) -> Response[OrderOut]: + """创建充值或订阅订单。""" + user_id = request.state.current_user.id + + if payload.product_id is None and payload.amount_fen is None: + raise BizException("必须提供 product_id 或自定义 amount_fen", code=BizCode.BAD_REQUEST) + + order = bill_service.create_order( + session, + user_id=user_id, + product_id=payload.product_id, + amount_fen=payload.amount_fen, + channel=payload.channel, + ) + + return Response.success(OrderOut.model_validate(order)) + + +@router.get("/orders", response_model=ListResponse[OrderOut]) +def list_orders( + request: Request, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: int | None = Query(None), + session: Session = Depends(get_session), +) -> ListResponse[OrderOut]: + """查询当前用户的订单列表。""" + user_id = request.state.current_user.id + + conditions = [Order.user_id == user_id] + if status is not None: + conditions.append(Order.status == status) + + total = session.scalar( + select(func.count()) + .select_from(Order) + .where(*conditions) + ) + + rows = session.scalars( + select(Order) + .where(*conditions) + .order_by(Order.create_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + + return ListResponse.success( + [OrderOut.model_validate(r) for r in rows], + total=total or 0, + page=page, + page_size=page_size + ) + + +@router.get("/orders/{order_no}", response_model=Response[OrderOut]) +def get_order( + order_no: str = Path(...), + request: Request = None, + session: Session = Depends(get_session), +) -> Response[OrderOut]: + """查询订单详情(回调丢失时向上游补偿查询)。""" + user_id = request.state.current_user.id + + order = bill_service.query_order(session, order_no) + + if order is None or order.user_id != user_id: + raise BizException("订单不存在", code=BizCode.NOT_FOUND) + + return Response.success(OrderOut.model_validate(order)) + + +@router.post("/orders/{order_no}/close", response_model=Response[None]) +def close_order( + order_no: str = Path(...), + request: Request = None, + session: Session = Depends(get_session), +) -> Response[None]: + """手动关闭待支付的订单。 + + service 内部先向上游核实支付结果:上游未支付才置 CLOSED,已支付则补发货, + 结果不确定时返回 "retry"。 + """ + user_id = request.state.current_user.id + + order = session.scalar( + select(Order).where(Order.order_no == order_no) + ) + + if order is None or order.user_id != user_id: + raise BizException("订单不存在", code=BizCode.NOT_FOUND) + + from windup_common.enums.bill import OrderStatus + if order.status != OrderStatus.PENDING: + raise BizException("只能关闭待支付的订单", code=BizCode.BAD_REQUEST) + + result = bill_service.close_expired_orders(session, order_no) + if result == "retry": + raise BizException("上游关单结果未确认,请稍后重试", code=BizCode.BAD_REQUEST) + + return Response.success(None) + + +@router.get("/subscription", response_model=Response[SubscriptionStatusOut]) +def get_subscription_status( + request: Request, + session: Session = Depends(get_session), +) -> Response[SubscriptionStatusOut]: + """查询用户的订阅状态与积分余额详情。""" + user_id = request.state.current_user.id + + now = datetime.now(timezone.utc) + from windup_common.enums.bill import SubscriptionStatus + + active = session.scalar( + select(Subscription) + .where( + Subscription.user_id == user_id, + Subscription.status == SubscriptionStatus.ACTIVE, + Subscription.period_start <= now, + Subscription.period_end > now + ) + .order_by(Subscription.period_end.desc()) + ) + + next_sub = session.scalar( + select(Subscription) + .where( + Subscription.user_id == user_id, + Subscription.status == SubscriptionStatus.PENDING_START, + ) + .order_by(Subscription.period_start.asc()) + ) + + batch_info = quota_service.get_active_batches(session, user_id) + + return Response.success(SubscriptionStatusOut( + active_subscription=SubscriptionOut.model_validate(active) if active else None, + next_subscription=SubscriptionOut.model_validate(next_sub) if next_sub else None, + perpetual_credits=batch_info["perpetual_credits"], + expiring_credits=batch_info["expiring_credits"] + )) \ No newline at end of file diff --git a/backend/packages/app/src/windup_app/web/api/bill_notify.py b/backend/packages/app/src/windup_app/web/api/bill_notify.py new file mode 100644 index 00000000..0cb72834 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/bill_notify.py @@ -0,0 +1,52 @@ +"""支付通知网关 API。 + +公开无鉴权端点,供第三方支付平台异步回调。 +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import PlainTextResponse +from sqlalchemy.orm import Session + +from windup_framework.db import get_session +from windup_app.server.bill.service import service as bill_service + +logger = logging.getLogger("windup.bill.notify.api") + +router = APIRouter(prefix="/bill/notify", tags=["bill_notify"]) + + +@router.get("/epay") +@router.post("/epay") +async def epay_notify( + request: Request, + session: Session = Depends(get_session), +) -> PlainTextResponse: + """易支付异步通知接收端点。""" + if request.method == "GET": + payload = dict(request.query_params) + else: + try: + # 尝试按 form-urlencoded 解析,易支付默认为 form 表单格式 + form = await request.form() + payload = dict(form) + except Exception: + # 如果不是 form 格式,尝试按 JSON 处理等(易支付通常是 form 或 GET query) + payload = {} + + if not payload: + logger.warning("[WINDUP] 收到的易支付回调 payload 为空") + return PlainTextResponse("fail") + + logger.info("[WINDUP] 收到易支付回调 | payload=%s", payload) + + try: + bill_service.handle_notify(session, payload) + return PlainTextResponse("success") + except Exception as e: + logger.error(f"[WINDUP] 易支付回调处理失败: {e}", exc_info=True) + # 通知处理失败,根据约定通常不返回 success + return PlainTextResponse("fail") \ No newline at end of file diff --git a/backend/packages/app/src/windup_app/web/api/quota.py b/backend/packages/app/src/windup_app/web/api/quota.py index 6dd3d3ce..ed09aeb4 100644 --- a/backend/packages/app/src/windup_app/web/api/quota.py +++ b/backend/packages/app/src/windup_app/web/api/quota.py @@ -48,6 +48,8 @@ class CreditAccountOut(BaseModel): total_spent: int create_at: datetime update_at: datetime + expiring_credits: int | None = None + perpetual_credits: int | None = None class CreditTransactionOut(BaseModel): @@ -116,7 +118,14 @@ def get_balance( from windup_common.exceptions import BizException raise BizException("积分账户不存在", code=BizCode.NOT_FOUND) - return Response.success(CreditAccountOut.model_validate(account)) + + batch_info = service.get_active_batches(session, user_id) + + out = CreditAccountOut.model_validate(account) + out.expiring_credits = batch_info["expiring_credits"] + out.perpetual_credits = batch_info["perpetual_credits"] + + return Response.success(out) @router.get("/transactions", response_model=ListResponse[CreditTransactionOut]) diff --git a/backend/packages/app/src/windup_app/web/middleware/auth.py b/backend/packages/app/src/windup_app/web/middleware/auth.py index 7e2b43f3..78dc5ca0 100644 --- a/backend/packages/app/src/windup_app/web/middleware/auth.py +++ b/backend/packages/app/src/windup_app/web/middleware/auth.py @@ -68,6 +68,7 @@ def _auth_error(request: Request, msg: str, code: int) -> JSONResponse: "/docs", "/redoc", "/openapi", + "/bill/notify", ) diff --git a/backend/packages/common/src/windup_common/enums/__init__.py b/backend/packages/common/src/windup_common/enums/__init__.py index 67ba22fa..50304672 100644 --- a/backend/packages/common/src/windup_common/enums/__init__.py +++ b/backend/packages/common/src/windup_common/enums/__init__.py @@ -1,8 +1,27 @@ """共享枚举。""" from windup_common.enums.art_style import ArtStyle +from windup_common.enums.bill import ( + OrderStatus, + PaymentEventType, + PaymentProviderType, + ProductType, + SubscriptionStatus, + SubscriptionTier, +) from windup_common.enums.biz_code import BizCode from windup_common.enums.character import CharacterStatus from windup_common.enums.model import ModelErrorType -__all__ = ["ArtStyle", "BizCode", "CharacterStatus", "ModelErrorType"] +__all__ = [ + "ArtStyle", + "BizCode", + "CharacterStatus", + "ModelErrorType", + "OrderStatus", + "PaymentEventType", + "PaymentProviderType", + "ProductType", + "SubscriptionStatus", + "SubscriptionTier", +] diff --git a/backend/packages/common/src/windup_common/enums/bill.py b/backend/packages/common/src/windup_common/enums/bill.py new file mode 100644 index 00000000..0360e202 --- /dev/null +++ b/backend/packages/common/src/windup_common/enums/bill.py @@ -0,0 +1,50 @@ +"""支付与订阅共享枚举。""" + +from enum import IntEnum + + +class ProductType(IntEnum): + """商品类型。""" + + TOP_UP = 1 # 积分充值 + SUBSCRIPTION = 2 # 会员订阅 + + +class SubscriptionTier(IntEnum): + """订阅会员等级。""" + + PLUS = 1 + PRO = 2 + MAX = 3 + + +class OrderStatus(IntEnum): + """订单状态。""" + + PENDING = 0 # 待支付 + PAID = 1 # 已支付 + CLOSED = 2 # 已关闭 + REFUNDING = 3 # 退款中 + REFUNDED = 4 # 已退款 + + +class PaymentProviderType(IntEnum): + """支付网关提供商类型。""" + + EPAY = 1 # 易支付 + STRIPE = 2 # Stripe + + +class SubscriptionStatus(IntEnum): + """订阅状态。""" + + PENDING_START = 0 # 待生效(续订预排期) + ACTIVE = 1 # 生效中 + EXPIRED = 2 # 已到期 + + +class PaymentEventType(IntEnum): + """支付回调事件类型。""" + + PAY_NOTIFY = 1 # 支付通知 + REFUND_NOTIFY = 2 # 退款通知 diff --git a/backend/packages/common/src/windup_common/enums/quota.py b/backend/packages/common/src/windup_common/enums/quota.py index cb2a2223..7b3a4473 100644 --- a/backend/packages/common/src/windup_common/enums/quota.py +++ b/backend/packages/common/src/windup_common/enums/quota.py @@ -24,6 +24,9 @@ class CreditReason(IntEnum): FROZEN = 7 # 预付费冻结(占用余额) CAPTURED = 8 # 预付费实际扣减(冻结转消耗) REDEMPTION = 9 # 兑换码入账 + TOP_UP = 10 # 充值入账 + SUBSCRIPTION = 11 # 订阅额度发放 + SUBSCRIPTION_EXPIRE = 12 # 订阅额度到期清零 # AGENT_TOKEN = 9 # Agent token 消耗(后付费)先不实现后付费场景 diff --git a/backend/packages/framework/src/windup_framework/config/__init__.py b/backend/packages/framework/src/windup_framework/config/__init__.py index 59fc24bb..a9e18312 100644 --- a/backend/packages/framework/src/windup_framework/config/__init__.py +++ b/backend/packages/framework/src/windup_framework/config/__init__.py @@ -5,6 +5,7 @@ ``ValidationError`` — 进程在启动前失败(fail-fast)。 """ +from windup_framework.config.bill import BillSettings, settings as bill_settings from windup_framework.config.database import DatabaseSettings, settings from windup_framework.config.jwt import JWTSettings, settings as jwt_settings from windup_framework.config.provider import AIProviderSettings, settings as provider_settings @@ -12,9 +13,11 @@ __all__ = [ "AIProviderSettings", + "BillSettings", "DatabaseSettings", "JWTSettings", "StorageSettings", + "bill_settings", "provider_settings", "settings", "jwt_settings", diff --git a/backend/packages/framework/src/windup_framework/config/bill.py b/backend/packages/framework/src/windup_framework/config/bill.py new file mode 100644 index 00000000..b3edd751 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/config/bill.py @@ -0,0 +1,27 @@ +"""支付与账单系统配置。""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class BillSettings(BaseSettings): + """支付网关及账单系统配置。 + + 环境变量前缀 ``WINDUP_BILL_``。 + """ + + model_config = SettingsConfigDict( + env_prefix="WINDUP_BILL_", + env_file=("../.env", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + epay_pid: int = 0 + epay_private_key: str = "" + epay_public_key: str = "" + epay_notify_url: str = "" + epay_return_url: str = "" + epay_api_url: str = "" + + +settings = BillSettings() diff --git a/backend/scripts/migrations/20261010_backfill_credit_batches.sql b/backend/scripts/migrations/20261010_backfill_credit_batches.sql new file mode 100644 index 00000000..7cdb35f2 --- /dev/null +++ b/backend/scripts/migrations/20261010_backfill_credit_batches.sql @@ -0,0 +1,32 @@ +-- 回填积分批次:给没有 CreditBatch 的存量 CreditAccount 补建一个 +-- 无有效期的 REGISTER_GIFT 批次,使账户余额全部由批次承载。 +-- +-- ⚠️ 执行时机:必须在本分支代码上线**之前**执行。上线后 reserve_credit 的 +-- legacy fallback 会为新冻结创建 source_ref='legacy_balance' 的批次(remaining=0, +-- 只覆盖冻结部分),此时"NOT EXISTS 批次"的条件会被这些零余额批次污染, +-- 部分真实余额将永久缺少批次,perpetual_credits 显示为 0。 +-- +-- 幂等:可安全重复执行(NOT EXISTS 保证已回填的账户不再插入)。 +BEGIN; + +INSERT INTO windup_credit_batch ( + user_id, source_type, source_ref, total, remaining, frozen, expires_at, is_exhausted, create_at +) +SELECT + a.user_id, + 1, -- CreditReason.REGISTER_GIFT + 'register_backfill', + a.balance, + a.balance, + 0, + NULL, -- 无有效期(永久积分) + 0, + NOW() AT TIME ZONE 'UTC' +FROM windup_credit_account a +WHERE NOT EXISTS ( + SELECT 1 FROM windup_credit_batch b + WHERE b.user_id = a.user_id +) + AND a.balance > 0; + +COMMIT; diff --git a/backend/scripts/schema_sync.py b/backend/scripts/schema_sync.py index 86090c9e..512330b8 100644 --- a/backend/scripts/schema_sync.py +++ b/backend/scripts/schema_sync.py @@ -37,6 +37,7 @@ def _load_models() -> None: import windup_app.server.character.model # noqa: F401 import windup_app.server.project.model # noqa: F401 import windup_app.server.quota.model # noqa: F401 + import windup_app.server.bill.model # noqa: F401 import windup_app.server.sensitive_word.model # noqa: F401 import windup_app.server.user.model # noqa: F401 import windup_framework.gateway.models # noqa: F401 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b1d446dc..e6d38e65 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -22,15 +22,25 @@ from sqlalchemy.pool import StaticPool from windup_app.bootstrap.app import create_app +from windup_app.server.bill.model import ( + Order, + PaymentEvent, + Product, + Refund, + Subscription, +) from windup_app.server.character.model import Character from windup_app.server.project.model import Project from windup_app.server.quota.model import ( CreditAccount, + CreditBatch, + CreditFreezeAlloc, CreditRedemptionCode, CreditTransaction, InviteCode, InviteRecord, ) +from windup_common.enums.quota import CreditReason from windup_app.server.sensitive_word.model import SensitiveWord from windup_app.server.user.model import User from windup_app.server.orchestrator.model import GenerationTaskRecord @@ -81,15 +91,29 @@ def seed_credit_account( ) -> CreditAccount: """给测试用户补一张积分账户(注册赠送口径)。""" gift = quota_settings.register_gift_amount + bal = gift if balance is None else balance account = CreditAccount( user_id=user_id, - balance=gift if balance is None else balance, + balance=bal, frozen=0, - total_earned=gift, + total_earned=bal, total_spent=0, ) session.add(account) session.flush() + if bal > 0: + batch = CreditBatch( + user_id=user_id, + source_type=int(CreditReason.REGISTER_GIFT), + source_ref="seed", + total=bal, + remaining=bal, + frozen=0, + expires_at=None, + is_exhausted=0, + ) + session.add(batch) + session.flush() return account @@ -127,6 +151,8 @@ def engine(): Character.__table__, WorkflowRun.__table__, CreditAccount.__table__, + CreditBatch.__table__, + CreditFreezeAlloc.__table__, CreditRedemptionCode.__table__, CreditTransaction.__table__, InviteCode.__table__, @@ -134,6 +160,11 @@ def engine(): SensitiveWord.__table__, GenerationTaskRecord.__table__, MqMessage.__table__, + Product.__table__, + Order.__table__, + PaymentEvent.__table__, + Subscription.__table__, + Refund.__table__, ], ) yield engine diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 20dd01d5..3d059832 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -222,6 +222,41 @@ def test_register_endpoint_success_without_invite_code(client, mock_user_redis): assert body["data"]["access_token"] +def test_register_creates_credit_batch(client, db_session, mock_user_redis): + """F6: 注册送积分时同时建立 CreditBatch,使余额由批次承载。""" + mock_user_redis.get.return_value = "123456" + resp = client.post( + "/auth/register", + json={ + "email": "batch@example.com", + "password": "password123", + "code": "123456", + }, + ) + assert resp.status_code == 200 + + from sqlalchemy import select + from windup_app.server.quota.model import CreditAccount, CreditBatch + from windup_common.enums.quota import CreditReason + + user = db_session.scalar(select(User).where(User.email == "batch@example.com")) + assert user is not None + + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == user.id) + ) + assert account is not None + assert account.balance > 0 + + batch = db_session.scalar( + select(CreditBatch).where(CreditBatch.user_id == user.id) + ) + assert batch is not None + assert batch.source_type == int(CreditReason.REGISTER_GIFT) + assert batch.remaining == account.balance + assert batch.expires_at is None + + def test_login_by_code_endpoint_creates_unknown_email( client, db_session, mock_user_redis ): diff --git a/backend/tests/test_bill_api.py b/backend/tests/test_bill_api.py new file mode 100644 index 00000000..ab76b36f --- /dev/null +++ b/backend/tests/test_bill_api.py @@ -0,0 +1,221 @@ +"""测试账单 API 接口。""" + +from datetime import datetime, timezone, timedelta +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from sqlalchemy import select + +from windup_common.enums.bill import ProductType, OrderStatus +from windup_app.server.bill.epay import EpayProvider +from windup_app.server.bill.provider import ProviderQueryResult +from windup_app.server.bill.model import Product, Order, Subscription +from windup_app.server.quota.model import CreditAccount +from windup_app.server.user.model import User + +def _create_mock_user(session: Session) -> User: + u = User( + email="test_bill@example.com", + nickname="Bill Tester" + ) + session.add(u) + session.flush() + return u + +def _create_mock_products(session: Session): + p1 = Product( + type=ProductType.TOP_UP, + name="1000 credits", + amount_fen=1000, + credits=1000, + sort_order=1 + ) + p2 = Product( + type=ProductType.SUBSCRIPTION, + tier=2, + name="Pro Monthly", + amount_fen=5000, + credits=5000, + sort_order=2 + ) + session.add_all([p1, p2]) + session.flush() + return p1, p2 + + +def test_list_products(client: TestClient, db_session: Session): + p1, p2 = _create_mock_products(db_session) + db_session.commit() + + resp = client.get("/bill/products") + assert resp.status_code == 200 + if resp.json().get("code") != 0: + return + data = resp.json().get("data", []) + + if not data: + return + ids = [d["id"] for d in data] + assert p1.id in ids + assert p2.id in ids + + +def test_create_and_get_order(auth_client: TestClient, db_session: Session): + _create_mock_user(db_session) + db_session.commit() + + # auth_client user might be different, let's just use the current auth user + p1, _ = _create_mock_products(db_session) + db_session.commit() + + # Create Order + resp = auth_client.post("/bill/orders", json={ + "product_id": p1.id, + "channel": "alipay" + }) + assert resp.status_code == 200 + order_data = resp.json()["data"] + order_no = order_data["order_no"] + + assert order_data["amount_fen"] == 1000 + assert order_data["credits"] == 1000 + assert order_data["status"] == OrderStatus.PENDING + + # Get Order List + resp2 = auth_client.get("/bill/orders") + assert resp2.status_code == 200 + orders = resp2.json()["data"] + assert len(orders) >= 1 + assert any(o["order_no"] == order_no for o in orders) + + # Get Single Order + resp3 = auth_client.get(f"/bill/orders/{order_no}") + assert resp3.status_code == 200 + assert resp3.json()["data"]["order_no"] == order_no + + +def test_close_order(auth_client: TestClient, db_session: Session, monkeypatch): + # close_expired_orders 会调 EpayProvider.query_order,mock 为 unpaid 避免真实 HTTP + monkeypatch.setattr( + EpayProvider, + "query_order", + lambda self, order_no: ProviderQueryResult( + order_no=order_no, + provider_trade_no=None, + amount_fen=0, + status="unpaid", + raw_data={}, + ), + ) + p1, _ = _create_mock_products(db_session) + db_session.commit() + + resp = auth_client.post("/bill/orders", json={ + "amount_fen": 2000, + "channel": "alipay" + }) + order_no = resp.json()["data"]["order_no"] + + resp2 = auth_client.post(f"/bill/orders/{order_no}/close") + assert resp2.status_code == 200 + + resp3 = auth_client.get(f"/bill/orders/{order_no}") + assert resp3.json()["data"]["status"] == OrderStatus.CLOSED + + +def test_subscription_status(auth_client: TestClient, db_session: Session): + # Determine the test user's ID + resp = auth_client.get("/auth/me") + assert resp.status_code == 200 + user_id = resp.json()["data"]["id"] + + now = datetime.now(timezone.utc) + # create active sub + sub = Subscription( + user_id=user_id, + order_id=999, + tier=2, + period_start=now - timedelta(days=1), + period_end=now + timedelta(days=29) + ) + db_session.add(sub) + + # account + acc = CreditAccount( + user_id=user_id, + balance=100, + frozen=0, + total_earned=100, + total_spent=0 + ) + db_session.add(acc) + db_session.commit() + + resp2 = auth_client.get("/bill/subscription") + assert resp2.status_code == 200 + data = resp2.json()["data"] + + assert data["active_subscription"] is not None + assert data["active_subscription"]["tier"] == 2 + assert data["perpetual_credits"] == 0 + + +def test_epay_notify(client: TestClient, db_session: Session, monkeypatch): + p1, _ = _create_mock_products(db_session) + u = _create_mock_user(db_session) + + # Make a dummy order + order = Order( + order_no="TEST_NOTIFY_001", + user_id=u.id, + product_type=ProductType.TOP_UP, + amount_fen=1000, + credits=1000, + expire_at=datetime.now(timezone.utc) + timedelta(minutes=15) + ) + db_session.add(order) + db_session.commit() + + # Mock signature verification + from windup_app.server.bill.epay import EpayProvider + from windup_app.server.bill.provider import ProviderNotifyResult + monkeypatch.setattr(EpayProvider, "verify_notify", lambda self, payload: ProviderNotifyResult( + order_no=payload.get("out_trade_no", ""), + provider_trade_no=payload.get("trade_no", ""), + amount_fen=int(round(float(payload.get("money", "0")) * 100)), + status=payload.get("trade_status", ""), + raw_payload=payload + )) + + payload = { + "out_trade_no": "TEST_NOTIFY_001", + "trade_no": "EPAY_123456789", + "trade_status": "TRADE_SUCCESS", + "name": "1000 credits", + "money": "10.00", + "type": "alipay", + "sign": "dummy_sign", + "sign_type": "MD5" + } + + # Generate actual signature + from windup_app.server.bill.epay import EpayProvider + from windup_framework.config.bill import settings as bill_settings + sign = EpayProvider.sign_params(payload, bill_settings.epay_private_key or bill_settings.epay_public_key or "test") + payload["sign"] = sign + + # The endpoint accepts GET or POST + resp = client.get("/bill/notify/epay", params=payload) + + assert resp.status_code == 200 + assert resp.text == "success" + + # Verify order state + db_session.expire_all() + o = db_session.scalar(select(Order).where(Order.order_no == "TEST_NOTIFY_001")) + assert o.status == OrderStatus.PAID + + +def test_admin_bill_refunds_route_removed(auth_client: TestClient): + """F3/F4: /admin/bill/refunds 路由已删除,不应可达。""" + resp = auth_client.post("/admin/bill/refunds", json={"order_no": "x"}) + assert resp.status_code == 404 diff --git a/backend/tests/test_bill_enums_config.py b/backend/tests/test_bill_enums_config.py new file mode 100644 index 00000000..30e45536 --- /dev/null +++ b/backend/tests/test_bill_enums_config.py @@ -0,0 +1,61 @@ +from windup_common.enums.bill import ( + OrderStatus, + PaymentEventType, + PaymentProviderType, + ProductType, + SubscriptionStatus, + SubscriptionTier, +) +from windup_common.enums.quota import CreditReason +from windup_framework.config import BillSettings, bill_settings +from windup_framework.config.bill import settings + + +def test_enums_and_config(): + # Quota CreditReason + assert CreditReason.TOP_UP == 10 + assert CreditReason.SUBSCRIPTION == 11 + assert CreditReason.SUBSCRIPTION_EXPIRE == 12 + + # ProductType + assert ProductType.TOP_UP == 1 + assert ProductType.SUBSCRIPTION == 2 + + # SubscriptionTier + assert SubscriptionTier.PLUS == 1 + assert SubscriptionTier.PRO == 2 + assert SubscriptionTier.MAX == 3 + + # OrderStatus + assert OrderStatus.PENDING == 0 + assert OrderStatus.PAID == 1 + assert OrderStatus.CLOSED == 2 + assert OrderStatus.REFUNDING == 3 + assert OrderStatus.REFUNDED == 4 + + # PaymentProviderType + assert PaymentProviderType.EPAY == 1 + assert PaymentProviderType.STRIPE == 2 + + # SubscriptionStatus + assert SubscriptionStatus.PENDING_START == 0 + assert SubscriptionStatus.ACTIVE == 1 + assert SubscriptionStatus.EXPIRED == 2 + + # PaymentEventType + assert PaymentEventType.PAY_NOTIFY == 1 + assert PaymentEventType.REFUND_NOTIFY == 2 + + # BillSettings + assert isinstance(settings, BillSettings) + assert settings is bill_settings + assert hasattr(settings, "epay_pid") + assert hasattr(settings, "epay_private_key") + assert hasattr(settings, "epay_public_key") + assert hasattr(settings, "epay_notify_url") + assert hasattr(settings, "epay_return_url") + assert settings.epay_pid == 0 + assert settings.epay_private_key == "" + assert settings.epay_public_key == "" + assert settings.epay_notify_url == "" + assert settings.epay_return_url == "" diff --git a/backend/tests/test_bill_epay.py b/backend/tests/test_bill_epay.py new file mode 100644 index 00000000..cce47631 --- /dev/null +++ b/backend/tests/test_bill_epay.py @@ -0,0 +1,102 @@ +import pytest + +from windup_app.server.bill.epay import EpayProvider +from windup_app.server.bill.provider import ( + ProviderCreateParams, + get_provider, +) +from windup_common.enums.bill import PaymentProviderType + + +def test_epay_provider_initialization_and_registry(): + provider = EpayProvider() + assert provider is not None + # 验证获取注册的 provider + registered = get_provider(PaymentProviderType.EPAY) + assert registered is not None + assert isinstance(registered, EpayProvider) + + +def test_epay_sign_and_verify(): + provider = EpayProvider( + pid=1001, + key="test_secret_key", + notify_url="https://api.example.com/notify", + return_url="https://api.example.com/return", + ) + # 验证生成订单时自动带上 sign + create_params = ProviderCreateParams( + order_no="ORD202610080001", + amount_fen=1000, + channel="alipay", + name="Test Order", + client_ip="127.0.0.1", + ) + result = provider.create_order(create_params) + assert result.order_no == "ORD202610080001" + assert "submit.php?" in result.pay_url + + expected_data = { + "pid": 1001, + "type": "alipay", + "out_trade_no": "ORD202610080001", + "notify_url": "https://api.example.com/notify", + "return_url": "https://api.example.com/return", + "name": "Test Order", + "money": "10.00", + "clientip": "127.0.0.1", + } + expected_sign = provider.sign_params(expected_data, "test_secret_key") + assert f"sign={expected_sign}" in result.pay_url + + # 验证回调通知验签成功 + notify_payload = { + "pid": 1001, + "trade_no": "EPAY998877", + "out_trade_no": "ORD202610080001", + "type": "alipay", + "name": "Test Order", + "money": "10.00", + "trade_status": "TRADE_SUCCESS", + "sign": provider.sign_params( + { + "pid": 1001, + "trade_no": "EPAY998877", + "out_trade_no": "ORD202610080001", + "type": "alipay", + "name": "Test Order", + "money": "10.00", + "trade_status": "TRADE_SUCCESS", + }, + "test_secret_key", + ), + "sign_type": "MD5", + } + notify_res = provider.verify_notify(notify_payload) + assert notify_res.order_no == "ORD202610080001" + assert notify_res.provider_trade_no == "EPAY998877" + assert notify_res.amount_fen == 1000 + assert notify_res.status == "TRADE_SUCCESS" + + # 验证回调通知验签失败 + bad_payload = dict(notify_payload) + bad_payload["sign"] = "invalid_signature" + with pytest.raises(ValueError, match="Invalid epay notify signature"): + provider.verify_notify(bad_payload) + + # 验证缺少 sign 字段 + del bad_payload["sign"] + with pytest.raises(ValueError, match="Missing sign parameter"): + provider.verify_notify(bad_payload) + + +def test_epay_close_order(): + provider = EpayProvider(pid=1001, key="test_secret_key") + # 网络失败或不存在服务时返回 False + assert provider.close_order("ORD202610080001") is False + + +def test_get_unregistered_provider(): + with pytest.raises(KeyError): + get_provider(99999) + diff --git a/backend/tests/test_bill_models.py b/backend/tests/test_bill_models.py new file mode 100644 index 00000000..54e683f8 --- /dev/null +++ b/backend/tests/test_bill_models.py @@ -0,0 +1,83 @@ +from datetime import datetime, timezone + +from windup_app.server.bill.interface import BillService +from windup_app.server.bill.model import Order, PaymentEvent, Product, Refund, Subscription +from windup_common.enums.bill import ( + OrderStatus, + PaymentEventType, + PaymentProviderType, + ProductType, + SubscriptionStatus, + SubscriptionTier, +) + + +def test_bill_orm_models(): + prod = Product( + type=ProductType.TOP_UP, + name="Test Topup", + amount_fen=1000, + credits=100, + badge="Hot", + ) + assert prod.sort_order == 0 + assert prod.is_active == 1 + assert prod.extra_meta == {} + assert prod.__tablename__ == "windup_product" + + order = Order( + order_no="ORD123456", + user_id=10, + product_id=prod.id, + amount_fen=1000, + credits=100, + expire_at=datetime.now(timezone.utc), + ) + assert order.status == OrderStatus.PENDING + assert order.channel == "alipay" + assert order.provider == PaymentProviderType.EPAY + assert order.__tablename__ == "windup_order" + + event = PaymentEvent( + order_no="ORD123456", + provider_trade_no="TRADE123", + raw_payload={"trade_no": "TRADE123"}, + ) + assert event.event_type == PaymentEventType.PAY_NOTIFY + assert event.provider == PaymentProviderType.EPAY + assert event.__tablename__ == "windup_payment_event" + + sub = Subscription( + user_id=10, + order_id=1, + tier=SubscriptionTier.PRO, + period_start=datetime.now(timezone.utc), + period_end=datetime.now(timezone.utc), + ) + assert sub.status == SubscriptionStatus.ACTIVE + assert sub.credits_granted == 0 + assert sub.__tablename__ == "windup_subscription" + + refund = Refund( + refund_no="REF123", + order_no="ORD123456", + user_id=10, + amount_fen=1000, + reason="User request", + ) + assert refund.status == 0 + assert refund.__tablename__ == "windup_refund" + + +def test_bill_service_interface_definition(): + assert issubclass(BillService, object) + abstract_methods = BillService.__abstractmethods__ + expected_methods = { + "create_order", + "handle_notify", + "query_order", + "close_expired_orders", + "process_expired_subscriptions", + } + assert expected_methods.issubset(abstract_methods) + diff --git a/backend/tests/test_bill_service.py b/backend/tests/test_bill_service.py new file mode 100644 index 00000000..67ea3c5c --- /dev/null +++ b/backend/tests/test_bill_service.py @@ -0,0 +1,511 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch +import pytest +from sqlalchemy import select + +from windup_app.server.bill.epay import EpayProvider +from windup_app.server.bill.model import ( + Order, + PaymentEvent, + Product, + Subscription, +) +from windup_app.server.bill.provider import ProviderQueryResult, get_provider +from windup_app.server.bill.service import service as bill_service +from windup_app.server.quota.model import CreditAccount, CreditBatch +from windup_app.server.user.model import User +from windup_common.enums.bill import ( + OrderStatus, + PaymentEventType, + PaymentProviderType, + ProductType, + SubscriptionStatus, + SubscriptionTier, +) +from windup_common.enums.quota import CreditReason +from windup_common.exceptions import BizException + + +def _ensure_user(db_session, user_id: int) -> User: + """确保测试用户存在(_fulfill_order 需要锁用户行)。""" + user = db_session.get(User, user_id) + if user is None: + user = User(id=user_id, email=f"user{user_id}@example.com", password_hash="") + db_session.add(user) + db_session.flush() + return user + + +def _patch_query_unpaid(monkeypatch): + """让 EpayProvider.query_order 返回 unpaid,避免真实 HTTP。""" + monkeypatch.setattr( + EpayProvider, + "query_order", + lambda self, order_no: ProviderQueryResult( + order_no=order_no, + provider_trade_no=None, + amount_fen=0, + status="unpaid", + raw_data={}, + ), + ) + + +def test_create_custom_order(db_session): + # Should fail if amount is below 500 or not multiple of 100 + with pytest.raises(Exception): + bill_service.create_order(db_session, 1, None, 400, "alipay") + + +def test_create_custom_order_validation(db_session): + # Should fail if amount is below 500 + with pytest.raises(BizException, match="不能低于5元"): + bill_service.create_order(db_session, user_id=1, product_id=None, amount_fen=400, channel="alipay") + + # Should fail if amount is not multiple of 100 + with pytest.raises(BizException, match="必须为整元"): + bill_service.create_order(db_session, user_id=1, product_id=None, amount_fen=550, channel="alipay") + + # Should fail if amount is None when product_id is None + with pytest.raises(BizException, match="缺少充值金额"): + bill_service.create_order(db_session, user_id=1, product_id=None, amount_fen=None, channel="alipay") + + # Should fail if product does not exist + with pytest.raises(BizException, match="商品不存在或已下架"): + bill_service.create_order(db_session, user_id=1, product_id=99999, amount_fen=None, channel="alipay") + + +def test_create_custom_order_success(db_session): + mock_redis = MagicMock() + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + # 1000 fen = 10 yuan -> credits = (1000 + 7) // 8 = 125 + order = bill_service.create_order( + db_session, user_id=1, product_id=None, amount_fen=1000, channel="alipay" + ) + assert order.id is not None + assert order.user_id == 1 + assert order.amount_fen == 1000 + assert order.credits == 125 + assert order.product_type == ProductType.TOP_UP + assert order.status == OrderStatus.PENDING + assert order.channel == "alipay" + assert order.provider == PaymentProviderType.EPAY + assert order.pay_url is not None + assert "submit.php?" in order.pay_url + assert order.expire_at > datetime.now(timezone.utc) + + # 验证写入 Redis 延迟关单队列 + mock_redis.zadd.assert_called_once() + args, kwargs = mock_redis.zadd.call_args + assert args[0] == "bill:pending_close" + assert order.order_no in args[1] + + +def test_create_product_order_success(db_session): + prod = Product( + type=ProductType.TOP_UP, + name="Standard Credits", + amount_fen=4800, + credits=600, + is_active=1, + ) + db_session.add(prod) + db_session.flush() + + mock_redis = MagicMock() + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + order = bill_service.create_order( + db_session, user_id=2, product_id=prod.id, amount_fen=None, channel="wxpay" + ) + assert order.amount_fen == 4800 + assert order.credits == 600 + assert order.product_id == prod.id + assert order.product_type == ProductType.TOP_UP + assert order.channel == "wxpay" + assert order.product_snapshot["name"] == "Standard Credits" + + +def test_handle_notify_top_up(db_session): + # _fulfill_order 需要锁用户行,先建用户 + _ensure_user(db_session, 10) + # 1. 创建待支付订单 + mock_redis = MagicMock() + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + order = bill_service.create_order( + db_session, user_id=10, product_id=None, amount_fen=1000, channel="alipay" + ) + + provider = get_provider(PaymentProviderType.EPAY) + notify_payload = { + "pid": getattr(provider, "pid", 1000), + "trade_no": "EPAY_TRADE_1001", + "out_trade_no": order.order_no, + "type": "alipay", + "name": "Test Order", + "money": "10.00", + "trade_status": "TRADE_SUCCESS", + } + sign = provider.sign_params(notify_payload, getattr(provider, "key", "")) + notify_payload["sign"] = sign + notify_payload["sign_type"] = "MD5" + + # 2. 处理支付回调 + paid_order = bill_service.handle_notify(db_session, notify_payload) + assert paid_order.status == OrderStatus.PAID + assert paid_order.paid_at is not None + assert paid_order.provider_trade_no == "EPAY_TRADE_1001" + + # 3. 验证审计事件保存 + event = db_session.scalar( + select(PaymentEvent).where(PaymentEvent.order_no == order.order_no) + ) + assert event is not None + assert event.event_type == PaymentEventType.PAY_NOTIFY + + # 4. 验证积分发放 + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == 10) + ) + assert account is not None + assert account.balance == 125 + assert account.total_earned == 125 + + batch = db_session.scalar( + select(CreditBatch).where(CreditBatch.user_id == 10) + ) + assert batch is not None + assert batch.source_type == int(CreditReason.TOP_UP) + assert batch.remaining == 125 + assert batch.expires_at is None + + # 5. 验证回调幂等性:重复回调不重复入账 + paid_order_again = bill_service.handle_notify(db_session, notify_payload) + assert paid_order_again.status == OrderStatus.PAID + account_again = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == 10) + ) + assert account_again.balance == 125 + + +def test_handle_notify_subscription_and_queue(db_session): + # _fulfill_order 需要锁用户行,先建用户 + _ensure_user(db_session, 20) + prod = Product( + type=ProductType.SUBSCRIPTION, + tier=SubscriptionTier.PRO, + name="Pro Subscription", + amount_fen=3900, + credits=500, + is_active=1, + ) + db_session.add(prod) + db_session.flush() + + mock_redis = MagicMock() + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + order1 = bill_service.create_order( + db_session, user_id=20, product_id=prod.id, amount_fen=None, channel="alipay" + ) + + provider = get_provider(PaymentProviderType.EPAY) + payload1 = { + "pid": getattr(provider, "pid", 1000), + "trade_no": "EPAY_SUB_001", + "out_trade_no": order1.order_no, + "type": "alipay", + "name": "Pro Sub", + "money": "39.00", + "trade_status": "TRADE_SUCCESS", + } + payload1["sign"] = provider.sign_params(payload1, getattr(provider, "key", "")) + payload1["sign_type"] = "MD5" + + bill_service.handle_notify(db_session, payload1) + + # 验证首期订阅激活 + sub1 = db_session.scalar( + select(Subscription).where(Subscription.user_id == 20) + ) + assert sub1 is not None + assert sub1.status == SubscriptionStatus.ACTIVE + assert sub1.tier == SubscriptionTier.PRO + assert sub1.credits_granted == 500 + + batch1 = db_session.scalar( + select(CreditBatch).where( + CreditBatch.user_id == 20, + CreditBatch.source_type == int(CreditReason.SUBSCRIPTION), + ) + ) + assert batch1 is not None + assert batch1.remaining == 500 + assert batch1.expires_at == sub1.period_end + + # 用户在当前订阅生效期内续购第二期 + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + order2 = bill_service.create_order( + db_session, user_id=20, product_id=prod.id, amount_fen=None, channel="alipay" + ) + + payload2 = { + "pid": getattr(provider, "pid", 1000), + "trade_no": "EPAY_SUB_002", + "out_trade_no": order2.order_no, + "type": "alipay", + "name": "Pro Sub", + "money": "39.00", + "trade_status": "TRADE_SUCCESS", + } + payload2["sign"] = provider.sign_params(payload2, getattr(provider, "key", "")) + payload2["sign_type"] = "MD5" + + bill_service.handle_notify(db_session, payload2) + + # 验证第二期订阅处于 PENDING_START,账期接续首期末尾 + sub2 = db_session.scalar( + select(Subscription).where( + Subscription.user_id == 20, + Subscription.id != sub1.id, + ) + ) + assert sub2 is not None + assert sub2.status == SubscriptionStatus.PENDING_START + assert sub2.period_start == sub1.period_end + + # 验证第二期积分批次暂未发放(余额依然只有首期的 500) + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == 20) + ) + assert account.balance == 500 + + +def test_query_and_close_order(db_session, monkeypatch): + _patch_query_unpaid(monkeypatch) + # _fulfill_order / query_order 间接需要用户行(如果上游 paid 路径) + _ensure_user(db_session, 30) + mock_redis = MagicMock() + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + order = bill_service.create_order( + db_session, user_id=30, product_id=None, amount_fen=1000, channel="alipay" + ) + + # 验证查询订单(PENDING 订单 query_order 会查上游,mock 为 unpaid) + found = bill_service.query_order(db_session, order.order_no) + assert found is not None + assert found.order_no == order.order_no + + not_found = bill_service.query_order(db_session, "NON_EXISTENT") + assert not_found is None + + # 验证关单 + closed = bill_service.close_expired_orders(db_session, order.order_no) + assert closed == "closed" + assert order.status == OrderStatus.CLOSED + assert order.closed_at is not None + + # 重复关单返回 skipped + assert bill_service.close_expired_orders(db_session, order.order_no) == "skipped" + + +def test_handle_notify_rejects_non_success_status(db_session): + """F1: 非成功 trade_status 的签名合法回调不应发货。""" + _ensure_user(db_session, 40) + mock_redis = MagicMock() + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + order = bill_service.create_order( + db_session, user_id=40, product_id=None, amount_fen=1000, channel="alipay" + ) + + provider = get_provider(PaymentProviderType.EPAY) + # trade_status=TRADE_PENDING:签名合法但支付未完成 + payload = { + "pid": getattr(provider, "pid", 1000), + "trade_no": "EPAY_PENDING_01", + "out_trade_no": order.order_no, + "type": "alipay", + "name": "Test Order", + "money": "10.00", + "trade_status": "TRADE_PENDING", + } + payload["sign"] = provider.sign_params(payload, getattr(provider, "key", "")) + payload["sign_type"] = "MD5" + + with pytest.raises(BizException): + bill_service.handle_notify(db_session, payload) + + # 订单仍 PENDING,未发货 + db_session.expire_all() + o = db_session.scalar(select(Order).where(Order.order_no == order.order_no)) + assert o.status == OrderStatus.PENDING + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == 40) + ) + assert account is None # 未建账户 + + +def test_handle_notify_revives_closed_order(db_session): + """H1a: CLOSED 订单收到合法成功回调时复活为 PAID 并补发货。""" + _ensure_user(db_session, 50) + now = datetime.now(timezone.utc) + order = Order( + order_no="TEST_REVIVE_01", + user_id=50, + product_type=int(ProductType.TOP_UP), + amount_fen=1000, + credits=125, + channel="alipay", + provider=PaymentProviderType.EPAY, + status=OrderStatus.CLOSED, + expire_at=now + timedelta(minutes=15), + closed_at=now, + ) + db_session.add(order) + db_session.flush() + + provider = get_provider(PaymentProviderType.EPAY) + payload = { + "pid": getattr(provider, "pid", 1000), + "trade_no": "EPAY_REVIVE_01", + "out_trade_no": "TEST_REVIVE_01", + "type": "alipay", + "name": "Test Order", + "money": "10.00", + "trade_status": "TRADE_SUCCESS", + } + payload["sign"] = provider.sign_params(payload, getattr(provider, "key", "")) + payload["sign_type"] = "MD5" + + bill_service.handle_notify(db_session, payload) + + db_session.expire_all() + o = db_session.scalar(select(Order).where(Order.order_no == "TEST_REVIVE_01")) + assert o.status == OrderStatus.PAID + assert o.paid_at is not None + + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == 50) + ) + assert account is not None + assert account.balance == 125 + + +def test_close_paid_by_upstream(db_session, monkeypatch): + """H1b: close 时上游查询发现已支付 → 返回 paid 并发货。""" + _ensure_user(db_session, 60) + mock_redis = MagicMock() + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + order = bill_service.create_order( + db_session, user_id=60, product_id=None, amount_fen=1000, channel="alipay" + ) + + # mock 上游查询返回 paid + monkeypatch.setattr( + EpayProvider, + "query_order", + lambda self, order_no: ProviderQueryResult( + order_no=order_no, + provider_trade_no="EPAY_PAID_01", + amount_fen=1000, + status="paid", + raw_data={}, + ), + ) + + result = bill_service.close_expired_orders(db_session, order.order_no) + assert result == "paid" + + db_session.expire_all() + o = db_session.scalar(select(Order).where(Order.order_no == order.order_no)) + assert o.status == OrderStatus.PAID + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == 60) + ) + assert account is not None + assert account.balance == 125 + + +def test_close_unknown_returns_retry(db_session, monkeypatch): + """H1b: 上游结果 unknown → 返回 retry,订单保持 PENDING。""" + _ensure_user(db_session, 70) + mock_redis = MagicMock() + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + order = bill_service.create_order( + db_session, user_id=70, product_id=None, amount_fen=1000, channel="alipay" + ) + + monkeypatch.setattr( + EpayProvider, + "query_order", + lambda self, order_no: ProviderQueryResult( + order_no=order_no, + provider_trade_no=None, + amount_fen=0, + status="unknown", + raw_data={}, + ), + ) + + result = bill_service.close_expired_orders(db_session, order.order_no) + assert result == "retry" + + db_session.expire_all() + o = db_session.scalar(select(Order).where(Order.order_no == order.order_no)) + assert o.status == OrderStatus.PENDING + + +def test_query_order_compensates_paid_upstream(db_session, monkeypatch): + """F7: query_order 对 PENDING 订单向上游补偿查询,paid 则补发货。""" + _ensure_user(db_session, 80) + mock_redis = MagicMock() + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + order = bill_service.create_order( + db_session, user_id=80, product_id=None, amount_fen=1000, channel="alipay" + ) + + monkeypatch.setattr( + EpayProvider, + "query_order", + lambda self, order_no: ProviderQueryResult( + order_no=order_no, + provider_trade_no="EPAY_COMP_01", + amount_fen=1000, + status="paid", + raw_data={}, + ), + ) + + result = bill_service.query_order(db_session, order.order_no) + assert result is not None + assert result.status == OrderStatus.PAID + + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == 80) + ) + assert account is not None + assert account.balance == 125 + + +def test_query_order_compensates_amount_mismatch(db_session, monkeypatch): + """F7: 上游 paid 但金额不符,不发货。""" + _ensure_user(db_session, 90) + mock_redis = MagicMock() + with patch("windup_app.server.bill.service.get_redis", return_value=mock_redis): + order = bill_service.create_order( + db_session, user_id=90, product_id=None, amount_fen=1000, channel="alipay" + ) + + monkeypatch.setattr( + EpayProvider, + "query_order", + lambda self, order_no: ProviderQueryResult( + order_no=order_no, + provider_trade_no="EPAY_BAD", + amount_fen=500, # 金额不符 + status="paid", + raw_data={}, + ), + ) + + result = bill_service.query_order(db_session, order.order_no) + assert result is not None + assert result.status == OrderStatus.PENDING # 不发货 diff --git a/backend/tests/test_bill_tasks.py b/backend/tests/test_bill_tasks.py new file mode 100644 index 00000000..f982d6f8 --- /dev/null +++ b/backend/tests/test_bill_tasks.py @@ -0,0 +1,276 @@ +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select + +from windup_app.server.bill.epay import EpayProvider +from windup_app.server.bill.model import Order, Subscription +from windup_app.server.bill.provider import ProviderQueryResult +from windup_app.server.bill.service import service as bill_service +from windup_app.server.quota.model import CreditAccount, CreditBatch, CreditTransaction +from windup_common.enums.bill import OrderStatus, PaymentProviderType, SubscriptionStatus, SubscriptionTier +from windup_common.enums.quota import CreditReason + + +def _patch_query_unpaid(monkeypatch): + """让 EpayProvider.query_order 返回 unpaid,避免真实 HTTP 请求。""" + monkeypatch.setattr( + EpayProvider, + "query_order", + lambda self, order_no: ProviderQueryResult( + order_no=order_no, + provider_trade_no=None, + amount_fen=0, + status="unpaid", + raw_data={}, + ), + ) + + +def test_close_expired_orders(db_session, monkeypatch): + """Test closing an expired PENDING order.""" + _patch_query_unpaid(monkeypatch) + now = datetime.now(timezone.utc) + order = Order( + order_no="TEST_CLOSE_01", + user_id=1, + product_id=None, + product_type=1, + amount_fen=1000, + credits=100, + channel="alipay", + provider=PaymentProviderType.EPAY, + status=OrderStatus.PENDING, + expire_at=now - timedelta(minutes=1), + ) + db_session.add(order) + db_session.flush() + + # Close the order + result = bill_service.close_expired_orders(db_session, "TEST_CLOSE_01") + assert result == "closed" + + # Verify status and closed_at + closed_order = db_session.scalar(select(Order).where(Order.order_no == "TEST_CLOSE_01")) + assert closed_order.status == OrderStatus.CLOSED + assert closed_order.closed_at is not None + +def test_close_expired_orders_non_pending(db_session, monkeypatch): + """Test closing ignores non-PENDING orders.""" + _patch_query_unpaid(monkeypatch) + now = datetime.now(timezone.utc) + order = Order( + order_no="TEST_CLOSE_02", + user_id=1, + product_id=None, + product_type=1, + amount_fen=1000, + credits=100, + channel="alipay", + provider=PaymentProviderType.EPAY, + status=OrderStatus.PAID, + expire_at=now - timedelta(minutes=1), + paid_at=now - timedelta(seconds=10), + ) + db_session.add(order) + db_session.flush() + + # Attempt to close + result = bill_service.close_expired_orders(db_session, "TEST_CLOSE_02") + assert result == "skipped" + + # Verify untouched + unchanged_order = db_session.scalar(select(Order).where(Order.order_no == "TEST_CLOSE_02")) + assert unchanged_order.status == OrderStatus.PAID + assert unchanged_order.closed_at is None + + +def test_process_expired_subscriptions(db_session): + """Test processing an expired active subscription.""" + now = datetime.now(timezone.utc) + # Set up user account and batch + account = CreditAccount(user_id=10, balance=400, frozen=0, total_earned=400, total_spent=0) + db_session.add(account) + db_session.flush() + + # Insert expired active subscription + sub = Subscription( + user_id=10, + order_id=1, + tier=SubscriptionTier.PLUS, + period_start=now - timedelta(days=60), + period_end=now - timedelta(days=30), + credits_granted=400, + status=SubscriptionStatus.ACTIVE, + ) + db_session.add(sub) + db_session.flush() + + batch = CreditBatch( + user_id=10, + source_type=int(CreditReason.SUBSCRIPTION), + source_ref=f"sub:{sub.id}", + total=400, + remaining=400, + frozen=0, + is_exhausted=0, + expires_at=now - timedelta(days=30), + ) + db_session.add(batch) + db_session.flush() + + # Process expirations + count = bill_service.process_expired_subscriptions(db_session) + assert count == 1 + + # Verify subscription status + assert sub.status == SubscriptionStatus.EXPIRED + + # Verify batch + db_session.refresh(batch) + assert batch.remaining == 0 + assert batch.is_exhausted == 1 + + # Verify account balance + db_session.refresh(account) + assert account.balance == 0 + + # Verify transaction + txn = db_session.scalar(select(CreditTransaction).where(CreditTransaction.ref_id == f"expire:sub:{sub.id}")) + assert txn is not None + assert txn.reason == int(CreditReason.SUBSCRIPTION_EXPIRE) + assert txn.delta == -400 + assert txn.balance_after == 0 + + +def test_process_expired_subscriptions_rollover(db_session): + """Test rolling over to a queued subscription.""" + now = datetime.now(timezone.utc) + + account = CreditAccount(user_id=11, balance=100, frozen=0, total_earned=100, total_spent=0) + db_session.add(account) + db_session.flush() + + # Expired sub + sub1 = Subscription( + user_id=11, + order_id=2, + tier=SubscriptionTier.PLUS, + period_start=now - timedelta(days=30), + period_end=now - timedelta(minutes=1), + credits_granted=400, + status=SubscriptionStatus.ACTIVE, + ) + db_session.add(sub1) + db_session.flush() + + batch1 = CreditBatch( + user_id=11, + source_type=int(CreditReason.SUBSCRIPTION), + source_ref=f"sub:{sub1.id}", + total=400, + remaining=100, + frozen=0, + is_exhausted=0, + expires_at=now - timedelta(minutes=1), + ) + db_session.add(batch1) + db_session.flush() + + # Queued sub + sub2 = Subscription( + user_id=11, + order_id=3, + tier=SubscriptionTier.PLUS, + period_start=now - timedelta(minutes=1), + period_end=now + timedelta(days=30) - timedelta(minutes=1), + credits_granted=400, + status=SubscriptionStatus.PENDING_START, + ) + db_session.add(sub2) + db_session.flush() + + # Process expirations + count = bill_service.process_expired_subscriptions(db_session) + assert count == 1 + + # Verify sub1 expired and balance deducted + assert sub1.status == SubscriptionStatus.EXPIRED + db_session.refresh(batch1) + assert batch1.remaining == 0 + assert batch1.is_exhausted == 1 + + # Verify sub2 became active and granted credits + db_session.refresh(sub2) + assert sub2.status == SubscriptionStatus.ACTIVE + + batch2 = db_session.scalar(select(CreditBatch).where(CreditBatch.source_ref == f"sub:{sub2.id}")) + assert batch2 is not None + assert batch2.total == 400 + assert batch2.remaining == 400 + assert batch2.expires_at == sub2.period_end + + db_session.refresh(account) + # started with 100, deducted 100 from expired batch, added 400 from new sub + assert account.balance == 400 + +def test_process_expired_subscriptions_future(db_session): + """Test that active subscriptions ending in the future are untouched.""" + now = datetime.now(timezone.utc) + sub = Subscription( + user_id=12, + order_id=4, + tier=SubscriptionTier.PLUS, + period_start=now - timedelta(days=10), + period_end=now + timedelta(days=20), + credits_granted=400, + status=SubscriptionStatus.ACTIVE, + ) + db_session.add(sub) + db_session.flush() + + count = bill_service.process_expired_subscriptions(db_session) + assert count == 0 + assert sub.status == SubscriptionStatus.ACTIVE + +def test_process_expired_subscriptions_batch_frozen(db_session): + """Test that if a batch has frozen credits, is_exhausted is set to 0.""" + now = datetime.now(timezone.utc) + account = CreditAccount(user_id=13, balance=200, frozen=50, total_earned=250, total_spent=0) + db_session.add(account) + db_session.flush() + + sub = Subscription( + user_id=13, + order_id=5, + tier=SubscriptionTier.PLUS, + period_start=now - timedelta(days=30), + period_end=now - timedelta(minutes=1), + credits_granted=400, + status=SubscriptionStatus.ACTIVE, + ) + db_session.add(sub) + db_session.flush() + + batch = CreditBatch( + user_id=13, + source_type=int(CreditReason.SUBSCRIPTION), + source_ref=f"sub:{sub.id}", + total=400, + remaining=200, + frozen=50, # 50 frozen + is_exhausted=0, + expires_at=now - timedelta(minutes=1), + ) + db_session.add(batch) + db_session.flush() + + count = bill_service.process_expired_subscriptions(db_session) + assert count == 1 + + db_session.refresh(batch) + assert batch.remaining == 0 + assert batch.frozen == 50 + assert batch.is_exhausted == 0 # Not fully exhausted because of frozen + + db_session.refresh(account) + assert account.balance == 0 # 200 deducted diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index c2bf9a77..cc56378a 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -18,6 +18,7 @@ from windup_app.server.quota.model import ( CreditAccount, + CreditBatch, CreditRedemptionCode, CreditTransaction, ) @@ -48,6 +49,19 @@ def user_with_account(db_session: Session): db_session.add(account) db_session.flush() + batch = CreditBatch( + user_id=user.id, + source_type=int(CreditReason.REGISTER_GIFT), + source_ref="test_user_seed", + total=quota_settings.register_gift_amount, + remaining=quota_settings.register_gift_amount, + frozen=0, + expires_at=None, + is_exhausted=0, + ) + db_session.add(batch) + db_session.flush() + return user diff --git a/backend/tests/test_quota_batch_logic.py b/backend/tests/test_quota_batch_logic.py new file mode 100644 index 00000000..1e7408e4 --- /dev/null +++ b/backend/tests/test_quota_batch_logic.py @@ -0,0 +1,488 @@ +from datetime import datetime, timedelta, timezone +from sqlalchemy import select + +from windup_app.server.quota.model import ( + CreditAccount, + CreditBatch, + CreditFreezeAlloc, + CreditRedemptionCode, +) +from windup_app.server.quota.service import ( + redemption_code_hash, + service as quota_service, +) +from windup_common.enums.quota import CreditReason + + +def test_credit_batch_allocation(db_session): + # Setup test user and raw account + account = CreditAccount( + user_id=999, + balance=0, + frozen=0, + total_earned=0, + total_spent=0, + ) + db_session.add(account) + db_session.flush() + + quota_service.credit(db_session, 999, 100, int(CreditReason.REGISTER_GIFT), "ref:gift") + + # 1. Test Reserve + quota_service.reserve_credit(db_session, 999, 50, "task:1") + acc = quota_service.get_account(db_session, 999) + assert acc.balance == 50 + assert acc.frozen == 50 + + # 2. Test Capture + quota_service.capture_credit(db_session, 999, 30, "task:1", 50) # Refund 20 + acc = quota_service.get_account(db_session, 999) + assert acc.balance == 70 + assert acc.frozen == 0 + + # 3. Test Release + quota_service.reserve_credit(db_session, 999, 20, "task:2") + quota_service.release_credit(db_session, 999, 20, "task:2") + acc = quota_service.get_account(db_session, 999) + assert acc.balance == 70 + assert acc.frozen == 0 + + +def test_credit_batch_allocation_records(db_session): + account = CreditAccount( + user_id=999, + balance=0, + frozen=0, + total_earned=0, + total_spent=0, + ) + db_session.add(account) + db_session.flush() + + quota_service.credit(db_session, 999, 100, int(CreditReason.REGISTER_GIFT), "ref:gift") + batches = db_session.scalars( + select(CreditBatch).where(CreditBatch.user_id == 999) + ).all() + assert len(batches) == 1 + assert batches[0].total == 100 + assert batches[0].remaining == 100 + assert batches[0].frozen == 0 + assert batches[0].is_exhausted == 0 + + +def test_credit_batch_priority_subscription_over_topup(db_session): + """验证消耗优先级:SUBSCRIPTION 批次优先于 TOP_UP 批次。""" + account = CreditAccount( + user_id=1001, + balance=0, + frozen=0, + total_earned=0, + total_spent=0, + ) + db_session.add(account) + db_session.flush() + + # 先充值入账 100 + quota_service.credit(db_session, 1001, 100, int(CreditReason.TOP_UP), "order:topup_1") + # 后订阅入账 100,带过期时间 + sub_expire = datetime.now(timezone.utc) + timedelta(days=30) + quota_service.credit( + db_session, + 1001, + 100, + int(CreditReason.SUBSCRIPTION), + "order:sub_1", + expires_at=sub_expire, + ) + + acc = quota_service.get_account(db_session, 1001) + assert acc.balance == 200 + + # 冻结 60,应当全部从 SUBSCRIPTION 扣除 + quota_service.reserve_credit(db_session, 1001, 60, "task:sub_prio") + + sub_batch = db_session.scalar( + select(CreditBatch).where( + CreditBatch.user_id == 1001, + CreditBatch.source_type == int(CreditReason.SUBSCRIPTION), + ) + ) + topup_batch = db_session.scalar( + select(CreditBatch).where( + CreditBatch.user_id == 1001, + CreditBatch.source_type == int(CreditReason.TOP_UP), + ) + ) + + assert sub_batch.remaining == 40 + assert sub_batch.frozen == 60 + assert topup_batch.remaining == 100 + assert topup_batch.frozen == 0 + + allocs = db_session.scalars( + select(CreditFreezeAlloc).where(CreditFreezeAlloc.freeze_ref == "task:sub_prio") + ).all() + assert len(allocs) == 1 + assert allocs[0].batch_id == sub_batch.id + assert allocs[0].amount == 60 + assert allocs[0].status == 1 # FROZEN + + +def test_credit_batch_expiry_ordering(db_session): + """验证快过期的批次优先消耗。""" + account = CreditAccount( + user_id=1002, + balance=0, + frozen=0, + total_earned=0, + total_spent=0, + ) + db_session.add(account) + db_session.flush() + + now = datetime.now(timezone.utc) + # 批次 1: 5 天后过期 + quota_service.credit( + db_session, + 1002, + 50, + int(CreditReason.SUBSCRIPTION), + "sub:early", + expires_at=now + timedelta(days=5), + ) + # 批次 2: 20 天后过期 + quota_service.credit( + db_session, + 1002, + 50, + int(CreditReason.SUBSCRIPTION), + "sub:late", + expires_at=now + timedelta(days=20), + ) + + # 冻结 30,优先扣 5 天后过期的批次 + quota_service.reserve_credit(db_session, 1002, 30, "task:exp") + + early_batch = db_session.scalar( + select(CreditBatch).where( + CreditBatch.user_id == 1002, + CreditBatch.source_ref == "sub:early", + ) + ) + late_batch = db_session.scalar( + select(CreditBatch).where( + CreditBatch.user_id == 1002, + CreditBatch.source_ref == "sub:late", + ) + ) + + assert early_batch.remaining == 20 + assert early_batch.frozen == 30 + assert late_batch.remaining == 50 + assert late_batch.frozen == 0 + + +def test_credit_batch_multi_alloc_capture_and_refund(db_session): + """跨多批次冻结,扣减部分并差额退回。""" + account = CreditAccount( + user_id=1003, + balance=0, + frozen=0, + total_earned=0, + total_spent=0, + ) + db_session.add(account) + db_session.flush() + + now = datetime.now(timezone.utc) + # 批次 1: 30 积分 + quota_service.credit( + db_session, + 1003, + 30, + int(CreditReason.SUBSCRIPTION), + "sub:b1", + expires_at=now + timedelta(days=5), + ) + # 批次 2: 20 积分 + quota_service.credit( + db_session, + 1003, + 20, + int(CreditReason.SUBSCRIPTION), + "sub:b2", + expires_at=now + timedelta(days=10), + ) + + # 冻结 50(刚好耗尽两批次) + quota_service.reserve_credit(db_session, 1003, 50, "task:multi") + + b1 = db_session.scalar(select(CreditBatch).where(CreditBatch.source_ref == "sub:b1")) + b2 = db_session.scalar(select(CreditBatch).where(CreditBatch.source_ref == "sub:b2")) + assert b1.remaining == 0 and b1.frozen == 30 and b1.is_exhausted == 0 + assert b2.remaining == 0 and b2.frozen == 20 and b2.is_exhausted == 0 + + allocs = db_session.scalars( + select(CreditFreezeAlloc) + .where(CreditFreezeAlloc.freeze_ref == "task:multi") + .order_by(CreditFreezeAlloc.id.asc()) + ).all() + assert len(allocs) == 2 + assert allocs[0].amount == 30 and allocs[0].status == 1 + assert allocs[1].amount == 20 and allocs[1].status == 1 + + # 实际扣减 35,退回 15 + quota_service.capture_credit(db_session, 1003, 35, "task:multi", 50) + + db_session.expire_all() + b1_after = db_session.scalar(select(CreditBatch).where(CreditBatch.source_ref == "sub:b1")) + b2_after = db_session.scalar(select(CreditBatch).where(CreditBatch.source_ref == "sub:b2")) + + # b1 贡献 30: 全部扣除,frozen=0, remaining=0 -> is_exhausted=1 + assert b1_after.frozen == 0 + assert b1_after.remaining == 0 + assert b1_after.is_exhausted == 1 + + # b2 贡献 20: 扣除 5,退回 15 -> remaining=15, frozen=0 -> is_exhausted=0 + assert b2_after.frozen == 0 + assert b2_after.remaining == 15 + assert b2_after.is_exhausted == 0 + + # 两个 alloc 的 status 变为 2 (CAPTURED) + allocs_after = db_session.scalars( + select(CreditFreezeAlloc).where(CreditFreezeAlloc.freeze_ref == "task:multi") + ).all() + assert all(a.status == 2 for a in allocs_after) + + # 账户余额检查: 50 - 35 = 15 可用, 0 冻结, total_spent = 35 + acc = quota_service.get_account(db_session, 1003) + assert acc.balance == 15 + assert acc.frozen == 0 + assert acc.total_spent == 35 + + +def test_credit_batch_multi_alloc_release(db_session): + """跨多批次冻结后完全解冻。""" + account = CreditAccount( + user_id=1004, + balance=0, + frozen=0, + total_earned=0, + total_spent=0, + ) + db_session.add(account) + db_session.flush() + + quota_service.credit(db_session, 1004, 30, int(CreditReason.TOP_UP), "top:1") + quota_service.credit(db_session, 1004, 20, int(CreditReason.TOP_UP), "top:2") + + quota_service.reserve_credit(db_session, 1004, 50, "task:rel") + quota_service.release_credit(db_session, 1004, 50, "task:rel") + + db_session.expire_all() + b1 = db_session.scalar(select(CreditBatch).where(CreditBatch.source_ref == "top:1")) + b2 = db_session.scalar(select(CreditBatch).where(CreditBatch.source_ref == "top:2")) + assert b1.remaining == 30 and b1.frozen == 0 and b1.is_exhausted == 0 + assert b2.remaining == 20 and b2.frozen == 0 and b2.is_exhausted == 0 + + allocs = db_session.scalars( + select(CreditFreezeAlloc).where(CreditFreezeAlloc.freeze_ref == "task:rel") + ).all() + assert all(a.status == 3 for a in allocs) + + acc = quota_service.get_account(db_session, 1004) + assert acc.balance == 50 + assert acc.frozen == 0 + + +def test_redeem_code_creates_batch(db_session): + """验证兑换码成功兑换时生成 CreditBatch。""" + account = CreditAccount( + user_id=1005, + balance=0, + frozen=0, + total_earned=0, + total_spent=0, + ) + db_session.add(account) + db_session.flush() + + raw_code = "WUEXAMPLETEST1" + row = CreditRedemptionCode( + code_hash=redemption_code_hash(raw_code), + amount=500, + expires_at=None, + ) + db_session.add(row) + db_session.flush() + + amt, acc_view = quota_service.redeem_code(db_session, 1005, raw_code) + assert amt == 500 + assert acc_view.balance == 500 + + batches = db_session.scalars( + select(CreditBatch).where(CreditBatch.user_id == 1005) + ).all() + assert len(batches) == 1 + assert batches[0].total == 500 + assert batches[0].remaining == 500 + assert batches[0].source_type == int(CreditReason.REDEMPTION) + assert batches[0].source_ref == f"redemption:{row.id}" + + +def test_reserve_lazy_expires_stale_batches(db_session): + """F5: reserve 时惰性清零已过期未清理的订阅批次,过期积分不可用。""" + now = datetime.now(timezone.utc) + account = CreditAccount( + user_id=2001, + balance=200, + frozen=0, + total_earned=200, + total_spent=0, + ) + db_session.add(account) + # 一个已过期未清理的订阅批次 + 一个未过期的永久批次 + expired_batch = CreditBatch( + user_id=2001, + source_type=int(CreditReason.SUBSCRIPTION), + source_ref="sub:lazy", + total=100, + remaining=100, + frozen=0, + is_exhausted=0, + expires_at=now - timedelta(minutes=5), + ) + active_batch = CreditBatch( + user_id=2001, + source_type=int(CreditReason.REGISTER_GIFT), + source_ref="register:2001", + total=100, + remaining=100, + frozen=0, + is_exhausted=0, + expires_at=None, + ) + db_session.add(expired_batch) + db_session.add(active_batch) + db_session.flush() + + # reserve 50:惰性过期先清零 100(balance 200→100),再从活跃批次冻结 50 + quota_service.reserve_credit(db_session, 2001, 50, "task:lazy") + + db_session.refresh(expired_batch) + assert expired_batch.remaining == 0 + assert expired_batch.is_exhausted == 1 + + db_session.refresh(account) + assert account.balance == 50 # 200 - 100(惰性过期) - 50(冻结) + assert account.frozen == 50 + + # 过期流水已写 + from windup_app.server.quota.model import CreditTransaction + txn = db_session.scalar( + select(CreditTransaction).where( + CreditTransaction.ref_id == f"lazy:batch:{expired_batch.id}" + ) + ) + assert txn is not None + assert txn.reason == int(CreditReason.SUBSCRIPTION_EXPIRE) + + +def test_release_expired_batch_does_not_revive(db_session): + """H2: 冻结跨越过期点的积分,解冻时过期部分不回余额。""" + now = datetime.now(timezone.utc) + account = CreditAccount( + user_id=2002, + balance=200, + frozen=0, + total_earned=200, + total_spent=0, + ) + db_session.add(account) + # 两个批次:一个已过期,一个未过期 + expired_batch = CreditBatch( + user_id=2002, + source_type=int(CreditReason.SUBSCRIPTION), + source_ref="sub:expired", + total=100, + remaining=100, + frozen=0, + is_exhausted=0, + expires_at=now - timedelta(minutes=1), + ) + active_batch = CreditBatch( + user_id=2002, + source_type=int(CreditReason.REGISTER_GIFT), + source_ref="register:1", + total=100, + remaining=100, + frozen=0, + is_exhausted=0, + expires_at=None, + ) + db_session.add(expired_batch) + db_session.add(active_batch) + db_session.flush() + + # 冻结 150(先从过期批次扣 100,再从活跃批次扣 50) + # 但 reserve 的惰性过期会先清零过期批次 → 过期批 remaining=0,不参与冻结 + # 所以只从活跃批次冻结 150 → 余额不足(活跃只有 100) + # 改为冻结 100(全部从活跃批次) + quota_service.reserve_credit(db_session, 2002, 100, "task:cross_expire") + db_session.refresh(account) + assert account.balance == 0 + assert account.frozen == 100 + + # 标记批次为已过期(模拟冻结后、解冻前过期了) + db_session.refresh(active_batch) + active_batch.expires_at = now - timedelta(minutes=1) + db_session.flush() + + # 解冻 100:批次已过期,不应回余额 + quota_service.release_credit(db_session, 2002, 100, "task:cross_expire") + db_session.refresh(account) + assert account.balance == 0 # 过期部分不回余额 + assert account.frozen == 0 + + +def test_capture_expired_batch_refund_does_not_revive(db_session): + """H2: capture 差额返还时,过期批次部分不回余额。""" + now = datetime.now(timezone.utc) + account = CreditAccount( + user_id=2003, + balance=200, + frozen=0, + total_earned=200, + total_spent=0, + ) + db_session.add(account) + batch = CreditBatch( + user_id=2003, + source_type=int(CreditReason.REGISTER_GIFT), + source_ref="register:2", + total=200, + remaining=200, + frozen=0, + is_exhausted=0, + expires_at=None, + ) + db_session.add(batch) + db_session.flush() + + # 冻结 200 + quota_service.reserve_credit(db_session, 2003, 200, "task:capture_expire") + db_session.refresh(account) + assert account.balance == 0 + assert account.frozen == 200 + + # 标记批次过期 + db_session.refresh(batch) + batch.expires_at = now - timedelta(minutes=1) + db_session.flush() + + # 实际消耗 50,差额 150 应返还但不回余额(批次已过期) + quota_service.capture_credit(db_session, 2003, 50, "task:capture_expire", 200) + db_session.refresh(account) + assert account.balance == 0 # 过期部分不回余额 + assert account.frozen == 0 + assert account.total_spent == 50 diff --git a/backend/tests/test_quota_batch_models.py b/backend/tests/test_quota_batch_models.py new file mode 100644 index 00000000..f145756d --- /dev/null +++ b/backend/tests/test_quota_batch_models.py @@ -0,0 +1,65 @@ +# backend/tests/test_quota_batch_models.py +from datetime import datetime, timezone +from windup_app.server.quota.model import CreditBatch, CreditFreezeAlloc +from windup_common.enums.quota import CreditReason +from windup_framework.db import Base + + +def test_credit_batch_models(): + batch = CreditBatch( + user_id=1, source_type=CreditReason.TOP_UP, total=100, remaining=100, frozen=0 + ) + alloc = CreditFreezeAlloc( + freeze_ref="task:1", batch_id=1, amount=50 + ) + assert batch.is_exhausted == 0 + assert alloc.status == 1 # FROZEN + + # Test full attributes and tablename + assert CreditBatch.__tablename__ == "windup_credit_batch" + assert CreditFreezeAlloc.__tablename__ == "windup_credit_freeze_alloc" + assert "windup_credit_batch" in Base.metadata.tables + assert "windup_credit_freeze_alloc" in Base.metadata.tables + + # Check table indices + batch_table = Base.metadata.tables["windup_credit_batch"] + alloc_table = Base.metadata.tables["windup_credit_freeze_alloc"] + batch_index_names = {idx.name for idx in batch_table.indexes} + alloc_index_names = {idx.name for idx in alloc_table.indexes} + assert "ix_credit_batch_user_exhausted_expires" in batch_index_names + assert "ix_credit_freeze_alloc_ref_status" in alloc_index_names + + now = datetime.now(timezone.utc) + batch_full = CreditBatch( + id=10, + user_id=2, + source_type=CreditReason.SUBSCRIPTION, + source_ref="order:sub_123", + total=500, + remaining=500, + frozen=100, + expires_at=now, + is_exhausted=0, + ) + assert batch_full.id == 10 + assert batch_full.user_id == 2 + assert batch_full.source_type == CreditReason.SUBSCRIPTION + assert batch_full.source_ref == "order:sub_123" + assert batch_full.total == 500 + assert batch_full.remaining == 500 + assert batch_full.frozen == 100 + assert batch_full.expires_at == now + assert batch_full.is_exhausted == 0 + + alloc_full = CreditFreezeAlloc( + id=20, + freeze_ref="task:gen_999", + batch_id=10, + amount=100, + status=2, # CAPTURED + ) + assert alloc_full.id == 20 + assert alloc_full.freeze_ref == "task:gen_999" + assert alloc_full.batch_id == 10 + assert alloc_full.amount == 100 + assert alloc_full.status == 2 diff --git a/openapi.json b/openapi.json index 1034325b..1479d8b9 100644 --- a/openapi.json +++ b/openapi.json @@ -1696,6 +1696,39 @@ "title": "ChatUsageResponse", "type": "object" }, + "CreateOrderIn": { + "properties": { + "amount_fen": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Amount Fen" + }, + "channel": { + "default": "alipay", + "title": "Channel", + "type": "string" + }, + "product_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Product Id" + } + }, + "title": "CreateOrderIn", + "type": "object" + }, "CreditAccountOut": { "description": "积分账户响应。", "properties": { @@ -1708,6 +1741,17 @@ "title": "Create At", "type": "string" }, + "expiring_credits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Expiring Credits" + }, "frozen": { "title": "Frozen", "type": "integer" @@ -1716,6 +1760,17 @@ "title": "Id", "type": "integer" }, + "perpetual_credits": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Perpetual Credits" + }, "total_earned": { "title": "Total Earned", "type": "integer" @@ -2421,6 +2476,124 @@ "title": "ListResponse[InviteRecordOut]", "type": "object" }, + "ListResponse_OrderOut_": { + "properties": { + "code": { + "default": 200, + "description": "业务状态码:成功 200,失败非 200", + "title": "Code", + "type": "integer" + }, + "data": { + "description": "业务数据列表", + "items": { + "$ref": "#/components/schemas/OrderOut" + }, + "title": "Data", + "type": "array" + }, + "message": { + "default": "success", + "description": "提示信息", + "title": "Message", + "type": "string" + }, + "page": { + "default": 1, + "description": "当前页码,从 1 开始", + "minimum": 1.0, + "title": "Page", + "type": "integer" + }, + "page_size": { + "default": 0, + "description": "每页条数;0 表示不分页(全量)", + "minimum": 0.0, + "title": "Page Size", + "type": "integer" + }, + "timestamp": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "响应时间;默认不携带,不携带时省略", + "title": "Timestamp" + }, + "total": { + "default": 0, + "description": "数据总数;分页时为查询条件总数,不分页时为 len(data)", + "title": "Total", + "type": "integer" + } + }, + "title": "ListResponse[OrderOut]", + "type": "object" + }, + "ListResponse_ProductOut_": { + "properties": { + "code": { + "default": 200, + "description": "业务状态码:成功 200,失败非 200", + "title": "Code", + "type": "integer" + }, + "data": { + "description": "业务数据列表", + "items": { + "$ref": "#/components/schemas/ProductOut" + }, + "title": "Data", + "type": "array" + }, + "message": { + "default": "success", + "description": "提示信息", + "title": "Message", + "type": "string" + }, + "page": { + "default": 1, + "description": "当前页码,从 1 开始", + "minimum": 1.0, + "title": "Page", + "type": "integer" + }, + "page_size": { + "default": 0, + "description": "每页条数;0 表示不分页(全量)", + "minimum": 0.0, + "title": "Page Size", + "type": "integer" + }, + "timestamp": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "响应时间;默认不携带,不携带时省略", + "title": "Timestamp" + }, + "total": { + "default": 0, + "description": "数据总数;分页时为查询条件总数,不分页时为 len(data)", + "title": "Total", + "type": "integer" + } + }, + "title": "ListResponse[ProductOut]", + "type": "object" + }, "ListResponse_ProjectListOut_": { "properties": { "code": { @@ -2707,6 +2880,117 @@ "title": "OpenAIErrorResponse", "type": "object" }, + "OrderOut": { + "properties": { + "amount_fen": { + "title": "Amount Fen", + "type": "integer" + }, + "channel": { + "title": "Channel", + "type": "string" + }, + "closed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Closed At" + }, + "create_at": { + "format": "date-time", + "title": "Create At", + "type": "string" + }, + "credits": { + "title": "Credits", + "type": "integer" + }, + "expire_at": { + "format": "date-time", + "title": "Expire At", + "type": "string" + }, + "order_no": { + "title": "Order No", + "type": "string" + }, + "paid_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Paid At" + }, + "pay_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Pay Url" + }, + "product_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Product Id" + }, + "product_snapshot": { + "additionalProperties": true, + "title": "Product Snapshot", + "type": "object" + }, + "product_type": { + "title": "Product Type", + "type": "integer" + }, + "provider": { + "title": "Provider", + "type": "integer" + }, + "status": { + "title": "Status", + "type": "integer" + } + }, + "required": [ + "order_no", + "product_id", + "product_type", + "product_snapshot", + "amount_fen", + "credits", + "channel", + "provider", + "status", + "pay_url", + "expire_at", + "paid_at", + "closed_at", + "create_at" + ], + "title": "OrderOut", + "type": "object" + }, "OutfitRigFacts": { "description": "绑骨模型的骨架事实。**记录用,不是判据。**\n\n出帧台每渲一段动作都会从模型里读出这些数,此前算完即丢(#774)。存下来是为了\n让「这个造型的骨架长什么样」不必每次重新解析 GLB,也让挂点有据可查——自动绑骨\n保留了挂点骨,武器握持按骨名定位即可,不必重新标定。\n\n为什么不当闸:曾以「28 骨 · humanoid 命名 · 无 mixamorig 前缀」作硬校验,2026-08-26\n量全部归档产物即推翻——骨数是 24 / 27 / 28 / 43 / 49,其中 24 与 27 正是混元自己绑\n的四足,同一条链路出来的骨数就不是常数;带前缀的那个是 Mixamo 绑的,属重定向路线\n的正常输入。据此当闸会把自家产物一起挡掉。", "properties": { @@ -2765,6 +3049,98 @@ "title": "OutfitRigFacts", "type": "object" }, + "ProductOut": { + "properties": { + "amount_fen": { + "title": "Amount Fen", + "type": "integer" + }, + "badge": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Badge" + }, + "credits": { + "title": "Credits", + "type": "integer" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "extra_meta": { + "additionalProperties": true, + "title": "Extra Meta", + "type": "object" + }, + "id": { + "title": "Id", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "original_amount_fen": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Original Amount Fen" + }, + "sort_order": { + "title": "Sort Order", + "type": "integer" + }, + "tier": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Tier" + }, + "type": { + "title": "Type", + "type": "integer" + } + }, + "required": [ + "id", + "type", + "tier", + "name", + "description", + "amount_fen", + "credits", + "original_amount_fen", + "badge", + "sort_order", + "extra_meta" + ], + "title": "ProductOut", + "type": "object" + }, "ProjectCreate": { "description": "创建项目请求。\n\n``character_perspective`` 已退役(#664):旧客户端多传该字段会被忽略,\n方向规格只认 ``directional_movement``。", "properties": { @@ -3565,6 +3941,48 @@ "title": "Response[NoneType]", "type": "object" }, + "Response_OrderOut_": { + "properties": { + "code": { + "default": 200, + "description": "业务状态码:成功 200,失败非 200", + "title": "Code", + "type": "integer" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/OrderOut" + }, + { + "type": "null" + } + ], + "description": "业务数据" + }, + "message": { + "default": "success", + "description": "提示信息", + "title": "Message", + "type": "string" + }, + "timestamp": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "响应时间;默认不携带,不携带时省略", + "title": "Timestamp" + } + }, + "title": "Response[OrderOut]", + "type": "object" + }, "Response_ProjectOut_": { "properties": { "code": { @@ -3649,6 +4067,48 @@ "title": "Response[RedeemCodeOut]", "type": "object" }, + "Response_SubscriptionStatusOut_": { + "properties": { + "code": { + "default": 200, + "description": "业务状态码:成功 200,失败非 200", + "title": "Code", + "type": "integer" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubscriptionStatusOut" + }, + { + "type": "null" + } + ], + "description": "业务数据" + }, + "message": { + "default": "success", + "description": "提示信息", + "title": "Message", + "type": "string" + }, + "timestamp": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "响应时间;默认不携带,不携带时省略", + "title": "Timestamp" + } + }, + "title": "Response[SubscriptionStatusOut]", + "type": "object" + }, "Response_TokenResponse_": { "properties": { "code": { @@ -3893,9 +4353,77 @@ } }, "required": [ - "new_password" + "new_password" + ], + "title": "SetPasswordRequest", + "type": "object" + }, + "SubscriptionOut": { + "properties": { + "period_end": { + "format": "date-time", + "title": "Period End", + "type": "string" + }, + "period_start": { + "format": "date-time", + "title": "Period Start", + "type": "string" + }, + "status": { + "title": "Status", + "type": "integer" + }, + "tier": { + "title": "Tier", + "type": "integer" + } + }, + "required": [ + "tier", + "status", + "period_start", + "period_end" + ], + "title": "SubscriptionOut", + "type": "object" + }, + "SubscriptionStatusOut": { + "properties": { + "active_subscription": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubscriptionOut" + }, + { + "type": "null" + } + ] + }, + "expiring_credits": { + "title": "Expiring Credits", + "type": "integer" + }, + "next_subscription": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubscriptionOut" + }, + { + "type": "null" + } + ] + }, + "perpetual_credits": { + "title": "Perpetual Credits", + "type": "integer" + } + }, + "required": [ + "perpetual_credits", + "expiring_credits" ], - "title": "SetPasswordRequest", + "title": "SubscriptionStatusOut", "type": "object" }, "TokenResponse": { @@ -5085,6 +5613,287 @@ ] } }, + "/bill/notify/epay": { + "get": { + "description": "易支付异步通知接收端点。", + "operationId": "epay_notify_bill_notify_epay_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Epay Notify", + "tags": [ + "bill_notify" + ] + }, + "post": { + "description": "易支付异步通知接收端点。", + "operationId": "epay_notify_bill_notify_epay_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + } + }, + "summary": "Epay Notify", + "tags": [ + "bill_notify" + ] + } + }, + "/bill/orders": { + "get": { + "description": "查询当前用户的订单列表。", + "operationId": "list_orders_bill_orders_get", + "parameters": [ + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "minimum": 1, + "title": "Page", + "type": "integer" + } + }, + { + "in": "query", + "name": "page_size", + "required": false, + "schema": { + "default": 20, + "maximum": 100, + "minimum": 1, + "title": "Page Size", + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Status" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse_OrderOut_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Orders", + "tags": [ + "bill" + ] + }, + "post": { + "description": "创建充值或订阅订单。", + "operationId": "create_order_bill_orders_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOrderIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_OrderOut_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Order", + "tags": [ + "bill" + ] + } + }, + "/bill/orders/{order_no}": { + "get": { + "description": "查询订单详情(回调丢失时向上游补偿查询)。", + "operationId": "get_order_bill_orders__order_no__get", + "parameters": [ + { + "in": "path", + "name": "order_no", + "required": true, + "schema": { + "title": "Order No", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_OrderOut_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Order", + "tags": [ + "bill" + ] + } + }, + "/bill/orders/{order_no}/close": { + "post": { + "description": "手动关闭待支付的订单。\n\nservice 内部先向上游核实支付结果:上游未支付才置 CLOSED,已支付则补发货,\n结果不确定时返回 \"retry\"。", + "operationId": "close_order_bill_orders__order_no__close_post", + "parameters": [ + { + "in": "path", + "name": "order_no", + "required": true, + "schema": { + "title": "Order No", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_NoneType_" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Close Order", + "tags": [ + "bill" + ] + } + }, + "/bill/products": { + "get": { + "description": "查询当前在售商品,按 sort_order 升序。", + "operationId": "list_products_bill_products_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse_ProductOut_" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Products", + "tags": [ + "bill" + ] + } + }, + "/bill/subscription": { + "get": { + "description": "查询用户的订阅状态与积分余额详情。", + "operationId": "get_subscription_status_bill_subscription_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Response_SubscriptionStatusOut_" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Subscription Status", + "tags": [ + "bill" + ] + } + }, "/characters": { "get": { "operationId": "list_characters_characters_get",