Files
xiaoxia-saas/packages/domain/points_service.py
T
xiaoxia 53fb25efcf
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m30s
CI/CD Pipeline / Validate - Style (push) Successful in 4m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m33s
CI/CD Pipeline / Validate - Security (push) Successful in 7m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (push) Successful in 10m11s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Failing after 26h14m3s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 26h24m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 26h19m47s
CI/CD Pipeline / PR Build Web Image (push) Failing after 26h23m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 26h23m44s
CI/CD Pipeline / Deploy Production (push) Failing after 26h13m23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 26h13m26s
CI/CD Pipeline / CI Gate (push) Failing after 26h13m25s
CI/CD Pipeline / Build Production API Image (push) Failing after 26h13m26s
CI/CD Pipeline / Canary Release to Production (push) Failing after 26h13m23s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 26h19m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 26h23m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 26h23m45s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 26h19m46s
fix(#1834): 批量修复 UP 系列静态分析警告(UP007/UP006/UP017/UP035) (#1928)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 12:59:17 +08:00

572 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""积分服务层 — 积分账户、扣减、充值、流水、每日免费额度 (#1895)
直接操作 SQLAlchemy session,不走 Repository 抽象层,简化事务处理。
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy.orm import Session
from packages.domain.points_rules import (
DAILY_FREE_CLIP_LIMIT,
POINTS_PACKAGES,
)
logger = logging.getLogger(__name__)
# ── 延迟导入模型(避免循环/顺序依赖) ────────────────────────────────────
def _get_models():
"""延迟获取积分相关模型类。"""
from packages.adapters.sqlalchemy_impl.models import (
DailyUsageRecordModel,
PointsAccountModel,
PointsOrderModel,
PointsTransactionModel,
UserModel,
)
return (
PointsAccountModel,
PointsTransactionModel,
PointsOrderModel,
DailyUsageRecordModel,
UserModel,
)
def _get_redis_client():
"""获取 Redis 客户端,用于每日额度缓存。"""
try:
import redis as redis_lib
from app.config import settings
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
except Exception:
return None
class PointsService:
"""积分核心服务。"""
# ──────────────── 账户管理 ────────────────
def get_or_create_account(self, user_id: str, db: Session) -> dict[str, Any]:
"""获取或创建积分账户,返回账户快照。"""
PointsAccountModel, _, _, _, _ = _get_models()
account = db.query(PointsAccountModel).filter(PointsAccountModel.user_id == user_id).first()
if account is None:
account = PointsAccountModel(
id=uuid.uuid4().hex,
user_id=user_id,
balance=0,
total_earned=0,
total_spent=0,
)
db.add(account)
db.flush()
return {
"id": account.id,
"user_id": account.user_id,
"balance": account.balance,
"total_earned": account.total_earned,
"total_spent": account.total_spent,
}
# ──────────────── 余额检查 ────────────────
def check_balance(self, user_id: str, amount: int, db: Session) -> dict[str, Any]:
"""检查余额是否足够。"""
account_data = self.get_or_create_account(user_id, db)
balance = account_data["balance"]
return {
"sufficient": balance >= amount,
"balance": balance,
"required": amount,
"remaining_after": balance - amount,
}
# ──────────────── 积分扣减(事务性) ────────────────
def deduct_points(
self,
user_id: str,
amount: int,
source: str,
db: Session,
description: str = "",
ref_id: str = "",
) -> dict[str, Any]:
"""扣减积分(事务性:SELECT FOR UPDATE → 检查余额 → 扣减 → 流水 → 同步用户表)。
Returns:
{"success": True/False, "balance": int, "transaction_id": str|None}
"""
PointsAccountModel, PointsTransactionModel, _, _, UserModel = _get_models()
try:
# 1. 行锁获取账户
account = (
db.query(PointsAccountModel).filter(PointsAccountModel.user_id == user_id).with_for_update().first()
)
if account is None:
account = PointsAccountModel(
id=uuid.uuid4().hex,
user_id=user_id,
balance=0,
total_earned=0,
total_spent=0,
)
db.add(account)
db.flush()
# 2. 检查余额
if account.balance < amount:
return {
"success": False,
"balance": account.balance,
"transaction_id": None,
}
# 3. 扣减余额
account.balance -= amount
account.total_spent += amount
# 4. 创建流水
txn_id = uuid.uuid4().hex
txn = PointsTransactionModel(
id=txn_id,
user_id=user_id,
account_id=account.id,
type="deduct",
source=source,
amount=amount,
balance_after=account.balance,
description=description or f"积分扣减: {source}",
ref_id=ref_id,
)
db.add(txn)
# 5. 同步用户表 points_balance
db.execute(
UserModel.__table__.update()
.where(UserModel.__table__.c.id == user_id)
.values(points_balance=account.balance)
)
db.commit()
return {
"success": True,
"balance": account.balance,
"transaction_id": txn_id,
}
except Exception:
db.rollback()
logger.exception(
"积分扣减失败: user_id=%s, amount=%d, source=%s",
user_id,
amount,
source,
)
return {"success": False, "balance": 0, "transaction_id": None}
# ──────────────── 积分增加 ────────────────
def add_points(
self,
user_id: str,
amount: int,
source: str,
db: Session,
description: str = "",
ref_id: str = "",
) -> dict[str, Any]:
"""增加积分(充值/赠送/退款)。"""
PointsAccountModel, PointsTransactionModel, _, _, UserModel = _get_models()
try:
account = (
db.query(PointsAccountModel).filter(PointsAccountModel.user_id == user_id).with_for_update().first()
)
if account is None:
account = PointsAccountModel(
id=uuid.uuid4().hex,
user_id=user_id,
balance=0,
total_earned=0,
total_spent=0,
)
db.add(account)
db.flush()
account.balance += amount
account.total_earned += amount
txn_id = uuid.uuid4().hex
txn = PointsTransactionModel(
id=txn_id,
user_id=user_id,
account_id=account.id,
type="add",
source=source,
amount=amount,
balance_after=account.balance,
description=description or f"积分增加: {source}",
ref_id=ref_id,
)
db.add(txn)
db.execute(
UserModel.__table__.update()
.where(UserModel.__table__.c.id == user_id)
.values(points_balance=account.balance)
)
db.commit()
return {
"success": True,
"balance": account.balance,
"transaction_id": txn_id,
}
except Exception:
db.rollback()
logger.exception(
"积分增加失败: user_id=%s, amount=%d, source=%s",
user_id,
amount,
source,
)
return {"success": False, "balance": 0, "transaction_id": None}
# ──────────────── 积分退还 ────────────────
def refund_points(
self,
user_id: str,
amount: int,
source: str,
db: Session,
ref_id: str = "",
description: str = "",
) -> dict[str, Any]:
"""退还积分(业务失败回退)。内部调用 add_points,source 前缀 refund:。"""
return self.add_points(
user_id=user_id,
amount=amount,
source=f"refund:{source}",
db=db,
description=description or f"积分退还: {source}",
ref_id=ref_id,
)
# ──────────────── 流水查询 ────────────────
def get_transactions(
self,
user_id: str,
db: Session,
page: int = 1,
page_size: int = 20,
type_filter: str | None = None,
source_filter: str | None = None,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> dict[str, Any]:
"""查询积分流水(分页+筛选)。"""
_, PointsTransactionModel, _, _, _ = _get_models()
query = db.query(PointsTransactionModel).filter(PointsTransactionModel.user_id == user_id)
if type_filter:
query = query.filter(PointsTransactionModel.type == type_filter)
if source_filter:
query = query.filter(PointsTransactionModel.source == source_filter)
if start_date:
query = query.filter(PointsTransactionModel.created_at >= start_date)
if end_date:
query = query.filter(PointsTransactionModel.created_at <= end_date)
total = query.count()
items = (
query.order_by(PointsTransactionModel.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {
"items": [
{
"id": item.id,
"type": item.type,
"source": item.source,
"amount": item.amount,
"balance_after": item.balance_after,
"description": item.description,
"ref_id": item.ref_id,
"created_at": (item.created_at.isoformat() if item.created_at else None),
}
for item in items
],
"total": total,
"page": page,
"page_size": page_size,
}
# ──────────────── 每日免费混剪额度 ────────────────
def _daily_key(self, user_id: str) -> str:
"""生成 Redis 每日额度 key。格式: daily_usage:{user_id}:{YYYYMMDD}:free_clip"""
today = datetime.now(UTC).strftime("%Y%m%d")
return f"daily_usage:{user_id}:{today}:free_clip"
def check_daily_free_clip(self, user_id: str, db: Session) -> bool:
"""检查今日是否还有免费混剪额度。
优先查 Redis,Redis 不可用时降级到 DB。
"""
redis_client = _get_redis_client()
if redis_client:
try:
key = self._daily_key(user_id)
current = redis_client.get(key)
if current is None:
return True
return int(current) < DAILY_FREE_CLIP_LIMIT
except Exception:
logger.warning("Redis 不可用,降级到 DB 查询每日额度")
# 降级到 DB
_, _, _, DailyUsageRecordModel, _ = _get_models()
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
record = (
db.query(DailyUsageRecordModel)
.filter(
DailyUsageRecordModel.user_id == user_id,
DailyUsageRecordModel.usage_type == "free_clip",
DailyUsageRecordModel.usage_date >= today_start,
)
.first()
)
if record is None:
return True
return record.count < DAILY_FREE_CLIP_LIMIT
def record_daily_free_clip(self, user_id: str, db: Session) -> bool:
"""记录使用一次免费混剪。
先 INCR Redis;如果超限回退 Redis。DB 使用 upsert 语义(唯一约束)。
"""
redis_client = _get_redis_client()
if redis_client:
try:
key = self._daily_key(user_id)
new_count = redis_client.incr(key)
if new_count == 1:
redis_client.expire(key, 48 * 3600) # TTL 48h
if new_count <= DAILY_FREE_CLIP_LIMIT:
return True
# 超限,回退 Redis
redis_client.decr(key)
except Exception:
logger.warning("Redis 不可用,降级到 DB 记录每日额度")
# 降级/兜底到 DB(upsert 语义)
_, _, _, DailyUsageRecordModel, _ = _get_models()
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
record = (
db.query(DailyUsageRecordModel)
.filter(
DailyUsageRecordModel.user_id == user_id,
DailyUsageRecordModel.usage_type == "free_clip",
DailyUsageRecordModel.usage_date >= today_start,
)
.first()
)
if record is None:
if DAILY_FREE_CLIP_LIMIT <= 0:
return False
record = DailyUsageRecordModel(
id=uuid.uuid4().hex,
user_id=user_id,
usage_type="free_clip",
usage_date=datetime.now(UTC),
count=1,
)
db.add(record)
else:
if record.count >= DAILY_FREE_CLIP_LIMIT:
return False
record.count += 1
db.commit()
return True
def get_daily_usage(self, user_id: str, db: Session) -> dict[str, Any]:
"""查询今日免费额度使用情况。"""
redis_client = _get_redis_client()
used = 0
if redis_client:
try:
key = self._daily_key(user_id)
val = redis_client.get(key)
used = int(val) if val else 0
except Exception:
pass
if used == 0:
# 从 DB 查
_, _, _, DailyUsageRecordModel, _ = _get_models()
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
record = (
db.query(DailyUsageRecordModel)
.filter(
DailyUsageRecordModel.user_id == user_id,
DailyUsageRecordModel.usage_type == "free_clip",
DailyUsageRecordModel.usage_date >= today_start,
)
.first()
)
used = record.count if record else 0
now = datetime.now(UTC)
tomorrow = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
return {
"free_clips_used": used,
"free_clips_limit": DAILY_FREE_CLIP_LIMIT,
"free_clips_remaining": max(0, DAILY_FREE_CLIP_LIMIT - used),
"reset_at": tomorrow.isoformat(),
}
# ──────────────── 订单管理 ────────────────
def create_order(
self,
user_id: str,
order_type: str,
product_code: str,
db: Session,
) -> dict[str, Any]:
"""创建积分充值或会员购买订单。
Args:
order_type: "points" 或 "membership"
product_code: 积分包 code (如 "starter_pack") 或会员类型 (如 "monthly")
"""
_, _, PointsOrderModel, _, _ = _get_models()
amount_cents = 0
points_amount = 0
if order_type == "points":
package = POINTS_PACKAGES.get(product_code)
if not package:
raise ValueError(f"Unknown points package: {product_code}")
amount_cents = package["price_cents"]
points_amount = package["points"]
elif order_type == "membership":
from packages.domain.points_rules import MEMBERSHIP_PRICES
membership = MEMBERSHIP_PRICES.get(product_code)
if not membership:
raise ValueError(f"Unknown membership type: {product_code}")
amount_cents = membership["price_cents"]
else:
raise ValueError(f"Unknown order type: {order_type}")
order = PointsOrderModel(
id=uuid.uuid4().hex,
user_id=user_id,
order_type=order_type,
product_code=product_code,
amount_cents=amount_cents,
original_amount_cents=amount_cents,
points_amount=points_amount,
status="pending",
)
db.add(order)
db.commit()
return {
"id": order.id,
"order_type": order.order_type,
"product_code": order.product_code,
"amount_cents": order.amount_cents,
"status": order.status,
"created_at": (order.created_at.isoformat() if order.created_at else None),
}
def confirm_payment(
self,
order_id: str,
payment_id: str,
db: Session,
) -> dict[str, Any]:
"""确认支付 → 更新订单状态 → 发放积分或会员。"""
_, _, PointsOrderModel, _, UserModel = _get_models()
try:
order = db.query(PointsOrderModel).filter(PointsOrderModel.id == order_id).with_for_update().first()
if order is None:
return {"success": False, "message": "订单不存在"}
if order.status != "pending":
return {"success": False, "message": f"订单状态异常: {order.status}"}
# 更新订单状态
order.status = "paid"
order.payment_id = payment_id
order.paid_at = datetime.now(UTC)
if order.order_type == "points":
# 发放积分
self.add_points(
user_id=order.user_id,
amount=order.points_amount,
source=f"recharge:{order.product_code}",
db=db,
description=f"积分充值: {order.product_code}",
ref_id=order.id,
)
elif order.order_type == "membership":
# 激活会员
from packages.domain.points_rules import MEMBERSHIP_PRICES
membership = MEMBERSHIP_PRICES.get(order.product_code, {})
duration_days = membership.get("duration_days", 30)
user = db.query(UserModel).filter(UserModel.id == order.user_id).first()
if user:
now = datetime.now(UTC)
current_expires = user.member_expires_at or now
if current_expires < now:
current_expires = now
user.member_expires_at = current_expires + timedelta(days=duration_days)
user.member_type = order.product_code
user.is_member = True
db.commit()
return {
"success": True,
"message": "支付确认成功",
"order_id": order_id,
}
except Exception:
db.rollback()
logger.exception("确认支付失败: order_id=%s", order_id)
return {"success": False, "message": "确认支付异常"}