You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

8.9 KiB

RFC 137: 积分过期机制

状态: 已完成
创建日期: 2026-01-27
作者: System
类型: 功能增强

概述

为 Credit Service 实现积分过期机制,自动处理赠送积分的过期逻辑,确保积分系统的健康运行。

背景

当前 credit_gifts 表已有 expires_at 字段,但缺少过期处理逻辑:

  1. 数据完整性: 赠送积分可以设置过期时间,但过期后不会自动扣除
  2. 用户体验: 用户无法查询即将过期的积分
  3. 系统健康: 过期积分未被清理,影响数据准确性

问题

  1. 赠送积分过期后仍然可用
  2. 用户不知道哪些积分即将过期
  3. 缺少定时任务自动处理过期积分
  4. 没有过期通知机制

解决方案

设计原则

  1. 自动化: 使用 Celery 定时任务自动处理过期积分
  2. 透明性: 提供 API 查询即将过期的积分
  3. 可追溯: 所有过期操作记录到交易流水
  4. 容错性: 处理失败不影响其他记录

实现细节

1. Repository 层 - 数据访问

新增查询方法:

# app/repositories/credit_repository.py

async def get_expiring_gifts(self, days: int = 7) -> List[CreditGift]:
    """获取即将过期的赠送积分"""
    expiry_threshold = datetime.utcnow() + timedelta(days=days)
    
    result = await self.session.execute(
        select(CreditGift).where(
            and_(
                CreditGift.expires_at.isnot(None),
                CreditGift.expires_at <= expiry_threshold,
                CreditGift.expires_at > datetime.utcnow()
            )
        ).order_by(CreditGift.expires_at)
    )
    return list(result.scalars().all())

async def get_expired_gifts(self) -> List[CreditGift]:
    """获取已过期的赠送积分"""
    result = await self.session.execute(
        select(CreditGift).where(
            and_(
                CreditGift.expires_at.isnot(None),
                CreditGift.expires_at <= datetime.utcnow()
            )
        ).order_by(CreditGift.expires_at)
    )
    return list(result.scalars().all())

async def get_user_expiring_gifts(
    self,
    user_id: UUID,
    days: int = 7
) -> List[CreditGift]:
    """获取用户即将过期的赠送积分"""
    # 实现逻辑...

2. Service 层 - 业务逻辑

新增服务方法:

# app/services/credit_service.py

async def get_expiring_credits(
    self,
    user_id: UUID,
    days: int = 7
) -> Dict[str, Any]:
    """查询用户即将过期的积分"""
    gifts = await self.repository.get_user_expiring_gifts(user_id, days)
    
    total_expiring = sum(gift.credits for gift in gifts)
    
    items = []
    for gift in gifts:
        items.append({
            'gift_id': str(gift.gift_id),
            'credits': gift.credits,
            'gift_type': GiftType.to_name(gift.gift_type),
            'description': gift.description,
            'expires_at': gift.expires_at.isoformat(),
            'days_until_expiry': (gift.expires_at - datetime.utcnow()).days
        })
    
    return {
        'total_expiring': total_expiring,
        'items': items,
        'days': days
    }

async def expire_gift_credits(self) -> int:
    """过期赠送积分(定时任务调用)"""
    expired_gifts = await self.repository.get_expired_gifts()
    
    count = 0
    for gift in expired_gifts:
        try:
            user = await self._get_user(gift.user_id)
            
            # 余额不足时记录日志但不扣除
            if user.ai_credits_balance < gift.credits:
                logger.warning(f"用户 {gift.user_id} 余额不足,无法扣除过期积分")
                continue
            
            async with self.session.begin_nested():
                # 扣除积分
                balance_before = user.ai_credits_balance
                balance_after = balance_before - gift.credits
                user.ai_credits_balance = balance_after
                
                # 创建过期交易记录
                transaction = CreditTransaction(
                    user_id=gift.user_id,
                    transaction_type=TransactionType.EXPIRE,
                    amount=-gift.credits,
                    balance_before=balance_before,
                    balance_after=balance_after,
                    description=f"赠送积分过期:{gift.description}"
                )
                await self.repository.create_transaction(transaction)
            
            await self.session.commit()
            count += 1
            
        except Exception as e:
            logger.error(f"过期积分处理失败: {e}")
            await self.session.rollback()
    
    return count

3. Celery 定时任务

创建定时任务:

# app/tasks/credit_tasks.py

@celery_app.task(name="app.tasks.credit_tasks.expire_gift_credits_task")
def expire_gift_credits_task(self) -> Dict[str, Any]:
    """过期赠送积分定时任务
    
    每天凌晨 3 点执行
    """
    async def process_expired_credits():
        async with async_session_maker() as session:
            service = CreditService(session)
            count = await service.expire_gift_credits()
            return count
    
    count = asyncio.run(process_expired_credits())
    
    return {
        "task_id": self.request.id,
        "status": "completed",
        "expired_count": count,
        "message": f"成功处理 {count} 条过期积分记录"
    }

@celery_app.task(name="app.tasks.credit_tasks.notify_expiring_credits_task")
def notify_expiring_credits_task(self, days: int = 7) -> Dict[str, Any]:
    """通知即将过期的积分
    
    每天上午 10 点执行
    """
    # 实现通知逻辑...

4. Celery Beat 配置

celery_app.py 添加定时调度:

beat_schedule={
    # 每天凌晨 3 点处理过期积分
    "expire-gift-credits": {
        "task": "app.tasks.credit_tasks.expire_gift_credits_task",
        "schedule": crontab(hour=3, minute=0),
    },
    # 每天上午 10 点通知即将过期的积分(7 天内)
    "notify-expiring-credits": {
        "task": "app.tasks.credit_tasks.notify_expiring_credits_task",
        "schedule": crontab(hour=10, minute=0),
        "kwargs": {"days": 7},
    },
}

5. API 端点

新增查询接口:

# app/api/v1/credits.py

@router.get("/expiring")
async def get_expiring_credits(
    user_id: UUID = Query(..., description="用户 ID"),
    days: int = Query(7, ge=1, le=30, description="未来多少天内过期"),
    service: CreditService = Depends(get_credit_service)
):
    """查询即将过期的积分"""
    result = await service.get_expiring_credits(user_id, days)
    
    return success_response(
        data=result,
        message=f"查询到 {result['total_expiring']} 积分即将在 {days} 天内过期"
    )

优势

  1. 自动化管理: 定时任务自动处理过期积分,无需人工干预
  2. 用户透明: 用户可以查询即将过期的积分,提前使用
  3. 数据准确: 过期积分及时清理,保证数据准确性
  4. 可追溯性: 所有过期操作记录到交易流水,便于审计
  5. 容错性强: 单条记录失败不影响其他记录处理

风险与缓解

风险 影响 缓解措施
用户余额不足 无法扣除过期积分 记录日志但不中断流程
定时任务失败 过期积分未清理 添加监控和告警
并发问题 数据不一致 使用数据库事务保证原子性
性能问题 大量过期记录处理慢 分批处理,添加索引

实施步骤

  1. 扩展 CreditRepository 添加查询方法
  2. 扩展 CreditService 添加业务方法
  3. 创建 credit_tasks.py 添加 Celery 任务
  4. 更新 celery_app.py 配置定时调度
  5. 添加 API 端点 /credits/expiring
  6. 测试定时任务执行
  7. 添加监控和告警

测试策略

单元测试

async def test_get_expiring_credits():
    """测试查询即将过期的积分"""
    # 创建测试数据
    # 调用服务方法
    # 验证返回结果

async def test_expire_gift_credits():
    """测试过期积分处理"""
    # 创建过期的赠送记录
    # 调用过期方法
    # 验证积分已扣除
    # 验证交易记录已创建

集成测试

def test_expire_gift_credits_task():
    """测试定时任务执行"""
    result = expire_gift_credits_task.apply()
    assert result.status == "SUCCESS"

监控指标

  1. 过期处理数量: 每天处理的过期记录数
  2. 处理失败数: 失败的记录数和原因
  3. 任务执行时间: 定时任务的执行时长
  4. 用户余额不足: 无法扣除的记录数

相关文档

变更日志

  • 2026-01-27: 初始版本,实现积分过期机制