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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
572 lines
19 KiB
Python
572 lines
19 KiB
Python
"""积分服务层 — 积分账户、扣减、充值、流水、每日免费额度 (#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": "确认支付异常"}
|