"""积分服务层 — 积分账户、扣减、充值、流水、每日免费额度 (#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": "确认支付异常"}