会员积分改版V7
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""expand credit addon validity months to 1-36
|
||||
|
||||
Revision ID: b7e2c4d91a63
|
||||
Revises: 1a4d1f095fa1
|
||||
Create Date: 2026-08-11 13:15:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "b7e2c4d91a63"
|
||||
down_revision = "1a4d1f095fa1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
CONSTRAINT_NAME = "ck_credit_products_type_required_fields"
|
||||
|
||||
|
||||
def _drop_type_constraint_if_exists() -> None:
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND t.relname = 'credit_products'
|
||||
AND c.conname = '{CONSTRAINT_NAME}'
|
||||
) THEN
|
||||
ALTER TABLE credit_products DROP CONSTRAINT {CONSTRAINT_NAME};
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _create_type_constraint(*, addon_condition: str) -> None:
|
||||
op.create_check_constraint(
|
||||
CONSTRAINT_NAME,
|
||||
"credit_products",
|
||||
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
|
||||
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
|
||||
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
|
||||
"AND grant_credits IS NULL AND validity_months IS NULL) "
|
||||
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
|
||||
f"AND {addon_condition} AND tier_code IS NULL AND tier_rank IS NULL "
|
||||
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
|
||||
"AND first_purchase_price IS NULL AND regular_price IS NULL "
|
||||
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 当前版本中增值包被旧约束固定为 validity_months = 1。
|
||||
# 先移除旧约束,再开放为1-36个自然月;不修改任何现有商品数据。
|
||||
_drop_type_constraint_if_exists()
|
||||
_create_type_constraint(addon_condition="validity_months BETWEEN 1 AND 36")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 回退到旧版本时,旧约束只允许1个月。为保证 downgrade 可执行,
|
||||
# 将现有增值包有效期恢复为旧版本唯一合法值1个月。
|
||||
_drop_type_constraint_if_exists()
|
||||
op.execute(
|
||||
"UPDATE credit_products "
|
||||
"SET validity_months = 1 "
|
||||
"WHERE product_type = 'credit_addon' "
|
||||
"AND validity_months IS DISTINCT FROM 1"
|
||||
)
|
||||
_create_type_constraint(addon_condition="validity_months = 1")
|
||||
@@ -35,7 +35,9 @@ def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
|
||||
mapping = {"features": "features_json"}
|
||||
for key, value in payload.items():
|
||||
setattr(product, mapping.get(key, key), value)
|
||||
if product.product_type == "credit_addon":
|
||||
if product.product_type == "credit_addon" and product.validity_months is None:
|
||||
# 兼容旧管理端未提交有效期的请求,新建增值包仍默认1个月;
|
||||
# 更新时未提交该字段则保留原值。
|
||||
product.validity_months = 1
|
||||
if product.product_type == "subscription":
|
||||
product.price = product.regular_price or 0
|
||||
@@ -77,6 +79,8 @@ def _validate_product_entity(product: CreditProduct) -> None:
|
||||
elif product.product_type == "credit_addon":
|
||||
if product.grant_credits is None or product.price is None:
|
||||
raise HTTPException(status_code=400, detail="积分增值包必须配置价格和积分数量")
|
||||
if product.validity_months is None or not 1 <= int(product.validity_months) <= 36:
|
||||
raise HTTPException(status_code=400, detail="积分增值包有效期必须为1-36个月")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="不支持的积分商品类型")
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class CreditProduct(Base, TimestampMixin):
|
||||
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
|
||||
"AND grant_credits IS NULL AND validity_months IS NULL) "
|
||||
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
|
||||
"AND validity_months = 1 AND tier_code IS NULL AND tier_rank IS NULL "
|
||||
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
|
||||
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
|
||||
"AND first_purchase_price IS NULL AND regular_price IS NULL "
|
||||
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
|
||||
@@ -63,7 +63,7 @@ class CreditProduct(Base, TimestampMixin):
|
||||
Boolean, nullable=False, default=True, server_default="true"
|
||||
)
|
||||
|
||||
# 积分增值包字段;当前固定一个自然月有效。
|
||||
# 积分增值包字段;有效期按自然月配置,范围1-36个月。
|
||||
grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
|
||||
validity_months: Mapped[int | None] = mapped_column(nullable=True)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class CreditProductBase(BaseModel):
|
||||
|
||||
price: float = Field(default=0, ge=0, le=999999999.99)
|
||||
grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
|
||||
validity_months: int | None = Field(default=None, ge=1, le=36)
|
||||
credit_level: Literal["promotional", "general"] = "general"
|
||||
currency: str = Field(default="CNY", min_length=1, max_length=8)
|
||||
is_active: bool = True
|
||||
@@ -55,6 +56,8 @@ class CreditProductBase(BaseModel):
|
||||
raise ValueError("积分增值包必须配置积分数量")
|
||||
if self.price < 0:
|
||||
raise ValueError("增值包价格不能小于0")
|
||||
if self.validity_months is not None and not 1 <= self.validity_months <= 36:
|
||||
raise ValueError("积分增值包有效期必须为1-36个月")
|
||||
return self
|
||||
|
||||
|
||||
@@ -79,6 +82,7 @@ class CreditProductUpdate(BaseModel):
|
||||
renewal_enabled: bool | None = None
|
||||
price: float | None = Field(default=None, ge=0, le=999999999.99)
|
||||
grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
|
||||
validity_months: int | None = Field(default=None, ge=1, le=36)
|
||||
credit_level: Literal["promotional", "general"] | None = None
|
||||
currency: str | None = Field(default=None, min_length=1, max_length=8)
|
||||
is_active: bool | None = None
|
||||
|
||||
@@ -239,7 +239,7 @@ def product_to_dict(
|
||||
"activity_end_at": product.activity_end_at,
|
||||
"renewal_enabled": bool(product.renewal_enabled),
|
||||
"grant_credits": float(product.grant_credits or 0),
|
||||
"validity_months": 1 if product.is_credit_addon else None,
|
||||
"validity_months": int(product.validity_months or 1) if product.is_credit_addon else None,
|
||||
"price": float(user_price if user_price is not None else product.price),
|
||||
"current_price": float(user_price if user_price is not None else product.price),
|
||||
"target_price": float(target_price) if target_price is not None else None,
|
||||
|
||||
@@ -57,6 +57,7 @@ def _product_snapshot(product: CreditProduct) -> dict:
|
||||
"regular_price": float(product.regular_price or 0),
|
||||
"activity_price": float(product.activity_price) if product.activity_price is not None else None,
|
||||
"grant_credits": float(product.grant_credits or 0),
|
||||
"validity_months": int(product.validity_months or 1) if product.is_credit_addon else None,
|
||||
"credit_level": product.credit_level,
|
||||
"features": product.features_json or [],
|
||||
}
|
||||
@@ -83,6 +84,24 @@ def _snapshot_decimal(snapshot: dict, key: str) -> Decimal:
|
||||
return to_credit_decimal(snapshot.get(key) or 0)
|
||||
|
||||
|
||||
def _snapshot_validity_months(snapshot: dict) -> int:
|
||||
raw_value = snapshot.get("validity_months")
|
||||
if raw_value is None:
|
||||
# 兼容历史订单快照:旧版本增值包固定为1个自然月。
|
||||
return 1
|
||||
if isinstance(raw_value, bool):
|
||||
raise ValueError("积分增值包有效期快照无效")
|
||||
try:
|
||||
months = int(raw_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("积分增值包有效期快照无效") from exc
|
||||
if isinstance(raw_value, float) and not raw_value.is_integer():
|
||||
raise ValueError("积分增值包有效期快照无效")
|
||||
if not 1 <= months <= 36:
|
||||
raise ValueError("积分增值包有效期必须为1-36个月")
|
||||
return months
|
||||
|
||||
|
||||
async def _create_subscription(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -489,7 +508,7 @@ async def fulfill_payment_product(
|
||||
description=f"购买积分增值包:{order.product_name_snapshot or snapshot.get('name') or '积分增值包'}",
|
||||
source_type=CreditBalanceSourceType.CREDIT_ADDON.value,
|
||||
valid_from=checked_at,
|
||||
expires_at=add_natural_months(checked_at, 1),
|
||||
expires_at=add_natural_months(checked_at, _snapshot_validity_months(snapshot)),
|
||||
credit_level=str(snapshot.get("credit_level") or "general"),
|
||||
source_id=order.id,
|
||||
product_id=order.product_id,
|
||||
|
||||
Reference in New Issue
Block a user