This commit is contained in:
root
2026-10-16 13:51:00 +08:00
parent 6bf949949c
commit 8fc899d0a9
3830 changed files with 11228 additions and 188377 deletions
View File
+299
View File
@@ -0,0 +1,299 @@
"""管理后台路由(/admin/*,对应《03-接口文档》第 11 章)
角色矩阵(super 拥有全部权限):
- reviewer:仅审核 → 待审核列表 / 审核视频
- operator:套餐、活动、看板 → 仪表盘 / 套餐 CRUD / 赠送
- 其余接口仅 super
"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.database import get_db
from app.deps import AdminIdentity, require_roles
from app.redis_client import get_redis
from app.schemas import admin as schema
from app.services import admin_service
router = APIRouter(prefix="/admin", tags=["管理后台"])
# 角色依赖:super 自动放行
require_reviewer = require_roles("reviewer")
require_operator = require_roles("operator")
require_super = require_roles()
def _ip(request: Request) -> str:
return request.client.host if request.client else ""
# ---------- 认证 / 看板 ----------
@router.post("/auth/login")
async def login(req: schema.AdminLoginReq, request: Request, db: AsyncSession = Depends(get_db)):
return ok(await admin_service.login(db, req, _ip(request)))
@router.get("/dashboard")
async def dashboard(db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.dashboard(db))
# ---------- 视频 ----------
@router.get("/videos/pending")
async def list_pending_videos(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_reviewer)):
return ok(await admin_service.list_pending_videos(db, page, page_size))
@router.get("/videos")
async def list_videos(keyword: str | None = Query(None), status: int | None = Query(None),
author_id: int | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_videos(db, keyword, status, author_id, page, page_size))
@router.post("/videos/{video_id}/review")
async def review_video(video_id: int, req: schema.VideoReviewReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_reviewer)):
return ok(await admin_service.review_video(db, redis, admin, video_id, req, _ip(request)))
@router.post("/videos/{video_id}/offline")
async def offline_video(video_id: int, req: schema.ReasonReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
await admin_service.offline_video(db, redis, admin, video_id, req.reason, _ip(request))
return ok()
# ---------- 用户 / 积分 ----------
@router.get("/users")
async def list_users(keyword: str | None = Query(None), status: int | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_users(db, keyword, status, page, page_size))
@router.get("/users/{user_id}")
async def get_user_detail(user_id: int, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.get_user_detail(db, user_id))
@router.put("/users/{user_id}/status")
async def set_user_status(user_id: int, req: schema.UserStatusReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.set_user_status(db, redis, admin, user_id, req, _ip(request)))
@router.post("/points/adjust")
async def adjust_points(req: schema.PointsAdjustReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.adjust_points(db, admin, req, _ip(request)))
@router.get("/points/records")
async def list_point_records(user_id: int | None = Query(None), biz_type: int | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_point_records(db, user_id, biz_type, page, page_size))
# ---------- 订单 / 套餐 ----------
@router.get("/orders")
async def list_orders(order_no: str | None = Query(None), user_id: int | None = Query(None),
pay_method: int | None = Query(None), status: int | None = Query(None),
start_date: str | None = Query(None), end_date: str | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_orders(
db, order_no, user_id, pay_method, status, start_date, end_date, page, page_size
))
@router.post("/orders/{order_no}/resettle")
async def resettle_order(order_no: str, req: schema.ResettleReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.resettle_order(db, admin, order_no, req, _ip(request)))
@router.post("/orders/{order_no}/refund")
async def refund_order(order_no: str, req: schema.RefundReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.refund_order(db, admin, order_no, req, _ip(request)))
@router.get("/packages")
async def list_packages(db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.list_packages(db))
@router.post("/packages")
async def create_package(req: schema.PackageCreateReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.create_package(db, admin, req, _ip(request)))
@router.put("/packages/{package_id}")
async def update_package(package_id: int, req: schema.PackageUpdateReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.update_package(db, admin, package_id, req, _ip(request)))
@router.delete("/packages/{package_id}")
async def delete_package(package_id: int, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.delete_package(db, admin, package_id, _ip(request)))
# ---------- 赠送 / 日志 ----------
@router.post("/gift")
async def gift(req: schema.GiftReq, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.gift(db, admin, req, _ip(request)))
@router.get("/logs")
async def list_logs(admin_id: int | None = Query(None), action: str | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_logs(db, admin_id, action, page, page_size))
# ---------- 提现 ----------
@router.get("/withdrawals")
async def list_withdrawals(status: int | None = Query(None), user_id: int | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_withdrawals(db, status, user_id, page, page_size))
@router.post("/withdrawals/{withdraw_no}/review")
async def review_withdrawal(withdraw_no: str, req: schema.WithdrawReviewReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.review_withdrawal(db, redis, admin, withdraw_no, req, _ip(request)))
# ---------- 推荐 ----------
@router.get("/referrals")
async def list_referrals(inviter_id: int | None = Query(None), status: int | None = Query(None),
risk_flag: str | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_referrals(db, inviter_id, status, risk_flag, page, page_size))
@router.post("/referrals/{referral_id}/review")
async def review_referral(referral_id: int, req: schema.ReferralReviewReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.review_referral(db, admin, referral_id, req, _ip(request)))
# ---------- 配置 ----------
@router.get("/configs")
async def list_configs(db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_configs(db))
@router.put("/configs/{config_key}")
async def update_config(config_key: str, req: schema.ConfigUpdateReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.update_config(db, redis, admin, config_key, req.value, _ip(request)))
# ---------- 评论 / 举报 ----------
@router.get("/comments")
async def list_comments(video_id: int | None = Query(None), user_id: int | None = Query(None),
keyword: str | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_comments(db, video_id, user_id, keyword, page, page_size))
@router.delete("/comments/{comment_id}")
async def delete_comment(comment_id: int, req: schema.ReasonReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
await admin_service.delete_comment(db, redis, admin, comment_id, req.reason, _ip(request))
return ok()
@router.get("/reports")
async def list_reports(status: int | None = Query(None), target_type: int | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_reports(db, status, target_type, page, page_size))
@router.post("/reports/{report_id}/review")
async def review_report(report_id: int, req: schema.ReportReviewReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.review_report(db, redis, admin, report_id, req, _ip(request)))
# ---------- 敏感词 ----------
@router.get("/sensitive-words")
async def list_sensitive_words(keyword: str | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_sensitive_words(db, keyword, page, page_size))
@router.post("/sensitive-words")
async def add_sensitive_word(req: schema.SensitiveWordAddReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.add_sensitive_word(db, redis, admin, req.word, _ip(request)))
@router.delete("/sensitive-words/{word_id}")
async def delete_sensitive_word(word_id: int, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
await admin_service.delete_sensitive_word(db, redis, admin, word_id, _ip(request))
return ok()
+77
View File
@@ -0,0 +1,77 @@
"""认证模块路由(/auth/*,对应《03-接口文档》第 3 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Header, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.core.security import decode_token
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.auth import (
EmailSendReq,
LoginReq,
PasswordResetReq,
RefreshReq,
RegisterReq,
SmsSendReq,
)
from app.services import auth_service
router = APIRouter(prefix="/auth", tags=["认证"])
def _client_ip(request: Request) -> str:
# Nginx 反代后取真实 IP
return request.headers.get("X-Real-IP") or (request.client.host if request.client else "")
@router.post("/sms/send")
async def sms_send(req: SmsSendReq, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
data = await auth_service.send_code(db, redis, "sms", req.phone, req.scene, _client_ip(request))
return ok(data)
@router.post("/email/send")
async def email_send(req: EmailSendReq, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
data = await auth_service.send_code(db, redis, "email", req.email, req.scene, _client_ip(request))
return ok(data)
@router.post("/register")
async def register(req: RegisterReq, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
data = await auth_service.register(db, redis, req, identity.device_id, _client_ip(request))
return ok(data)
@router.post("/login")
async def login(req: LoginReq, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
data = await auth_service.login(db, redis, req, _client_ip(request))
return ok(data)
@router.post("/token/refresh")
async def token_refresh(req: RefreshReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
return ok(await auth_service.refresh(db, redis, req.refresh_token))
@router.post("/logout")
async def logout(authorization: str = Header(...),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
payload = decode_token(authorization.removeprefix("Bearer ").strip())
await auth_service.logout(redis, identity.user_id, payload.get("jti", ""), int(payload.get("exp", 0)))
return ok()
@router.post("/password/reset")
async def password_reset(req: PasswordResetReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
await auth_service.reset_password(db, redis, req)
return ok()
+54
View File
@@ -0,0 +1,54 @@
"""通用与消息模块路由(/app/version + /notifications + /reports,对应《03-接口文档》第 10 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import ratelimit
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.common import NotificationReadReq, ReportReq
from app.services import common_service
router = APIRouter(tags=["通用"])
@router.get("/app/version")
async def get_app_version(platform: str = Query(pattern="^(ios|android)$"),
version_code: int = Query(ge=1),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await common_service.get_app_version(db, redis, platform, version_code))
@router.get("/notifications")
async def list_notifications(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
type: int | None = Query(None),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await common_service.list_notifications(db, identity.user_id, page, page_size, type))
@router.get("/notifications/unread_count")
async def get_unread_count(db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok({"unread": await common_service.get_unread_count(db, redis, identity.user_id)})
@router.post("/notifications/read")
async def mark_read(req: NotificationReadReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await common_service.mark_read(db, redis, identity.user_id, req.ids, bool(req.all))
return ok()
@router.post("/reports")
async def create_report(req: ReportReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "report", 10, 86400, "今日举报次数已达上限")
await common_service.create_report(db, redis, identity.user_id, req)
return ok(message="已收到举报,我们会尽快处理")
+62
View File
@@ -0,0 +1,62 @@
"""激励模块路由(/referral/* + /assets/*,对应《03-接口文档》第 9 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import ratelimit
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, require_user
from app.redis_client import get_redis
from app.schemas.incentive import BindInviteReq, WithdrawReq
from app.services import incentive_service
router_referral = APIRouter(prefix="/referral", tags=["推荐"])
router_assets = APIRouter(prefix="/assets", tags=["资产"])
@router_referral.get("/me")
async def get_my_referral(db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await incentive_service.get_my_referral(db, redis, identity.user_id))
@router_referral.post("/bind")
async def bind_invite_code(req: BindInviteReq, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
ip = request.client.host if request.client else ""
return ok(await incentive_service.bind_invite_code(
db, redis, identity.user_id, req.invite_code, identity.device_id, ip
))
@router_assets.get("/me")
async def get_my_assets(db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await incentive_service.get_my_assets(db, redis, identity.user_id))
@router_assets.get("/records")
async def list_asset_records(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
biz_type: int | None = Query(None),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await incentive_service.list_asset_records(db, identity.user_id, page, page_size, biz_type))
@router_assets.post("/withdraw")
async def apply_withdraw(req: WithdrawReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "withdraw", 1, 60, "提现申请过于频繁,请稍后再试")
return ok(await incentive_service.apply_withdraw(
db, redis, identity.user_id, req.assets, req.channel, req.account
))
@router_assets.get("/withdraw/records")
async def list_withdraw_records(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await incentive_service.list_withdraw_records(db, identity.user_id, page, page_size))
@@ -0,0 +1,55 @@
"""互动模块路由(对应《03-接口文档》第 6 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import ratelimit
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.interaction import CommentCreateReq
from app.services import interaction_service
# /videos/{video_id} 下的互动接口
router_videos = APIRouter(prefix="/videos", tags=["互动"])
# /comments 下的接口
router_comments = APIRouter(prefix="/comments", tags=["互动"])
@router_videos.post("/{video_id}/like")
async def toggle_like(video_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await interaction_service.toggle_like(db, redis, identity.user_id, video_id))
@router_videos.post("/{video_id}/favorite")
async def toggle_favorite(video_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await interaction_service.toggle_favorite(db, redis, identity.user_id, video_id))
@router_videos.get("/{video_id}/comments")
async def list_comments(video_id: int, page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=50),
parent_id: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(get_identity)):
return ok(await interaction_service.list_comments(db, video_id, page, page_size, parent_id))
@router_videos.post("/{video_id}/comments")
async def create_comment(video_id: int, req: CommentCreateReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "comment", 10, 60, "评论过于频繁,请稍后再试")
return ok(await interaction_service.create_comment(db, redis, identity.user_id, video_id, req))
@router_comments.delete("/{comment_id}")
async def delete_comment(comment_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await interaction_service.delete_comment(db, redis, identity.user_id, comment_id))
+45
View File
@@ -0,0 +1,45 @@
"""推广归因路由(/invite/* + H5 中间页 /r/{code})
三段式归因:分享口令(主)→ H5 点击埋点模糊匹配(辅)→ 注册手填邀请码(兜底)
"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.database import get_db
from app.redis_client import get_redis
from app.schemas.invite import InviteClickReq, InviteMatchReq
from app.services import invite_service
router = APIRouter(prefix="/invite", tags=["推广归因"])
router_page = APIRouter(tags=["推广落地页"], include_in_schema=False)
def _client_ip(request: Request) -> str:
# Nginx 反代后取真实 IP
return request.headers.get("X-Real-IP") or (request.client.host if request.client else "")
@router.post("/click")
async def invite_click(req: InviteClickReq, request: Request,
redis: aioredis.Redis = Depends(get_redis)):
"""H5 中间页点击埋点(打开页面即上报,记录 code+IP+UA,24h 有效)"""
ua = request.headers.get("User-Agent", "")
return ok(await invite_service.record_click(redis, req.code.upper(), _client_ip(request), ua))
@router.post("/match")
async def invite_match(req: InviteMatchReq, request: Request,
redis: aioredis.Redis = Depends(get_redis)):
"""App 首启归因匹配:按 IP+时间就近匹配点击记录,结果按 device_id 服务端缓存"""
ua = request.headers.get("User-Agent", "")
return ok(await invite_service.match_and_cache(redis, req.device_id, _client_ip(request), ua))
@router_page.get("/r/{code}", response_class=HTMLResponse)
async def invite_landing(code: str, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
"""推广落地页(复制口令 + 下载入口,打开即自动埋点)"""
return await invite_service.render_landing_page(db, redis, code.upper())
+28
View File
@@ -0,0 +1,28 @@
"""会员模块路由(/membership/*,对应《03-接口文档》第 7 章)"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, require_user
from app.schemas.common import RedeemReq
from app.services import membership_service
router = APIRouter(prefix="/membership", tags=["会员"])
@router.get("/plans")
async def list_plans():
return ok(membership_service.list_plans())
@router.get("/me")
async def get_my_membership(db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await membership_service.get_my_membership(db, identity.user_id))
@router.post("/redeem")
async def redeem(req: RedeemReq, db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await membership_service.redeem(db, identity.user_id, req.plan_code))
+58
View File
@@ -0,0 +1,58 @@
"""支付模块路由(/payments/* + /callbacks/*,对应《03-接口文档》第 8 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Header, Request
from fastapi.responses import JSONResponse, PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, require_user
from app.redis_client import get_redis
from app.schemas.payment import RechargeReq
from app.services import payment_service
router = APIRouter(prefix="/payments", tags=["支付"])
router_callbacks = APIRouter(prefix="/callbacks", tags=["支付回调"])
@router.get("/packages")
async def list_packages(db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await payment_service.list_packages(db, redis))
@router.post("/recharge")
async def create_recharge(req: RechargeReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
x_platform: str | None = Header(default=None),
identity: Identity = Depends(require_user)):
return ok(await payment_service.create_recharge(db, redis, identity.user_id, req, x_platform))
@router.get("/orders/{order_no}")
async def get_order(order_no: str, db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await payment_service.get_order(db, identity.user_id, order_no))
@router.post("/orders/{order_no}/cancel")
async def cancel_order(order_no: str, db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
await payment_service.cancel_order(db, identity.user_id, order_no)
return ok()
@router_callbacks.post("/alipay", include_in_schema=False)
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
"""支付宝异步通知:平台要求纯文本 success/failure"""
form = dict(await request.form())
result = await payment_service.handle_alipay_callback(db, form)
return PlainTextResponse(result)
@router_callbacks.post("/wechat", include_in_schema=False)
async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
"""微信支付 V3 异步通知:{"code": "SUCCESS"} 或 {"code": "FAIL"}"""
body = await request.body()
result = await payment_service.handle_wechat_callback(db, dict(request.headers), body)
return JSONResponse(result)
+89
View File
@@ -0,0 +1,89 @@
"""用户模块路由(/users/*,对应《03-接口文档》第 4 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.user import DeactivateReq, UpdateMeReq
from app.services import interaction_service, storage_service, user_service, video_service
router = APIRouter(prefix="/users", tags=["用户"])
# 注意:/users/me 系列须先于 /users/{user_id} 注册,避免 "me" 被路径参数匹配
@router.get("/me")
async def get_me(db: AsyncSession = Depends(get_db), identity: Identity = Depends(require_user)):
return ok(await user_service.get_me(db, identity.user_id))
@router.put("/me")
async def update_me(req: UpdateMeReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await user_service.update_me(db, redis, identity.user_id, req))
@router.post("/me/avatar")
async def upload_avatar(file: UploadFile, db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
url = await storage_service.save_avatar(file, identity.user_id)
return ok(await user_service.set_avatar(db, identity.user_id, url))
@router.get("/me/wallet")
async def get_wallet(db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await user_service.get_wallet(db, redis, identity.user_id))
@router.get("/me/points/records")
async def get_points_records(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
biz_type: int | None = Query(None),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await user_service.get_points_records(db, identity.user_id, page, page_size, biz_type))
@router.post("/me/deactivate")
async def deactivate(req: DeactivateReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await user_service.deactivate(db, redis, identity.user_id, req.password))
@router.get("/me/favorites")
async def get_my_favorites(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await interaction_service.list_my_favorites(db, identity.user_id, page, page_size))
@router.get("/me/history")
async def get_my_history(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await interaction_service.list_my_history(db, identity.user_id, page, page_size))
@router.get("/me/videos")
async def get_my_videos(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
status: int | None = Query(None, ge=0, le=5),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await video_service.list_my_videos(db, identity.user_id, page, page_size, status))
@router.get("/{user_id}")
async def get_public_profile(user_id: int, db: AsyncSession = Depends(get_db)):
return ok(await user_service.get_public_profile(db, user_id))
@router.get("/{user_id}/videos")
async def get_user_videos(user_id: int, page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(get_identity)):
return ok(await user_service.get_user_videos(db, user_id, page, page_size, identity.user_id))
+117
View File
@@ -0,0 +1,117 @@
"""视频模块路由(/videos/* + /tags/* + /search/*,对应《03-接口文档》第 5 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Form, Query, Request, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import ratelimit
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.video import EditVideoReq, PublishVideoReq, UploadTokenReq, WatchCompleteReq, WatchReq
from app.services import video_service
router = APIRouter(prefix="/videos", tags=["视频"])
router_tags = APIRouter(prefix="/tags", tags=["视频"])
router_search = APIRouter(prefix="/search", tags=["搜索"])
# 搜索限流:30 次/分钟(登录按用户,游客按 IP)
SEARCH_RATE_LIMIT = 30
# 上传凭证 + 发布限流:5 次/小时(同一动作桶)
UPLOAD_RATE_LIMIT = 5
# 注意:/videos/feed、/videos/categories、/videos/search、/videos/upload/* 须先于 /videos/{video_id} 注册
@router.get("/feed")
async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20),
category_id: int | None = Query(None),
type: str = Query("recommend", pattern="^(recommend|latest|hot)$"),
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.get_feed(db, redis, identity, type, cursor, limit, category_id))
@router.get("/categories")
async def get_categories(db: AsyncSession = Depends(get_db)):
return ok(await video_service.list_categories(db))
@router.get("/search")
async def search_videos(request: Request, keyword: str = Query(min_length=1, max_length=30),
sort: str = Query("hot", pattern="^(hot|latest)$"),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
subject = identity.subject or (request.client.host if request.client else "anon")
await ratelimit.hit_subject(redis, subject, "search", SEARCH_RATE_LIMIT, 60)
return ok(await video_service.search_videos(db, redis, identity, keyword, sort, page, page_size))
@router_search.get("/hot")
async def get_hot_search(redis: aioredis.Redis = Depends(get_redis)):
return ok(await video_service.get_hot_search(redis))
@router_tags.get("/{tag_name}/videos")
async def get_tag_videos(tag_name: str, sort: str = Query("hot", pattern="^(hot|latest)$"),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db), identity: Identity = Depends(get_identity)):
return ok(await video_service.get_tag_videos(db, identity, tag_name, sort, page, page_size))
@router.post("/upload/token")
async def get_upload_token(req: UploadTokenReq, redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "upload", UPLOAD_RATE_LIMIT, 3600, "上传过于频繁,请稍后再试")
return ok(await video_service.get_upload_token(redis, identity, req))
@router.post("/upload/file")
async def upload_file(file: UploadFile, object_key: str = Form(...),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await video_service.register_upload(redis, identity, object_key, file))
@router.post("")
async def publish_video(req: PublishVideoReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "upload", UPLOAD_RATE_LIMIT, 3600, "发布过于频繁,请稍后再试")
return ok(await video_service.publish_video(db, redis, identity, req))
@router.get("/{video_id}")
async def get_detail(video_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.get_detail(db, redis, identity, video_id))
@router.put("/{video_id}")
async def update_video(video_id: int, req: EditVideoReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await video_service.update_video(db, redis, identity, video_id, req))
@router.delete("/{video_id}")
async def delete_video(video_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await video_service.delete_video(db, redis, identity, video_id)
return ok()
@router.post("/{video_id}/watch")
async def report_watch(video_id: int, req: WatchReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.report_watch(db, redis, identity, video_id, req.position))
@router.post("/{video_id}/watch/complete")
async def report_complete(video_id: int, req: WatchCompleteReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.report_complete(db, redis, identity, video_id, req.watch_duration))
+82
View File
@@ -0,0 +1,82 @@
"""应用配置:环境变量(.env)+ 业务参数(config.yaml)"""
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parent.parent # server/
class Settings(BaseSettings):
"""环境配置(连接串、密钥等,均不入库)"""
model_config = SettingsConfigDict(
env_file=BASE_DIR / ".env", env_file_encoding="utf-8", extra="ignore"
)
# 服务
app_env: str = "dev"
app_host: str = "0.0.0.0"
app_port: int = 8000
# 数据库 / Redis
database_url: str = "mysql+aiomysql://root:root@127.0.0.1:3306/video?charset=utf8mb4"
redis_url: str = "redis://127.0.0.1:6379/0"
celery_broker_url: str = "redis://127.0.0.1:6379/1"
celery_result_backend: str = "redis://127.0.0.1:6379/2"
# JWT
jwt_secret: str = "please-change-me-in-production"
jwt_algorithm: str = "HS256"
access_token_ttl: int = 2 * 3600
refresh_token_ttl: int = 30 * 24 * 3600
admin_token_ttl: int = 12 * 3600
# 对象存储
oss_endpoint: str = ""
oss_bucket: str = ""
oss_access_key_id: str = ""
oss_access_key_secret: str = ""
# 支付渠道
alipay_app_id: str = ""
alipay_private_key: str = ""
alipay_public_key: str = ""
alipay_notify_url: str = ""
alipay_return_url: str = ""
wechat_mchid: str = ""
wechat_app_id: str = ""
wechat_api_v3_key: str = ""
wechat_private_key: str = ""
wechat_cert_serial_no: str = "" # 商户 API 证书序列号
wechat_platform_cert: str = "" # 微信支付平台证书(PEM,回调验签用)
wechat_notify_url: str = ""
wechat_api_base: str = "https://api.mch.weixin.qq.com"
tron_api_key: str = ""
# 短信 / 邮件
sms_access_key_id: str = ""
sms_access_key_secret: str = ""
sms_sign_name: str = ""
smtp_host: str = ""
smtp_port: int = 465
smtp_user: str = ""
smtp_password: str = ""
@lru_cache
def get_settings() -> Settings:
return Settings()
@lru_cache
def get_biz_config() -> dict[str, Any]:
"""业务参数(积分/会员/激励等默认值,后台 system_configs 可覆盖部分项)"""
with open(BASE_DIR / "config.yaml", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
settings = get_settings()
biz_config = get_biz_config()
+66
View File
@@ -0,0 +1,66 @@
"""业务错误码(对齐《03-接口文档》1.3 通用错误码)"""
# 成功
OK = 0
# 1xxx 通用
PARAM_ERROR = 1001 # 参数错误
UNAUTHORIZED = 1002 # 未登录或 Token 无效/过期
FORBIDDEN = 1003 # 无权限(功能需登录/已被封禁)
CAPTCHA_ERROR = 1004 # 验证码错误或已过期
RATE_LIMITED = 1005 # 请求过于频繁
NOT_FOUND = 1006 # 资源不存在
ACCOUNT_DEACTIVATED = 1009 # 账号已注销(冻结期内登录可撤销)
# 2xxx 业务
POINTS_NOT_ENOUGH = 2001 # 积分不足
FREE_LIMIT_EXCEEDED = 2002 # 今日免费观看次数已用完(游客需登录)
VIDEO_NOT_AVAILABLE = 2003 # 视频不存在或未发布
LIFETIME_MEMBER = 2004 # 已是终身会员,无需再开通
INVITE_CODE_INVALID = 2101 # 邀请码无效或不可绑定
ASSET_NOT_ENOUGH = 2102 # 资产余额不足
WITHDRAW_TOO_LOW = 2103 # 低于最低提现额度
WITHDRAW_PENDING = 2104 # 已有审核中的提现申请
SENSITIVE_WORD = 2105 # 内容包含敏感词
REPORT_DUPLICATED = 2106 # 请勿重复举报
NOT_AUTHOR = 2107 # 无权操作(非作者本人)
# 3xxx 支付
ORDER_NOT_FOUND = 3001 # 订单不存在
ORDER_STATUS_ERROR = 3002 # 订单状态异常
AMOUNT_MISMATCH = 3003 # 支付金额不匹配
# 4xxx 后台
ADMIN_FORBIDDEN = 4001 # 无管理员权限或角色不允许该操作
REASON_REQUIRED = 4002 # 该操作必须填写原因
# 5xxx 服务端
INTERNAL_ERROR = 5000 # 服务器内部错误
DEFAULT_MESSAGES = {
OK: "ok",
PARAM_ERROR: "参数错误",
UNAUTHORIZED: "未登录或登录已过期",
FORBIDDEN: "无权限操作",
CAPTCHA_ERROR: "验证码错误或已过期",
RATE_LIMITED: "请求过于频繁,请稍后再试",
NOT_FOUND: "资源不存在",
ACCOUNT_DEACTIVATED: "账号已注销",
POINTS_NOT_ENOUGH: "积分不足,请充值或开通会员",
FREE_LIMIT_EXCEEDED: "今日免费观看次数已用完",
VIDEO_NOT_AVAILABLE: "视频不存在或未发布",
LIFETIME_MEMBER: "已是终身会员,无需再开通",
INVITE_CODE_INVALID: "邀请码无效或不可绑定",
ASSET_NOT_ENOUGH: "资产余额不足",
WITHDRAW_TOO_LOW: "低于最低提现额度",
WITHDRAW_PENDING: "已有审核中的提现申请",
SENSITIVE_WORD: "内容包含敏感词",
REPORT_DUPLICATED: "请勿重复举报",
NOT_AUTHOR: "无权操作,非作者本人",
ORDER_NOT_FOUND: "订单不存在",
ORDER_STATUS_ERROR: "订单状态异常",
AMOUNT_MISMATCH: "支付金额不匹配",
ADMIN_FORBIDDEN: "无管理员权限或角色不允许该操作",
REASON_REQUIRED: "该操作必须填写原因",
INTERNAL_ERROR: "服务器内部错误",
}
+33
View File
@@ -0,0 +1,33 @@
"""接口限流:Redis 固定窗口计数(Nginx 层之外的第二道,对齐《02-技术文档》4.5)"""
from datetime import datetime
import redis.asyncio as aioredis
from app.core import errors
from app.core.response import BizException
async def hit(redis: aioredis.Redis, key: str, limit: int, window_seconds: int, message: str | None = None) -> None:
"""固定窗口计数,超限抛 1005(HTTP 429)。key 需自带维度与窗口标识。"""
n = await redis.incr(key)
if n == 1:
await redis.expire(key, window_seconds)
if n > limit:
raise BizException(errors.RATE_LIMITED, message, http_status=429)
def window_key(window_seconds: int) -> str:
"""窗口标识:分钟 %Y%m%d%H%M / 小时 %Y%m%d%H / 天 %Y%m%d"""
now = datetime.now()
if window_seconds >= 86400:
return f"{now:%Y%m%d}"
if window_seconds >= 3600:
return f"{now:%Y%m%d%H}"
return f"{now:%Y%m%d%H%M}"
async def hit_subject(
redis: aioredis.Redis, subject: str, action: str, limit: int, window_seconds: int, message: str | None = None
) -> None:
"""按主体(用户ID/设备ID/IP)+ 动作限流:rl:{action}:{subject}:{窗口}"""
await hit(redis, f"rl:{action}:{subject}:{window_key(window_seconds)}", limit, window_seconds, message)
+58
View File
@@ -0,0 +1,58 @@
"""统一响应与业务异常(对齐《03-接口文档》1.2 统一响应结构)"""
import uuid
from contextvars import ContextVar
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.core import errors
# 请求链路 ID(中间件写入,日志/响应共用)
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="")
def ok(data: Any = None, message: str = "ok") -> dict:
return {
"code": errors.OK,
"message": message,
"data": data if data is not None else {},
"request_id": request_id_ctx.get() or str(uuid.uuid4()),
}
class BizException(Exception):
"""业务异常:抛出后由全局处理器转为统一响应"""
def __init__(self, code: int, message: str | None = None, http_status: int = 200, data: dict | None = None):
self.code = code
self.message = message or errors.DEFAULT_MESSAGES.get(code, "error")
self.http_status = http_status
self.data = data or {}
def _fail_body(code: int, message: str, data: dict | None = None) -> dict:
return {
"code": code,
"message": message,
"data": data or {},
"request_id": request_id_ctx.get() or str(uuid.uuid4()),
}
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(BizException)
async def biz_handler(request: Request, exc: BizException) -> JSONResponse:
return JSONResponse(status_code=exc.http_status, content=_fail_body(exc.code, exc.message, exc.data))
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(
status_code=200,
content=_fail_body(errors.PARAM_ERROR, f"参数错误: {exc.errors()[0].get('msg', '')}"),
)
@app.exception_handler(Exception)
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=500, content=_fail_body(errors.INTERNAL_ERROR, "服务器内部错误"))
+61
View File
@@ -0,0 +1,61 @@
"""安全组件:bcrypt 密码哈希 + JWT 双 Token(对齐《02-技术文档》4.2)"""
import uuid
from datetime import datetime, timedelta, timezone
import bcrypt
import jwt
from app.config import settings
from app.core import errors
from app.core.response import BizException
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode(), hashed.encode())
except ValueError:
return False
def _create_token(subject: int, token_type: str, ttl: int, epoch: int = 0) -> tuple[str, str, int]:
"""生成 JWT,返回 (token, jti, 过期时间戳)"""
now = datetime.now(timezone.utc)
jti = uuid.uuid4().hex
exp = now + timedelta(seconds=ttl)
payload = {
"sub": str(subject),
"jti": jti,
"type": token_type, # access / refresh / admin:{role}
"ep": epoch, # 令牌纪元:改密/注销后递增使旧 Token 全端失效
"iat": int(now.timestamp()),
"exp": exp,
}
token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
return token, jti, int(exp.timestamp())
def create_access_token(user_id: int, epoch: int = 0) -> tuple[str, str, int]:
return _create_token(user_id, "access", settings.access_token_ttl, epoch)
def create_refresh_token(user_id: int, epoch: int = 0) -> tuple[str, str, int]:
return _create_token(user_id, "refresh", settings.refresh_token_ttl, epoch)
def create_admin_token(admin_id: int, role: str) -> tuple[str, str, int]:
"""管理员 Token,payload 带角色,有效期 12h"""
token, jti, exp = _create_token(admin_id, f"admin:{role}", settings.admin_token_ttl)
return token, jti, exp
def decode_token(token: str) -> dict:
"""解码并校验 Token,失败抛 1002"""
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except jwt.PyJWTError:
raise BizException(errors.UNAUTHORIZED, http_status=401)
return payload
+24
View File
@@ -0,0 +1,24 @@
"""MySQL 异步连接(SQLAlchemy 2.0 async)"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(
settings.database_url,
pool_size=20,
max_overflow=10,
pool_recycle=3600,
pool_pre_ping=True,
echo=False,
)
SessionFactory = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False, autoflush=False
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with SessionFactory() as session:
yield session
+103
View File
@@ -0,0 +1,103 @@
"""FastAPI 依赖注入:DB / Redis / 当前身份(用户或游客)/ 管理员"""
from dataclasses import dataclass
import redis.asyncio as aioredis
from fastapi import Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import errors
from app.core.response import BizException
from app.core.security import decode_token
from app.database import get_db
from app.redis_client import get_redis
TOKEN_TYPE_ACCESS = "access"
@dataclass
class Identity:
"""请求身份:登录用户(user_id 非空)或游客(device_id 非空)"""
user_id: int | None
device_id: str | None
@property
def is_user(self) -> bool:
return self.user_id is not None
@property
def is_guest(self) -> bool:
return self.user_id is None and self.device_id is not None
@property
def subject(self) -> str:
"""计费主体标识:注册用户=user_id,游客=device_id"""
return str(self.user_id) if self.user_id is not None else (self.device_id or "")
async def get_identity(
authorization: str | None = Header(default=None),
x_device_id: str | None = Header(default=None),
redis: aioredis.Redis = Depends(get_redis),
) -> Identity:
"""解析顺序:Bearer Token 有效 → 用户态;否则取 X-Device-ID → 游客态"""
if authorization and authorization.startswith("Bearer "):
token = authorization.removeprefix("Bearer ").strip()
try:
payload = decode_token(token)
except BizException:
payload = None
if payload and payload.get("type") == TOKEN_TYPE_ACCESS:
# 登出/改密的 jti 黑名单校验
jti = payload.get("jti", "")
if jti and await redis.exists(f"token:blacklist:{jti}"):
payload = None
else:
# 令牌纪元校验:改密/注销后旧 Token 全端失效
user_id = int(payload["sub"])
epoch = int(await redis.get(f"auth:epoch:{user_id}") or 0)
if int(payload.get("ep", 0)) != epoch:
payload = None
if payload:
return Identity(user_id=int(payload["sub"]), device_id=x_device_id)
return Identity(user_id=None, device_id=x_device_id)
async def require_user(identity: Identity = Depends(get_identity)) -> Identity:
"""仅登录用户可用"""
if identity.user_id is None:
raise BizException(errors.UNAUTHORIZED, http_status=401)
return identity
@dataclass
class AdminIdentity:
admin_id: int
role: str # super / reviewer / operator
async def get_admin(
authorization: str | None = Header(default=None),
redis: aioredis.Redis = Depends(get_redis),
) -> AdminIdentity:
if not authorization or not authorization.startswith("Bearer "):
raise BizException(errors.UNAUTHORIZED, http_status=401)
payload = decode_token(authorization.removeprefix("Bearer ").strip())
token_type = payload.get("type", "")
if not token_type.startswith("admin:"):
raise BizException(errors.ADMIN_FORBIDDEN, http_status=403)
jti = payload.get("jti", "")
if jti and await redis.exists(f"token:blacklist:{jti}"):
raise BizException(errors.UNAUTHORIZED, http_status=401)
return AdminIdentity(admin_id=int(payload["sub"]), role=token_type.split(":", 1)[1])
def require_roles(*roles: str):
"""后台角色鉴权:super 拥有全部权限"""
async def checker(admin: AdminIdentity = Depends(get_admin)) -> AdminIdentity:
if admin.role != "super" and admin.role not in roles:
raise BizException(errors.ADMIN_FORBIDDEN, http_status=403)
return admin
return checker
+90
View File
@@ -0,0 +1,90 @@
"""FastAPI 应用入口"""
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.core.response import register_exception_handlers, request_id_ctx
from app.database import engine
from app.redis_client import redis_client
@asynccontextmanager
async def lifespan(app: FastAPI):
# 周期任务(推荐激活/创作收益/超时关单),进程退出时取消
from app.services import settle_service
tasks = settle_service.start_periodic_tasks()
yield
for t in tasks:
t.cancel()
# 关闭连接池
await redis_client.aclose()
await engine.dispose()
app = FastAPI(
title="短视频应用 API",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs" if settings.app_env == "dev" else None,
redoc_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
"""request_id 贯穿:优先取客户端 X-Request-ID,否则生成"""
rid = request.headers.get("X-Request-ID") or str(uuid.uuid4())
request_id_ctx.set(rid)
response = await call_next(request)
response.headers["X-Request-ID"] = rid
return response
register_exception_handlers(app)
@app.get("/health", include_in_schema=False)
async def health() -> dict:
return {"status": "ok"}
# 本地文件存储(开发环境):/static 访问 server/uploads
from fastapi.staticfiles import StaticFiles
from app.config import BASE_DIR
(BASE_DIR / "uploads").mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=BASE_DIR / "uploads"), name="static")
# 业务路由(按模块逐步注册)
from app.api.v1 import admin, auth, common, incentives, interactions, invite, membership, payments, users, videos
app.include_router(auth.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
app.include_router(videos.router, prefix="/api/v1")
app.include_router(videos.router_tags, prefix="/api/v1")
app.include_router(videos.router_search, prefix="/api/v1")
app.include_router(interactions.router_videos, prefix="/api/v1")
app.include_router(interactions.router_comments, prefix="/api/v1")
app.include_router(membership.router, prefix="/api/v1")
app.include_router(payments.router, prefix="/api/v1")
app.include_router(payments.router_callbacks, prefix="/api/v1")
app.include_router(incentives.router_referral, prefix="/api/v1")
app.include_router(incentives.router_assets, prefix="/api/v1")
app.include_router(common.router, prefix="/api/v1")
app.include_router(admin.router, prefix="/api/v1")
app.include_router(invite.router, prefix="/api/v1")
# 推广落地页(根路径 /r/{code},对应 referral/me 返回的 invite_url)
app.include_router(invite.router_page)
+26
View File
@@ -0,0 +1,26 @@
"""模型汇总(Alembic autogenerate 依赖全量导入)"""
from app.models.base import Base
from app.models.admin import AdminLog, AdminUser
from app.models.billing import PointTransaction, RechargeOrder, RechargePackage, WatchRecord
from app.models.common import Notification, Report, SensitiveWord, SystemConfig
from app.models.incentive import (
AssetTransaction,
AssetWallet,
Referral,
VideoEarning,
Withdrawal,
)
from app.models.interaction import Comment, Favorite, Like
from app.models.user import MembershipOrder, User, UserMembership, UserWallet
from app.models.video import Category, Tag, TranscodeTask, Video, VideoTag
__all__ = [
"Base",
"User", "UserWallet", "UserMembership", "MembershipOrder",
"Video", "Category", "Tag", "VideoTag", "TranscodeTask",
"Comment", "Like", "Favorite",
"WatchRecord", "PointTransaction", "RechargeOrder", "RechargePackage",
"Referral", "AssetWallet", "AssetTransaction", "Withdrawal", "VideoEarning",
"Notification", "Report", "SensitiveWord", "SystemConfig",
"AdminUser", "AdminLog",
]
+35
View File
@@ -0,0 +1,35 @@
"""管理后台域:admin_users / admin_logs"""
from datetime import datetime
from sqlalchemy import DateTime, Index, SmallInteger, String, func
from sqlalchemy.dialects.mysql import BIGINT
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class AdminUser(Base):
__tablename__ = "admin_users"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
name: Mapped[str] = mapped_column(String(50), default="")
role: Mapped[str] = mapped_column(String(20), comment="super/reviewer/operator")
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1正常 0禁用")
last_login_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class AdminLog(Base):
__tablename__ = "admin_logs"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
admin_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
action: Mapped[str] = mapped_column(String(50))
target: Mapped[str] = mapped_column(String(100), default="")
detail: Mapped[str] = mapped_column(String(1000), default="")
ip: Mapped[str] = mapped_column(String(45), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (Index("idx_admin_created", "admin_id", "created_at"),)
+6
View File
@@ -0,0 +1,6 @@
"""SQLAlchemy 声明式基类"""
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+85
View File
@@ -0,0 +1,85 @@
"""计费域:watch_records / point_transactions / recharge_orders / recharge_packages"""
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, Index, Numeric, SmallInteger, String, func
from sqlalchemy.dialects.mysql import BIGINT, INTEGER
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class WatchRecord(Base):
__tablename__ = "watch_records"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
subject_type: Mapped[int] = mapped_column(SmallInteger, comment="1用户 2游客")
subject_id: Mapped[str] = mapped_column(String(64), comment="user_id或device_id")
video_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
watch_date: Mapped[date] = mapped_column(Date)
cost_type: Mapped[int] = mapped_column(SmallInteger, comment="0免费 1积分 2会员 3本人")
cost_points: Mapped[int] = mapped_column(default=0)
watch_duration: Mapped[int] = mapped_column(default=0, comment="秒")
position: Mapped[int] = mapped_column(default=0, comment="最后播放进度(秒),续播用")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (
Index("uk_dedup", "subject_type", "subject_id", "video_id", "watch_date", unique=True),
Index("idx_subject", "subject_type", "subject_id", "created_at"),
)
class PointTransaction(Base):
__tablename__ = "point_transactions"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
change_amount: Mapped[int] = mapped_column(comment="正增负减")
balance_after: Mapped[int] = mapped_column()
biz_type: Mapped[int] = mapped_column(
SmallInteger, comment="1充值 2观看 3评论奖励 4会员 5赠送 6调整 7扣回 8推荐奖励"
)
biz_id: Mapped[str] = mapped_column(String(64), default="")
remark: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (
Index("idx_user_created", "user_id", "created_at"),
Index("idx_biz", "biz_type", "biz_id"),
)
class RechargeOrder(Base):
__tablename__ = "recharge_orders"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
order_no: Mapped[str] = mapped_column(String(32), unique=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
amount_fen: Mapped[int] = mapped_column(comment="人民币金额(分)")
points: Mapped[int] = mapped_column(comment="应到账积分")
pay_method: Mapped[int] = mapped_column(SmallInteger, comment="1支付宝 2微信 3USDT")
status: Mapped[int] = mapped_column(SmallInteger, default=0, comment="0待支付 1已支付 2已关闭 3已退款")
third_trade_no: Mapped[str | None] = mapped_column(String(64), unique=True)
usdt_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 6))
usdt_rate: Mapped[Decimal | None] = mapped_column(Numeric(10, 4))
paid_at: Mapped[datetime | None] = mapped_column(DateTime)
expire_at: Mapped[datetime] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (Index("idx_user_status", "user_id", "status"),)
class RechargePackage(Base):
"""充值套餐档位(管理后台实时可配)"""
__tablename__ = "recharge_packages"
id: Mapped[int] = mapped_column(INTEGER(unsigned=True), primary_key=True, autoincrement=True)
amount_yuan: Mapped[int] = mapped_column()
points: Mapped[int] = mapped_column(comment="基础积分=金额x汇率")
bonus_points: Mapped[int] = mapped_column(default=0)
sort: Mapped[int] = mapped_column(default=0)
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1上架 0下架")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+64
View File
@@ -0,0 +1,64 @@
"""通用域:notifications / reports / sensitive_words / system_configs"""
from datetime import datetime
from sqlalchemy import DateTime, Index, SmallInteger, String, func
from sqlalchemy.dialects.mysql import BIGINT, INTEGER
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class Notification(Base):
__tablename__ = "notifications"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="接收人")
type: Mapped[int] = mapped_column(SmallInteger, comment="1审核结果 2提现进度 3系统公告 4评论回复")
title: Mapped[str] = mapped_column(String(100), default="")
content: Mapped[str] = mapped_column(String(500), default="")
biz_id: Mapped[str] = mapped_column(String(64), default="")
is_read: Mapped[int] = mapped_column(SmallInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (Index("idx_user_read", "user_id", "is_read", "id"),)
class Report(Base):
__tablename__ = "reports"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="举报人")
target_type: Mapped[int] = mapped_column(SmallInteger, comment="1视频 2评论")
target_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
reason_type: Mapped[int] = mapped_column(SmallInteger, comment="1色情低俗 2暴力血腥 3侵权 4广告营销 5其他")
remark: Mapped[str] = mapped_column(String(200), default="")
status: Mapped[int] = mapped_column(SmallInteger, default=0, comment="0待处理 1已处理 2已驳回")
handle_result: Mapped[str] = mapped_column(String(50), default="")
admin_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
handled_at: Mapped[datetime | None] = mapped_column(DateTime)
__table_args__ = (
Index("uk_user_target", "user_id", "target_type", "target_id", unique=True),
Index("idx_status", "status", "created_at"),
)
class SensitiveWord(Base):
__tablename__ = "sensitive_words"
id: Mapped[int] = mapped_column(INTEGER(unsigned=True), primary_key=True, autoincrement=True)
word: Mapped[str] = mapped_column(String(50), unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class SystemConfig(Base):
"""平台配置键值对(后台可改,Redis 缓存 60s)"""
__tablename__ = "system_configs"
config_key: Mapped[str] = mapped_column(String(50), primary_key=True)
config_value: Mapped[str] = mapped_column(String(500), default="")
description: Mapped[str] = mapped_column(String(200), default="")
updated_by: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+98
View File
@@ -0,0 +1,98 @@
"""激励域:referrals / asset_wallets / asset_transactions / withdrawals / video_earnings"""
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, Index, Numeric, SmallInteger, String, func
from sqlalchemy.dialects.mysql import BIGINT
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class Referral(Base):
__tablename__ = "referrals"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
inviter_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="推荐人")
invitee_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="被推荐人")
invite_code: Mapped[str] = mapped_column(String(10))
status: Mapped[int] = mapped_column(SmallInteger, default=0, comment="0待激活 1有效 2无效 3风控冻结")
risk_flag: Mapped[str] = mapped_column(String(50), default="")
register_ip: Mapped[str] = mapped_column(String(45), default="")
activated_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (
Index("uk_invitee", "invitee_id", unique=True),
Index("idx_inviter", "inviter_id", "created_at"),
Index("idx_status", "status", "created_at"),
)
class AssetWallet(Base):
__tablename__ = "asset_wallets"
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True)
assets: Mapped[Decimal] = mapped_column(Numeric(12, 1), default=0, comment="当前资产(允许为负)")
total_earned: Mapped[Decimal] = mapped_column(Numeric(14, 1), default=0)
total_withdrawn: Mapped[Decimal] = mapped_column(Numeric(14, 1), default=0)
version: Mapped[int] = mapped_column(default=0)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
class AssetTransaction(Base):
__tablename__ = "asset_transactions"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
change_amount: Mapped[Decimal] = mapped_column(Numeric(12, 1))
balance_after: Mapped[Decimal] = mapped_column(Numeric(12, 1))
biz_type: Mapped[int] = mapped_column(
SmallInteger, comment="1充值返利 2完整观看 3点赞 4提现 5提现退回 6返利扣回 7后台调整"
)
biz_id: Mapped[str] = mapped_column(String(64), default="")
remark: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (
Index("idx_user_created", "user_id", "created_at"),
Index("idx_biz", "biz_type", "biz_id"),
)
class Withdrawal(Base):
__tablename__ = "withdrawals"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
withdraw_no: Mapped[str] = mapped_column(String(32), unique=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
assets: Mapped[Decimal] = mapped_column(Numeric(12, 1))
amount_fen: Mapped[int] = mapped_column(comment="折合人民币(分)")
channel: Mapped[int] = mapped_column(SmallInteger, comment="1支付宝 2微信 3USDT")
account: Mapped[str] = mapped_column(String(200))
status: Mapped[int] = mapped_column(SmallInteger, default=0, comment="0待审核 1已打款 2已拒绝")
reason: Mapped[str] = mapped_column(String(200), default="")
admin_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
processed_at: Mapped[datetime | None] = mapped_column(DateTime)
__table_args__ = (
Index("idx_user", "user_id", "created_at"),
Index("idx_status", "status", "created_at"),
)
class VideoEarning(Base):
"""视频收益结算刻度(完整观看/点赞按增量结算,余数保留)"""
__tablename__ = "video_earnings"
video_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True)
author_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
complete_views: Mapped[int] = mapped_column(default=0, comment="累计完整观看次数")
views_settled: Mapped[int] = mapped_column(default=0, comment="已结算的完整观看刻度")
likes_accrued: Mapped[int] = mapped_column(default=0, comment="累计点赞(取消会回退)")
likes_settled: Mapped[int] = mapped_column(default=0, comment="已结算的点赞刻度")
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (Index("idx_author", "author_id"),)
@@ -0,0 +1,56 @@
"""互动域:comments / likes / favorites"""
from datetime import datetime
from sqlalchemy import DateTime, Index, SmallInteger, String, func
from sqlalchemy.dialects.mysql import BIGINT
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class Comment(Base):
__tablename__ = "comments"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
video_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
parent_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), default=0, comment="0=一级评论")
reply_to_user_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
content: Mapped[str] = mapped_column(String(500))
like_count: Mapped[int] = mapped_column(default=0)
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1正常 2已删除 3审核中")
reward_counted: Mapped[int] = mapped_column(SmallInteger, default=0, comment="是否已计入评论奖励进度")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (
Index("idx_video", "video_id", "status", "created_at"),
Index("idx_user_video", "user_id", "video_id"),
)
class Like(Base):
__tablename__ = "likes"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
video_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (
Index("uk_user_video", "user_id", "video_id", unique=True),
Index("idx_video", "video_id"),
)
class Favorite(Base):
__tablename__ = "favorites"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
video_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (
Index("uk_user_video", "user_id", "video_id", unique=True),
Index("idx_video", "video_id"),
)
+78
View File
@@ -0,0 +1,78 @@
"""用户域:users / user_wallets / user_membership / membership_orders"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, SmallInteger, String, func
from sqlalchemy.dialects.mysql import BIGINT, INTEGER
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
phone: Mapped[str | None] = mapped_column(String(20), unique=True, comment="手机号")
email: Mapped[str | None] = mapped_column(String(100), unique=True, comment="邮箱")
password_hash: Mapped[str] = mapped_column(String(255))
nickname: Mapped[str] = mapped_column(String(50), default="")
avatar_url: Mapped[str] = mapped_column(String(500), default="")
gender: Mapped[int] = mapped_column(SmallInteger, default=0, comment="0未知 1男 2女")
bio: Mapped[str] = mapped_column(String(200), default="")
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1正常 2封禁 3已注销")
register_type: Mapped[int] = mapped_column(SmallInteger, comment="1手机 2邮箱")
invite_code: Mapped[str] = mapped_column(String(10), unique=True, default="", comment="我的邀请码")
register_ip: Mapped[str] = mapped_column(String(45), default="")
last_login_at: Mapped[datetime | None] = mapped_column(DateTime)
last_login_ip: Mapped[str] = mapped_column(String(45), default="")
deactivate_at: Mapped[datetime | None] = mapped_column(DateTime, comment="注销发起时间(7天冻结期)")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (Index("idx_status_created", "status", "created_at"),)
class UserWallet(Base):
__tablename__ = "user_wallets"
user_id: Mapped[int] = mapped_column(
BIGINT(unsigned=True), ForeignKey("users.id"), primary_key=True
)
points: Mapped[int] = mapped_column(INTEGER(unsigned=True), default=0)
total_recharge: Mapped[int] = mapped_column(INTEGER(unsigned=True), default=0)
total_earned: Mapped[int] = mapped_column(INTEGER(unsigned=True), default=0)
total_consumed: Mapped[int] = mapped_column(INTEGER(unsigned=True), default=0)
version: Mapped[int] = mapped_column(default=0, comment="乐观锁")
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
class UserMembership(Base):
"""用户当前会员状态(冗余加速查询,以 expire_at 判定为准)"""
__tablename__ = "user_membership"
user_id: Mapped[int] = mapped_column(
BIGINT(unsigned=True), ForeignKey("users.id"), primary_key=True
)
is_member: Mapped[int] = mapped_column(SmallInteger, default=0)
current_plan: Mapped[str] = mapped_column(String(20), default="", comment="trial/month/year/lifetime")
is_lifetime: Mapped[int] = mapped_column(SmallInteger, default=0)
expire_at: Mapped[datetime | None] = mapped_column(DateTime, comment="终身卡为 NULL")
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
class MembershipOrder(Base):
__tablename__ = "membership_orders"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
order_no: Mapped[str] = mapped_column(String(32), unique=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), ForeignKey("users.id"))
plan_code: Mapped[str] = mapped_column(String(20), comment="trial/month/year/lifetime")
points_cost: Mapped[int] = mapped_column(default=0, comment="赠送为 0")
duration_days: Mapped[int] = mapped_column(default=0, comment="0=终身")
start_at: Mapped[datetime] = mapped_column(DateTime)
end_at: Mapped[datetime | None] = mapped_column(DateTime, comment="终身为 NULL")
source: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1积分购买 2活动赠送")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (Index("idx_user", "user_id", "created_at"),)
+91
View File
@@ -0,0 +1,91 @@
"""视频域:videos / categories / tags / video_tags / transcode_tasks"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Index, SmallInteger, String, func
from sqlalchemy.dialects.mysql import BIGINT, INTEGER
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class Video(Base):
__tablename__ = "videos"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BIGINT(unsigned=True), comment="作者")
category_id: Mapped[int] = mapped_column(default=0)
title: Mapped[str] = mapped_column(String(100), default="")
description: Mapped[str] = mapped_column(String(500), default="")
cover_url: Mapped[str] = mapped_column(String(500), default="")
video_url: Mapped[str] = mapped_column(String(500), default="", comment="m3u8地址")
duration: Mapped[int] = mapped_column(default=0, comment="秒")
file_size: Mapped[int] = mapped_column(BigInteger, default=0)
width: Mapped[int] = mapped_column(default=0)
height: Mapped[int] = mapped_column(default=0)
status: Mapped[int] = mapped_column(
SmallInteger, default=0, comment="0转码中 1待审核 2已发布 3拒绝 4下架 5转码失败/已删除"
)
reject_reason: Mapped[str] = mapped_column(String(200), default="")
reviewer_id: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime)
is_priority: Mapped[int] = mapped_column(SmallInteger, default=0)
view_count: Mapped[int] = mapped_column(BigInteger, default=0)
like_count: Mapped[int] = mapped_column(default=0)
comment_count: Mapped[int] = mapped_column(default=0)
favorite_count: Mapped[int] = mapped_column(default=0)
published_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
Index("idx_user_status", "user_id", "status"),
Index("idx_feed", "status", "published_at"),
Index("idx_category", "category_id", "status"),
)
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(INTEGER(unsigned=True), primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50))
sort: Mapped[int] = mapped_column(default=0)
status: Mapped[int] = mapped_column(SmallInteger, default=1, comment="1启用 0停用")
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(INTEGER(unsigned=True), primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(20), unique=True)
video_count: Mapped[int] = mapped_column(default=0, comment="冗余")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class VideoTag(Base):
__tablename__ = "video_tags"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
video_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
tag_id: Mapped[int] = mapped_column(INTEGER(unsigned=True))
__table_args__ = (
Index("uk_video_tag", "video_id", "tag_id", unique=True),
Index("idx_tag", "tag_id", "video_id"),
)
class TranscodeTask(Base):
__tablename__ = "transcode_tasks"
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
video_id: Mapped[int] = mapped_column(BIGINT(unsigned=True))
source_url: Mapped[str] = mapped_column(String(500))
task_status: Mapped[int] = mapped_column(SmallInteger, default=0, comment="0排队 1转码中 2成功 3失败")
priority: Mapped[int] = mapped_column(SmallInteger, default=0, comment="0普通 1优先(会员)")
retry_count: Mapped[int] = mapped_column(default=0)
error_msg: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (Index("idx_pick", "task_status", "priority", "created_at"),)
+1
View File
@@ -0,0 +1 @@
"""支付适配层(《02-技术文档》6.5):三渠道统一产出 PaymentResult 交 settle 入账"""
+67
View File
@@ -0,0 +1,67 @@
"""支付宝渠道:App 支付下单参数(本地 RSA2 签名)+ 回调 RSA2 验签。
密钥未配置(开发环境)时:下单返回 dev_mock 占位参数便于 App 联调,回调一律验签失败拒绝入账。
生产接入:配置 ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY(应用私钥)/ ALIPAY_PUBLIC_KEY(支付宝公钥)
及 ALIPAY_NOTIFY_URL 后自动启用真实签名与验签。
"""
import json
from datetime import datetime
from urllib.parse import urlencode
from app.config import settings
from app.payment.base import build_sign_content, load_public_key, rsa2_sign, rsa2_verify
METHOD_APP_PAY = "alipay.trade.app.pay"
PRODUCT_CODE = "QUICK_MSECURITY_PAY"
DEFAULT_SUBJECT = "积分充值"
ORDER_EXPIRE = "30m" # 与订单有效期保持一致
def is_configured() -> bool:
return bool(settings.alipay_app_id and settings.alipay_private_key and settings.alipay_public_key)
def create_app_pay_params(order_no: str, amount_fen: int) -> dict:
"""生成 App SDK 调起参数 order_string(未配置密钥时返回 dev 占位)"""
if not is_configured():
return {"dev_mock": True, "order_string": f"mock://alipay/{order_no}"}
biz_content = json.dumps({
"out_trade_no": order_no,
"total_amount": f"{amount_fen / 100:.2f}",
"subject": DEFAULT_SUBJECT,
"product_code": PRODUCT_CODE,
"timeout_express": ORDER_EXPIRE,
}, ensure_ascii=False, separators=(",", ":"))
params = {
"app_id": settings.alipay_app_id,
"method": METHOD_APP_PAY,
"format": "JSON",
"charset": "utf-8",
"sign_type": "RSA2",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"version": "1.0",
"biz_content": biz_content,
}
if settings.alipay_notify_url:
params["notify_url"] = settings.alipay_notify_url
if settings.alipay_return_url:
params["return_url"] = settings.alipay_return_url
# 签名串不含 sign_type/sign,按 key 字典序拼接
params["sign"] = rsa2_sign(build_sign_content(params), settings.alipay_private_key)
# order_string 为全参数(含 sign)按 key 字典序 URL 编码后的查询串
return {"order_string": urlencode(sorted(params.items()))}
def verify_callback(form: dict) -> bool:
"""RSA2 验签:用支付宝公钥校验回调 sign(未配置/格式错误一律拒绝)"""
sign = form.get("sign", "")
if not sign or not settings.alipay_public_key:
return False
try:
load_public_key(settings.alipay_public_key)
except Exception: # noqa: BLE001
return False
return rsa2_verify(build_sign_content(form), sign, settings.alipay_public_key)
+113
View File
@@ -0,0 +1,113 @@
"""支付适配层公共能力:
- 密钥加载与 PEM 规整(兼容支付宝常见的「无头无尾裸 base64」私钥/公钥)
- RSA2(SHA256withRSA) 签名与验签(支付宝 RSA2 / 微信 V3 共用同一原语)
- 微信 V3 回调 AES-256-GCM 解密
三渠道统一把解析结果交给 payment_service.settle_order 幂等入账。
"""
import base64
import json
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class PaymentConfigError(Exception):
"""支付渠道密钥/参数不可用(配置缺失或格式错误)"""
# ---------- 密钥加载 ----------
def _normalize_pem(key: str, label: str) -> bytes:
"""把裸 base64 或带换行内容规整为标准 PEM;已带头尾则原样返回"""
key = (key or "").strip()
if not key:
raise PaymentConfigError(f"{label} 未配置")
if key.startswith("-----BEGIN"):
return key.encode()
body = "".join(key.split())
chunks = "\n".join(body[i:i + 64] for i in range(0, len(body), 64))
return f"-----BEGIN {label}-----\n{chunks}\n-----END {label}-----\n".encode()
def load_private_key(key: str) -> rsa.RSAPrivateKey:
pem = _normalize_pem(key, "RSA PRIVATE KEY")
try:
loaded = serialization.load_pem_private_key(pem, password=None)
except Exception as exc: # noqa: BLE001
# 支付宝密钥常见为 PKCS8,裸 base64 时按 PKCS8 再试一次
try:
pem8 = _normalize_pem(key, "PRIVATE KEY")
loaded = serialization.load_pem_private_key(pem8, password=None)
except Exception as exc2: # noqa: BLE001
raise PaymentConfigError(f"私钥格式错误: {exc2}") from exc
if not isinstance(loaded, rsa.RSAPrivateKey):
raise PaymentConfigError("私钥不是 RSA 类型")
return loaded
def load_public_key(key: str) -> rsa.RSAPublicKey:
pem = _normalize_pem(key, "PUBLIC KEY")
try:
loaded = serialization.load_pem_public_key(pem)
except Exception as exc: # noqa: BLE001
try:
pem_cert = _normalize_pem(key, "CERTIFICATE")
loaded = serialization.load_pem_public_key(pem_cert)
except Exception as exc2: # noqa: BLE001
raise PaymentConfigError(f"公钥格式错误: {exc2}") from exc
if not isinstance(loaded, rsa.RSAPublicKey):
raise PaymentConfigError("公钥不是 RSA 类型")
return loaded
# ---------- RSA2 签名 / 验签 ----------
def rsa2_sign(content: str, private_key: str) -> str:
"""SHA256withRSA 签名,返回 base64"""
key = load_private_key(private_key)
signature = key.sign(content.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
return base64.b64encode(signature).decode()
def rsa2_verify(content: str, signature_b64: str, public_key: str) -> bool:
"""SHA256withRSA 验签;内容/签名异常一律返回 False(不抛错,交由调用方拒绝入账)"""
try:
key = load_public_key(public_key)
key.verify(
base64.b64decode(signature_b64),
content.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception: # noqa: BLE001
return False
def build_sign_content(params: dict) -> str:
"""待验签/待签名串:剔除 sign/sign_type 与空值,按 key 字典序,k=v 以 & 连接"""
items = sorted(
(k, v) for k, v in params.items()
if k not in ("sign", "sign_type") and v not in (None, "")
)
return "&".join(f"{k}={v}" for k, v in items)
# ---------- 微信 V3 回调解密 ----------
def wechat_decrypt_resource(resource: dict, api_v3_key: str) -> dict | None:
"""AES-256-GCM 解密回调 resource,返回业务明文 dict;失败返回 None"""
try:
ciphertext = base64.b64decode(resource.get("ciphertext", ""))
nonce = resource.get("nonce", "").encode("utf-8")
associated = (resource.get("associated_data") or "").encode("utf-8")
if not ciphertext or not nonce:
return None
aesgcm = AESGCM(api_v3_key.encode("utf-8"))
plain = aesgcm.decrypt(nonce, ciphertext, associated)
return json.loads(plain.decode("utf-8"))
except Exception: # noqa: BLE001
return None
+16
View File
@@ -0,0 +1,16 @@
"""USDT (TRC20) 渠道:唯一金额生成。
USDT 无支付平台回调,到账依赖服务端轮询 TronGrid(每 15s):
匹配条件 = 收款地址一致 + 金额(含尾差)精确相等 + 转账时间在订单有效期内 + 确认数 >= 19;
匹配成功即入账(tx_hash 写入 third_trade_no,唯一索引保证幂等)。
当前以 Celery worker 部署后实现轮询任务;开发期可用后台"人工补单"入账。
"""
import random
from decimal import ROUND_HALF_UP, Decimal
def gen_usdt_amount(amount_yuan: int, rate: Decimal) -> Decimal:
"""base(元/汇率) + 随机尾差(0.001~0.999),保留 3 位小数;有效期内金额唯一用于订单匹配"""
base = (Decimal(amount_yuan) / rate).quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)
tail = Decimal(random.randint(1, 999)) / Decimal(1000)
return base + tail
+111
View File
@@ -0,0 +1,111 @@
"""微信支付渠道(V3):App 下单(服务端签名) + 回调验签/AES-GCM 解密。
密钥未配置(开发环境)时:下单返回 dev_mock 占位,回调一律拒绝入账。
生产接入需配置:WECHAT_APP_ID / WECHAT_MCHID / WECHAT_API_V3_KEY / WECHAT_PRIVATE_KEY(商户 API 私钥)
/ WECHAT_CERT_SERIAL_NO(商户证书序列号)/ WECHAT_PLATFORM_CERT(微信支付平台证书 PEM)/ WECHAT_NOTIFY_URL。
"""
import json
import time
import uuid
from app.config import settings
from app.payment.base import PaymentConfigError, rsa2_sign, rsa2_verify, wechat_decrypt_resource
APP_PATH = "/v3/pay/transactions/app"
APP_PACKAGE = "Sign=WXPay"
DEFAULT_SUBJECT = "积分充值"
HTTP_TIMEOUT = 10
def is_configured() -> bool:
return bool(
settings.wechat_mchid and settings.wechat_app_id
and settings.wechat_api_v3_key and settings.wechat_private_key
)
def _authorization(method: str, url_path: str, body: str) -> str:
"""构造 V3 请求签名的 Authorization 头(WECHATPAY2-SHA256-RSA2048)"""
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
message = f"{method}\n{url_path}\n{timestamp}\n{nonce}\n{body}\n"
signature = rsa2_sign(message, settings.wechat_private_key)
return (
f'WECHATPAY2-SHA256-RSA2048 mchid="{settings.wechat_mchid}",'
f'nonce_str="{nonce}",signature="{signature}",'
f'timestamp="{timestamp}",serial_no="{settings.wechat_cert_serial_no}"'
)
async def _create_prepay_id(order_no: str, amount_fen: int) -> str:
"""V3 下单(App 支付)→ prepay_id"""
import httpx # 延迟导入:未配置渠道时无需依赖
body = json.dumps({
"appid": settings.wechat_app_id,
"mchid": settings.wechat_mchid,
"description": DEFAULT_SUBJECT,
"out_trade_no": order_no,
"notify_url": settings.wechat_notify_url,
"amount": {"total": amount_fen, "currency": "CNY"},
}, ensure_ascii=False, separators=(",", ":"))
headers = {
"Authorization": _authorization("POST", APP_PATH, body),
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "video-server/1.0",
}
async with httpx.AsyncClient(base_url=settings.wechat_api_base, timeout=HTTP_TIMEOUT) as client:
resp = await client.post(APP_PATH, content=body.encode("utf-8"), headers=headers)
if resp.status_code != 200:
raise PaymentConfigError(f"微信下单失败: {resp.status_code} {resp.text[:200]}")
prepay_id = (resp.json() or {}).get("prepay_id") or ""
if not prepay_id:
raise PaymentConfigError("微信下单未返回 prepay_id")
return prepay_id
async def create_app_pay_params(order_no: str, amount_fen: int) -> dict:
"""生成 App SDK 调起参数(未配置密钥时返回 dev 占位)"""
if not is_configured():
return {"dev_mock": True, "prepayid": f"mock-{order_no}"}
prepay_id = await _create_prepay_id(order_no, amount_fen)
# App 调起需二次签名(不同于接口签名串)
timestamp = str(int(time.time()))
noncestr = uuid.uuid4().hex
sign_str = (
f"{settings.wechat_app_id}\n{settings.wechat_mchid}\n{prepay_id}\n"
f"{APP_PACKAGE}\n{noncestr}\n{timestamp}\n"
)
return {
"appid": settings.wechat_app_id,
"partnerid": settings.wechat_mchid,
"prepayid": prepay_id,
"package": APP_PACKAGE,
"noncestr": noncestr,
"timestamp": timestamp,
"sign": rsa2_sign(sign_str, settings.wechat_private_key),
}
def verify_callback(headers: dict, body: bytes) -> dict | None:
"""V3 回调验签 + AES-GCM 解密,成功返回明文报文 dict,失败返回 None(未配置一律 None)"""
if not is_configured() or not settings.wechat_platform_cert:
return None
timestamp = headers.get("wechatpay-timestamp", "")
nonce = headers.get("wechatpay-nonce", "")
signature = headers.get("wechatpay-signature", "")
if not (timestamp and nonce and signature):
return None
# 验签串:timestamp\nnonce\nbody\n(body 为原始报文,勿用解析后结果)
message = f"{timestamp}\n{nonce}\n{body.decode('utf-8')}\n"
if not rsa2_verify(message, signature, settings.wechat_platform_cert):
return None
try:
payload = json.loads(body.decode("utf-8"))
except Exception: # noqa: BLE001
return None
resource = payload.get("resource") or {}
return wechat_decrypt_resource(resource, settings.wechat_api_v3_key)
+10
View File
@@ -0,0 +1,10 @@
"""Redis 异步客户端(缓存/计数/队列 Broker)"""
import redis.asyncio as aioredis
from app.config import settings
redis_client: aioredis.Redis = aioredis.from_url(settings.redis_url, decode_responses=True)
async def get_redis() -> aioredis.Redis:
return redis_client
+111
View File
@@ -0,0 +1,111 @@
"""管理后台请求模型(对应《03-接口文档》第 11 章)"""
from pydantic import BaseModel, Field
class AdminLoginReq(BaseModel):
"""管理员登录(11.1)"""
username: str = Field(min_length=1, max_length=50)
password: str = Field(min_length=1, max_length=100)
class VideoReviewReq(BaseModel):
"""审核视频(11.4)"""
action: str = Field(pattern="^(approve|reject)$")
reason: str | None = Field(None, max_length=200)
class ReasonReq(BaseModel):
"""通用敏感操作原因(下架视频 / 删除评论等)"""
reason: str = Field(min_length=1, max_length=200)
class UserStatusReq(BaseModel):
"""封禁/解封用户(11.8)"""
status: int = Field(ge=1, le=2) # 1正常 2封禁
reason: str = Field(min_length=1, max_length=200)
class PointsAdjustReq(BaseModel):
"""积分调整(11.9)"""
user_id: int = Field(ge=1)
change_amount: int = Field(ge=-1000000, le=1000000) # 非 0 由服务端校验
reason: str = Field(min_length=1, max_length=200)
class ResettleReq(BaseModel):
"""人工补单(11.12)"""
reason: str = Field(min_length=1, max_length=200)
third_trade_no: str | None = Field(None, max_length=64)
class RefundReq(BaseModel):
"""退款登记(11.12-2【敏感】):reason 必填"""
reason: str = Field(min_length=1, max_length=200)
class PackageCreateReq(BaseModel):
"""新增充值套餐(11.14)"""
amount_yuan: int = Field(ge=1, le=100000)
bonus_points: int = Field(0, ge=0)
sort: int = 0
points: int | None = Field(None, ge=0) # 不传则按汇率计算
class PackageUpdateReq(BaseModel):
"""修改充值套餐(11.15)"""
amount_yuan: int | None = Field(None, ge=1, le=100000)
bonus_points: int | None = Field(None, ge=0)
sort: int | None = None
status: int | None = Field(None, ge=0, le=1)
class GiftReq(BaseModel):
"""赠送会员/积分(11.17)"""
user_ids: list[int] = Field(min_length=1, max_length=100)
gift_type: str = Field(pattern="^(membership|points)$")
plan_code: str | None = Field(None, max_length=20)
points: int | None = Field(None, gt=0, le=1000000)
reason: str = Field(min_length=1, max_length=200)
class WithdrawReviewReq(BaseModel):
"""提现审核(11.20)"""
action: str = Field(pattern="^(approve|reject)$")
reason: str | None = Field(None, max_length=200)
class ReferralReviewReq(BaseModel):
"""推荐风控处理(11.22)"""
action: str = Field(pattern="^(release|invalid)$")
reason: str = Field(min_length=1, max_length=200)
class ConfigUpdateReq(BaseModel):
"""修改平台配置(11.24)"""
value: str = Field(max_length=500)
class ReportReviewReq(BaseModel):
"""举报处理(11.29)"""
action: str = Field(pattern="^(offline_video|delete_comment|reject)$")
reason: str = Field(min_length=1, max_length=200)
class SensitiveWordAddReq(BaseModel):
"""添加敏感词(11.31)"""
word: str = Field(min_length=1, max_length=50)
+107
View File
@@ -0,0 +1,107 @@
"""认证模块请求/响应模型"""
import re
from pydantic import BaseModel, Field, field_validator, model_validator
PASSWORD_RE = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,32}$")
PHONE_RE = re.compile(r"^1\d{10}$")
EMAIL_RE = re.compile(r"^[\w.+-]+@[\w-]+\.[\w.]+$")
def _check_password(v: str) -> str:
if not PASSWORD_RE.match(v):
raise ValueError("密码须 8-32 位且包含字母和数字")
return v
class SmsSendReq(BaseModel):
phone: str
scene: str = Field(pattern="^(register|login|reset)$")
@field_validator("phone")
@classmethod
def _v_phone(cls, v: str) -> str:
if not PHONE_RE.match(v):
raise ValueError("手机号格式不正确")
return v
class EmailSendReq(BaseModel):
email: str
scene: str = Field(pattern="^(register|login|reset)$")
@field_validator("email")
@classmethod
def _v_email(cls, v: str) -> str:
if not EMAIL_RE.match(v):
raise ValueError("邮箱格式不正确")
return v
class RegisterReq(BaseModel):
type: str = Field(pattern="^(phone|email)$")
phone: str | None = None
email: str | None = None
code: str = Field(min_length=4, max_length=8)
password: str
nickname: str | None = Field(default=None, max_length=20)
invite_code: str | None = Field(default=None, max_length=10)
@field_validator("password")
@classmethod
def _v_pwd(cls, v: str) -> str:
return _check_password(v)
@model_validator(mode="after")
def _v_target(self):
if self.type == "phone" and not (self.phone and PHONE_RE.match(self.phone)):
raise ValueError("手机号格式不正确")
if self.type == "email" and not (self.email and EMAIL_RE.match(self.email)):
raise ValueError("邮箱格式不正确")
return self
class LoginReq(BaseModel):
type: str = Field(pattern="^(phone_password|phone_code|email_password)$")
phone: str | None = None
email: str | None = None
password: str | None = None
code: str | None = None
revoke: bool = False # 注销冻结期内:确认撤销注销
@model_validator(mode="after")
def _v_fields(self):
if self.type.startswith("phone") and not self.phone:
raise ValueError("手机号必填")
if self.type.startswith("email") and not self.email:
raise ValueError("邮箱必填")
if self.type.endswith("password") and not self.password:
raise ValueError("密码必填")
if self.type.endswith("code") and not self.code:
raise ValueError("验证码必填")
return self
class RefreshReq(BaseModel):
refresh_token: str = Field(min_length=10)
class PasswordResetReq(BaseModel):
type: str = Field(pattern="^(phone|email)$")
phone: str | None = None
email: str | None = None
code: str = Field(min_length=4, max_length=8)
new_password: str
@field_validator("new_password")
@classmethod
def _v_pwd(cls, v: str) -> str:
return _check_password(v)
@model_validator(mode="after")
def _v_target(self):
if self.type == "phone" and not (self.phone and PHONE_RE.match(self.phone)):
raise ValueError("手机号格式不正确")
if self.type == "email" and not (self.email and EMAIL_RE.match(self.email)):
raise ValueError("邮箱格式不正确")
return self
+24
View File
@@ -0,0 +1,24 @@
"""通用模块请求模型"""
from pydantic import BaseModel, Field
class NotificationReadReq(BaseModel):
"""标记已读(10.4):ids 与 all 二选一"""
ids: list[int] | None = None
all: bool | None = None
class ReportReq(BaseModel):
"""提交举报(10.5)"""
target_type: int = Field(ge=1, le=2) # 1视频 2评论
target_id: int = Field(ge=1)
reason_type: int = Field(ge=1, le=5) # 1色情低俗 2暴力血腥 3侵权 4广告营销 5其他
remark: str | None = Field(None, max_length=200)
class RedeemReq(BaseModel):
"""积分开通会员(7.3)"""
plan_code: str = Field(min_length=1, max_length=20)
+18
View File
@@ -0,0 +1,18 @@
"""激励模块请求模型"""
from decimal import Decimal
from pydantic import BaseModel, Field
class BindInviteReq(BaseModel):
"""绑定邀请码(9.2)"""
invite_code: str = Field(min_length=1, max_length=10)
class WithdrawReq(BaseModel):
"""申请提现(9.5)"""
assets: Decimal = Field(gt=0, le=Decimal("1000000"), max_digits=12, decimal_places=1)
channel: int = Field(ge=1, le=3) # 1支付宝 2微信 3USDT
account: str = Field(min_length=1, max_length=200)
@@ -0,0 +1,10 @@
"""互动模块请求模型"""
from pydantic import BaseModel, Field
class CommentCreateReq(BaseModel):
"""发表评论(6.4):parent_id=0 为一级评论"""
content: str = Field(min_length=1, max_length=500)
parent_id: int = 0
reply_to_user_id: int | None = None
+14
View File
@@ -0,0 +1,14 @@
"""推广归因模块请求模型"""
from pydantic import BaseModel, Field
class InviteClickReq(BaseModel):
"""H5 中间页点击埋点"""
code: str = Field(min_length=4, max_length=10)
class InviteMatchReq(BaseModel):
"""App 首启归因上报(按设备指纹匹配点击记录)"""
device_id: str = Field(min_length=8, max_length=64)
+9
View File
@@ -0,0 +1,9 @@
"""支付模块请求模型"""
from pydantic import BaseModel, Field
class RechargeReq(BaseModel):
"""创建充值订单(8.2)"""
amount_yuan: int = Field(ge=1, le=100000)
pay_method: int = Field(ge=1, le=3) # 1支付宝 2微信 3USDT
+12
View File
@@ -0,0 +1,12 @@
"""用户模块请求模型(对应《03-接口文档》第 4 章)"""
from pydantic import BaseModel, Field
class UpdateMeReq(BaseModel):
nickname: str | None = Field(default=None, min_length=2, max_length=20)
bio: str | None = Field(default=None, max_length=200)
gender: int | None = Field(default=None, ge=0, le=2)
class DeactivateReq(BaseModel):
password: str = Field(min_length=1, max_length=32)
+37
View File
@@ -0,0 +1,37 @@
"""视频模块请求模型(对应《03-接口文档》第 5 章)"""
from pydantic import BaseModel, Field
class WatchReq(BaseModel):
position: int = Field(default=0, ge=0, description="当前播放进度(秒)")
class WatchCompleteReq(BaseModel):
watch_duration: int = Field(ge=0, description="本次实际观看时长(秒)")
class UploadTokenReq(BaseModel):
"""5.5 获取上传凭证"""
file_name: str = Field(min_length=1, max_length=200)
file_size: int = Field(gt=0, description="字节数")
upload_mode: str = Field(default="raw", pattern="^(raw|hls)$")
class PublishVideoReq(BaseModel):
"""5.6 发布视频(开发环境仅支持 raw 模式:object_key 必填)"""
object_key: str = Field(min_length=1, max_length=300)
title: str = Field(min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
category_id: int = Field(ge=1)
tags: list[str] = Field(default_factory=list, max_length=5)
class EditVideoReq(BaseModel):
"""5.12 编辑视频(字段均可选,传哪个改哪个)"""
title: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=500)
category_id: int | None = Field(default=None, ge=1)
tags: list[str] | None = Field(default=None, max_length=5)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
"""认证服务:验证码 / 注册 / 登录 / 刷新 / 登出 / 找回密码"""
import logging
import random
import string
from datetime import datetime, timedelta
import redis.asyncio as aioredis
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config, settings
from app.core import errors
from app.core.response import BizException
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from app.models.incentive import AssetWallet, Referral
from app.models.user import User
from app.services import config_service, invite_service, membership_service, sensitive_service, wallet_service
logger = logging.getLogger(__name__)
REFRESH_TTL = 30 * 24 * 3600
DEACTIVATE_FREEZE_DAYS = 7
# ---------- 验证码 ----------
def _code_key(scene: str, channel: str, target: str) -> str:
return f"{channel}:code:{scene}:{target}"
async def send_code(
db: AsyncSession, redis: aioredis.Redis, channel: str, target: str, scene: str, ip: str
) -> dict:
"""发送验证码(短信/邮箱)。未配置通道时按开发模式返回 dev_code 并记日志。"""
# 场景前置校验:register 要求未注册,login/reset 要求已注册
user = await _find_user(db, channel, target)
if scene == "register" and user is not None:
raise BizException(errors.PARAM_ERROR, "该账号已注册")
if scene in ("login", "reset") and user is None:
raise BizException(errors.PARAM_ERROR, "该账号未注册")
# 限流:同号 60s 1 条 + 同号每天 10 条 + 同 IP 5 条/小时
limit_key = f"{channel}:limit:{target}"
if await redis.exists(limit_key):
raise BizException(errors.RATE_LIMITED, "发送过于频繁,请 60 秒后再试")
day_key = f"{channel}:day:{target}:{datetime.now():%Y%m%d}"
if int(await redis.get(day_key) or 0) >= 10:
raise BizException(errors.RATE_LIMITED, "今日验证码发送已达上限")
ip_key = f"{channel}:iphour:{ip}:{datetime.now():%Y%m%d%H}"
if int(await redis.get(ip_key) or 0) >= 5:
raise BizException(errors.RATE_LIMITED, "发送过于频繁,请稍后再试")
code = "".join(random.choices(string.digits, k=6))
ttl = 300 if channel == "sms" else 600
await redis.set(_code_key(scene, channel, target), code, ex=ttl)
await redis.set(limit_key, 1, ex=60)
await redis.incr(day_key)
await redis.expire(day_key, 48 * 3600)
await redis.incr(ip_key)
await redis.expire(ip_key, 3600)
data = {"expire_seconds": ttl, "retry_after": 60}
# TODO: 接入阿里云短信 / SMTP 实际发送;未配置通道时按开发模式处理
channel_configured = bool(settings.sms_access_key_id) if channel == "sms" else bool(settings.smtp_host)
if not channel_configured:
logger.info("[dev] %s code for %s (%s): %s", channel, target, scene, code)
data["dev_code"] = code
return data
async def _verify_code(redis: aioredis.Redis, channel: str, target: str, scene: str, code: str) -> None:
key = _code_key(scene, channel, target)
saved = await redis.get(key)
if not saved or saved != code:
raise BizException(errors.CAPTCHA_ERROR)
await redis.delete(key)
async def _find_user(db: AsyncSession, channel: str, target: str) -> User | None:
field = User.phone if channel == "sms" else User.email
return (await db.execute(select(User).where(field == target))).scalar_one_or_none()
# ---------- Token ----------
async def _issue_tokens(redis: aioredis.Redis, user_id: int) -> dict:
"""签发双 Token:access 2h + refresh 30d(白名单 Set 支持多端)"""
epoch = int(await redis.get(f"auth:epoch:{user_id}") or 0)
access, _, _ = create_access_token(user_id, epoch)
refresh, refresh_jti, _ = create_refresh_token(user_id, epoch)
key = f"auth:refresh:{user_id}"
await redis.sadd(key, refresh_jti) # type: ignore[misc]
await redis.expire(key, REFRESH_TTL)
return {"access_token": access, "refresh_token": refresh, "expires_in": settings.access_token_ttl}
async def _user_brief(db: AsyncSession, user: User) -> dict:
is_member = await membership_service.is_member(db, user.id)
return {
"user_id": user.id,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"is_member": is_member,
}
# ---------- 注册 ----------
async def register(
db: AsyncSession, redis: aioredis.Redis, req, device_id: str | None, ip: str
) -> dict:
channel = "sms" if req.type == "phone" else "email"
target = req.phone if req.type == "phone" else req.email
await _verify_code(redis, channel, target, "register", req.code)
# 唯一性校验
dup = await db.execute(
select(User.id).where(or_(User.phone == req.phone if req.phone else False,
User.email == req.email if req.email else False))
)
if dup.first():
raise BizException(errors.PARAM_ERROR, "手机号/邮箱已注册")
# 昵称:默认"用户+随机尾号",过敏感词
nickname = req.nickname or f"用户{random.randint(100000, 999999)}"
if await sensitive_service.find_sensitive(db, redis, nickname):
raise BizException(errors.SENSITIVE_WORD)
# 生成唯一邀请码
invite_code = await _gen_invite_code(db)
user = User(
phone=req.phone if req.type == "phone" else None,
email=req.email if req.type == "email" else None,
password_hash=hash_password(req.password),
nickname=nickname,
register_type=1 if req.type == "phone" else 2,
invite_code=invite_code,
register_ip=ip,
last_login_at=datetime.now(),
last_login_ip=ip,
)
db.add(user)
await db.flush()
await wallet_service.get_or_create_wallet(db, user.id)
db.add(AssetWallet(user_id=user.id))
# 绑定推荐关系:显式邀请码(口令/手填)优先,否则取首启模糊归因缓存
bind_code = req.invite_code
if not bind_code and device_id:
bind_code = await invite_service.pop_attribution(redis, device_id)
if bind_code:
await _bind_referral(db, redis, user, bind_code, device_id, ip)
# 设备注册标记(防小号绑定,须在绑定校验之后写入,否则同设备校验永远读到新账号自己)
if device_id:
await redis.set(f"ref:device:{device_id}", user.id)
# 新注册赠送体验会员(后台可配开关与天数,实时生效)
trial_granted = None
gift_enabled = await config_service.get_config(db, redis, "register_gift_trial.enabled", "1")
if str(gift_enabled) == "1":
days = await config_service.get_config_int(db, redis, "register_gift_trial.days", 1)
order = await membership_service.grant_trial_gift(db, user.id, days)
trial_granted = {
"plan_code": "trial",
"plan_name": "体验会员",
"days": days,
"end_at": order.end_at.strftime("%Y-%m-%d %H:%M:%S"),
}
await db.commit()
tokens = await _issue_tokens(redis, user.id)
return {
"user_id": user.id,
**tokens,
"user": await _user_brief(db, user),
"trial_granted": trial_granted,
}
async def _gen_invite_code(db: AsyncSession) -> str:
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" # 去除易混淆字符
for _ in range(10):
code = "".join(random.choices(alphabet, k=8))
exists = await db.execute(select(User.id).where(User.invite_code == code))
if not exists.first():
return code
raise BizException(errors.INTERNAL_ERROR, "邀请码生成失败")
async def _bind_referral(
db: AsyncSession, redis: aioredis.Redis, invitee: User, invite_code: str, device_id: str | None, ip: str
) -> None:
"""注册时绑定推荐关系,含风控规则(同设备无效/邀请人单日超限冻结/同IP批量注册标记)"""
inviter = (await db.execute(select(User).where(User.invite_code == invite_code))).scalar_one_or_none()
if inviter is None or inviter.id == invitee.id:
return # 邀请码无效则忽略绑定(不阻断注册)
status, risk_flag = 0, ""
# 同 device 已存在账号 → 绑定直接无效
if device_id and await redis.exists(f"ref:device:{device_id}"):
old = int(await redis.get(f"ref:device:{device_id}") or 0)
if old and old != invitee.id:
status, risk_flag = 2, "same_device"
# 邀请人 24h 新增绑定 >10 → 冻结
risk_key = f"ref:risk:{inviter.id}:{datetime.now():%Y%m%d}"
bind_count = await redis.incr(risk_key)
await redis.expire(risk_key, 48 * 3600)
max_bind = (biz_config.get("referral") or {}).get("risk_max_bind_per_day", 10)
if status == 0 and bind_count > max_bind:
status, risk_flag = 3, "inviter_burst"
# 同 IP 24h 注册 >5 → 标记风险
if status == 0:
max_reg = (biz_config.get("referral") or {}).get("risk_max_register_per_ip", 5)
cnt = (await db.execute(
select(func.count(User.id)).where(
User.register_ip == ip, User.created_at > datetime.now() - timedelta(hours=24)
)
)).scalar() or 0
if cnt > max_reg:
status, risk_flag = 3, "batch_register"
db.add(Referral(
inviter_id=inviter.id,
invitee_id=invitee.id,
invite_code=invite_code,
status=status,
risk_flag=risk_flag,
register_ip=ip,
))
# ---------- 登录 ----------
async def login(db: AsyncSession, redis: aioredis.Redis, req, ip: str) -> dict:
# 连续失败 5 次锁定 15 分钟(IP 维度)
fail_key = f"login:fail:{ip}"
if int(await redis.get(fail_key) or 0) >= 5:
raise BizException(errors.RATE_LIMITED, "失败次数过多,账号已锁定 15 分钟", http_status=429)
channel = "sms" if req.type.startswith("phone") else "email"
target = req.phone if channel == "sms" else req.email
user = await _find_user(db, channel, target)
async def _fail():
await redis.incr(fail_key)
await redis.expire(fail_key, 15 * 60)
raise BizException(errors.PARAM_ERROR, "账号或密码错误")
if user is None:
await _fail()
if req.type.endswith("code"):
try:
await _verify_code(redis, channel, target, "login", req.code)
except BizException:
await _fail()
elif not verify_password(req.password, user.password_hash):
await _fail()
await redis.delete(fail_key)
if user.status == 2:
raise BizException(errors.FORBIDDEN, "账号已被封禁", http_status=403)
# 注销冻结期内登录:返回 1009 + can_revoke(App 弹窗询问;确认后带 revoke=1 重试即撤销注销)
if user.deactivate_at is not None:
if getattr(req, "revoke", False):
user.deactivate_at = None # 冻结期内撤销注销
else:
raise BizException(
errors.ACCOUNT_DEACTIVATED, "账号已注销", http_status=403, data={"can_revoke": True}
)
user.last_login_at = datetime.now()
user.last_login_ip = ip
await db.commit()
tokens = await _issue_tokens(redis, user.id)
return {"user_id": user.id, **tokens, "user": await _user_brief(db, user)}
# ---------- 刷新 / 登出 / 找回密码 ----------
async def refresh(db: AsyncSession, redis: aioredis.Redis, refresh_token: str) -> dict:
"""刷新令牌:白名单校验 + 旋转(旧 refresh 立即作废)"""
payload = decode_token(refresh_token)
if payload.get("type") != "refresh":
raise BizException(errors.UNAUTHORIZED, http_status=401)
user_id = int(payload["sub"])
jti = payload.get("jti", "")
epoch = int(await redis.get(f"auth:epoch:{user_id}") or 0)
if int(payload.get("ep", 0)) != epoch:
raise BizException(errors.UNAUTHORIZED, http_status=401)
key = f"auth:refresh:{user_id}"
if not await redis.sismember(key, jti): # type: ignore[misc]
raise BizException(errors.UNAUTHORIZED, http_status=401)
await redis.srem(key, jti) # type: ignore[misc]
# 用户仍须可用
user = await db.get(User, user_id)
if user is None or user.status != 1:
raise BizException(errors.UNAUTHORIZED, http_status=401)
return await _issue_tokens(redis, user_id)
async def logout(redis: aioredis.Redis, user_id: int, access_jti: str, access_exp: int) -> None:
"""登出:access jti 入黑名单 + 删 refresh 白名单"""
ttl = max(access_exp - int(datetime.now().timestamp()), 1)
await redis.set(f"token:blacklist:{access_jti}", 1, ex=ttl)
await redis.delete(f"auth:refresh:{user_id}")
async def reset_password(db: AsyncSession, redis: aioredis.Redis, req) -> None:
channel = "sms" if req.type == "phone" else "email"
target = req.phone if req.type == "phone" else req.email
user = await _find_user(db, channel, target)
if user is None:
raise BizException(errors.PARAM_ERROR, "账号不存在")
await _verify_code(redis, channel, target, "reset", req.code)
user.password_hash = hash_password(req.new_password)
# 全端 Token 失效:递增令牌纪元 + 清 refresh 白名单
await redis.incr(f"auth:epoch:{user.id}")
await redis.delete(f"auth:refresh:{user.id}")
await db.commit()
@@ -0,0 +1,145 @@
"""通用服务:版本检查 / 通知 / 举报(对应《03-接口文档》第 10 章)"""
import json
from datetime import datetime
import redis.asyncio as aioredis
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import errors, ratelimit
from app.core.response import BizException
from app.models.common import Notification, Report
from app.services import config_service
# 举报限流:10 次/天/用户
REPORT_DAILY_LIMIT = 10
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
# ---------- 版本检查 ----------
async def get_app_version(db: AsyncSession, redis: aioredis.Redis, platform: str, version_code: int) -> dict:
"""版本检查(10.1):配置存 system_configs key=app.version.{platform},JSON 值,后台可改"""
raw = await config_service.get_config(db, redis, f"app.version.{platform}", "")
cfg: dict = {}
if raw:
try:
cfg = json.loads(raw)
except (TypeError, ValueError):
cfg = {}
min_code = int(cfg.get("min_version_code", 0))
return {
"latest_version": cfg.get("latest_version", "1.0.0"),
"latest_version_code": int(cfg.get("latest_version_code", 1)),
"force_update": version_code < min_code,
"update_url": cfg.get("update_url", ""),
"notice": cfg.get("notice", ""),
}
# ---------- 通知 ----------
async def notify(
db: AsyncSession, redis: aioredis.Redis, user_id: int,
type: int, title: str, content: str = "", biz_id: str = "",
) -> None:
"""写入通知并累加 Redis 未读数(审核/提现/公告/评论回复共用,供后续模块调用)"""
db.add(Notification(user_id=user_id, type=type, title=title, content=content, biz_id=biz_id))
await db.flush()
await redis.incr(f"notif:unread:{user_id}")
async def list_notifications(
db: AsyncSession, user_id: int, page: int, page_size: int, type: int | None
) -> dict:
"""通知列表(10.2)"""
stmt = select(Notification).where(Notification.user_id == user_id)
if type is not None:
stmt = stmt.where(Notification.type == type)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (
await db.execute(
stmt.order_by(Notification.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
).scalars().all()
return {
"total": total,
"list": [
{
"id": n.id,
"type": n.type,
"title": n.title,
"content": n.content,
"biz_id": n.biz_id,
"is_read": bool(n.is_read),
"created_at": _fmt(n.created_at),
}
for n in rows
],
}
async def get_unread_count(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> int:
"""未读数(10.3):直接读 Redis;缺失时从 DB 回填"""
key = f"notif:unread:{user_id}"
cached = await redis.get(key)
if cached is not None:
return max(int(cached), 0)
count = (await db.execute(
select(func.count(Notification.id)).where(Notification.user_id == user_id, Notification.is_read == 0)
)).scalar() or 0
await redis.set(key, count)
return count
async def mark_read(
db: AsyncSession, redis: aioredis.Redis, user_id: int, ids: list[int] | None, mark_all: bool
) -> None:
"""标记已读(10.4):ids 与 all 二选一;同步修正 Redis 未读数"""
if mark_all:
await db.execute(
update(Notification).where(Notification.user_id == user_id, Notification.is_read == 0)
.values(is_read=1)
)
await db.commit()
await redis.set(f"notif:unread:{user_id}", 0)
return
if not ids:
raise BizException(errors.PARAM_ERROR, "ids 与 all 需传其一")
result = await db.execute(
update(Notification)
.where(Notification.user_id == user_id, Notification.is_read == 0, Notification.id.in_(ids))
.values(is_read=1)
)
await db.commit()
affected = result.rowcount or 0
if affected > 0:
# 未读数下限 0(DB 为准重算,避免并发脏读)
remaining = (await db.execute(
select(func.count(Notification.id)).where(Notification.user_id == user_id, Notification.is_read == 0)
)).scalar() or 0
await redis.set(f"notif:unread:{user_id}", remaining)
# ---------- 举报 ----------
async def create_report(db: AsyncSession, redis: aioredis.Redis, user_id: int, req) -> None:
"""提交举报(10.5):唯一索引 uk_user_target 防重复(2106);限流 10 次/天"""
today = f"{datetime.now():%Y%m%d}"
await ratelimit.hit(redis, f"rl:report:{user_id}:{today}", REPORT_DAILY_LIMIT, 24 * 3600)
db.add(Report(
user_id=user_id,
target_type=req.target_type,
target_id=req.target_id,
reason_type=req.reason_type,
remark=req.remark or "",
))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise BizException(errors.REPORT_DUPLICATED)
@@ -0,0 +1,50 @@
"""平台配置服务:system_configs 表 + Redis 缓存 60s(后台修改即时生效)"""
from typing import Any
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.common import SystemConfig
CACHE_KEY = "config:system"
CACHE_TTL = 60
async def get_all_configs(db: AsyncSession, redis: aioredis.Redis) -> dict[str, str]:
"""全量读取配置(Redis Hash 缓存 60s)"""
cached = await redis.hgetall(CACHE_KEY) # type: ignore[misc]
if cached:
return dict(cached)
rows = (await db.execute(select(SystemConfig))).scalars().all()
configs = {row.config_key: row.config_value for row in rows}
if configs:
await redis.hset(CACHE_KEY, mapping=configs) # type: ignore[misc]
await redis.expire(CACHE_KEY, CACHE_TTL)
return configs
async def get_config(db: AsyncSession, redis: aioredis.Redis, key: str, default: Any = None) -> str | Any:
"""读取单个配置项"""
configs = await get_all_configs(db, redis)
return configs.get(key, default)
async def get_config_int(db: AsyncSession, redis: aioredis.Redis, key: str, default: int) -> int:
value = await get_config(db, redis, key, None)
try:
return int(value) if value is not None else default
except (TypeError, ValueError):
return default
async def set_config(db: AsyncSession, redis: aioredis.Redis, key: str, value: str, admin_id: int | None = None) -> None:
"""写入配置并失效缓存(后台调用)"""
row = await db.get(SystemConfig, key)
if row is None:
db.add(SystemConfig(config_key=key, config_value=value, updated_by=admin_id))
else:
row.config_value = value
row.updated_by = admin_id
await db.commit()
await redis.delete(CACHE_KEY)
@@ -0,0 +1,324 @@
"""激励服务:资产钱包 / 推荐绑定 / 提现(对应《03-接口文档》第 9 章、《02-技术文档》6.6)"""
from datetime import datetime, timedelta
from decimal import Decimal
import redis.asyncio as aioredis
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config
from app.core import errors
from app.core.response import BizException
from app.models.incentive import AssetTransaction, AssetWallet, Referral, Withdrawal
from app.models.user import User
from app.services import config_service, membership_service
# 资产流水业务类型(与 asset_transactions.biz_type 注释一致)
ASSET_BIZ_REBATE = 1 # 充值返利
ASSET_BIZ_COMPLETE = 2 # 完整观看收益
ASSET_BIZ_LIKE = 3 # 点赞收益
ASSET_BIZ_WITHDRAW = 4 # 提现
ASSET_BIZ_WITHDRAW_BACK = 5 # 提现退回
ASSET_BIZ_CLAWBACK = 6 # 返利扣回
ASSET_BIZ_ADJUST = 7 # 后台调整
# 提现单在途锁兜底过期(正常由后台审核后释放)
WD_LOCK_TTL = 7 * 24 * 3600
# 补绑邀请码时限:注册后 24h 内
BIND_WINDOW_HOURS = 24
REFERRAL_STATUS_TEXT = {0: "待激活", 1: "有效", 2: "无效", 3: "风控冻结"}
WITHDRAW_STATUS_TEXT = {0: "待审核", 1: "已打款", 2: "已拒绝"}
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def _asset_cfg() -> dict:
return biz_config.get("asset") or {}
def _withdraw_cfg() -> dict:
return biz_config.get("withdraw") or {}
# ---------- 资产钱包变更(与积分钱包同一套"行锁 + 流水 + 乐观锁"规范) ----------
async def get_or_create_asset_wallet(db: AsyncSession, user_id: int, for_update: bool = False) -> AssetWallet:
stmt = select(AssetWallet).where(AssetWallet.user_id == user_id)
if for_update:
stmt = stmt.with_for_update()
wallet = (await db.execute(stmt)).scalar_one_or_none()
if wallet is None:
wallet = AssetWallet(user_id=user_id)
db.add(wallet)
await db.flush()
if for_update:
wallet = (await db.execute(stmt)).scalar_one()
return wallet
async def change_assets(
db: AsyncSession,
user_id: int,
amount: Decimal,
biz_type: int,
biz_id: str = "",
remark: str = "",
allow_negative: bool = False,
) -> Decimal:
"""变更资产余额,返回变动后余额。
- amount 正增负减;扣减默认不允许透支(退款/返利扣回场景传 allow_negative=True)
- 同事务内:行锁 → 校验/更新余额与累计 → 写流水
"""
wallet = await get_or_create_asset_wallet(db, user_id, for_update=True)
new_balance = wallet.assets + amount
if amount < 0 and new_balance < 0 and not allow_negative:
raise BizException(errors.ASSET_NOT_ENOUGH)
wallet.assets = new_balance
wallet.version += 1
if amount > 0:
wallet.total_earned += amount
elif biz_type == ASSET_BIZ_WITHDRAW:
wallet.total_withdrawn += -amount
db.add(
AssetTransaction(
user_id=user_id,
change_amount=amount,
balance_after=new_balance,
biz_type=biz_type,
biz_id=biz_id,
remark=remark,
)
)
await db.flush()
return new_balance
# ---------- 推荐 ----------
async def get_my_referral(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> dict:
"""我的邀请(9.1):邀请码 + 统计 + 当前会员身份(eligible)"""
user = await db.get(User, user_id)
if user is None:
raise BizException(errors.UNAUTHORIZED, http_status=401)
base_url = await config_service.get_config(db, redis, "app.invite_base_url", "https://app.example.com/r/")
total_invitees = (await db.execute(
select(func.count(Referral.id)).where(Referral.inviter_id == user_id)
)).scalar() or 0
activated_count = (await db.execute(
select(func.count(Referral.id)).where(
Referral.inviter_id == user_id, Referral.status == 1
)
)).scalar() or 0
# 邀请积分奖励累计(point_transactions biz_type=8)
from app.models.billing import PointTransaction
reward_points_total = (await db.execute(
select(func.coalesce(func.sum(PointTransaction.change_amount), 0)).where(
PointTransaction.user_id == user_id, PointTransaction.biz_type == 8
)
)).scalar() or 0
# 充值返利资产累计(asset_transactions biz_type=1)
rebate_assets_total = (await db.execute(
select(func.coalesce(func.sum(AssetTransaction.change_amount), 0)).where(
AssetTransaction.user_id == user_id, AssetTransaction.biz_type == ASSET_BIZ_REBATE
)
)).scalar() or 0
return {
"invite_code": user.invite_code,
"invite_url": f"{base_url}{user.invite_code}",
"eligible": await membership_service.is_member(db, user_id),
"total_invitees": total_invitees,
"activated_count": activated_count,
"reward_points_total": int(reward_points_total),
"rebate_assets_total": float(rebate_assets_total),
}
async def bind_invite_code(
db: AsyncSession, redis: aioredis.Redis, user_id: int, invite_code: str, device_id: str | None, ip: str
) -> dict:
"""补绑邀请码(9.2):注册 24h 内仅一次;无效码/已绑定/绑自己/同设备均 2101"""
user = await db.get(User, user_id)
if user is None:
raise BizException(errors.UNAUTHORIZED, http_status=401)
bound = (await db.execute(
select(Referral.id).where(Referral.invitee_id == user_id)
)).first()
if bound:
raise BizException(errors.INVITE_CODE_INVALID, "已绑定过邀请码")
if user.created_at < datetime.now() - timedelta(hours=BIND_WINDOW_HOURS):
raise BizException(errors.INVITE_CODE_INVALID, "超过绑定时限(注册后24小时内可绑定)")
inviter = (await db.execute(
select(User).where(User.invite_code == invite_code)
)).scalar_one_or_none()
if inviter is None:
raise BizException(errors.INVITE_CODE_INVALID, "邀请码无效")
if inviter.id == user_id:
raise BizException(errors.INVITE_CODE_INVALID, "不能绑定自己的邀请码")
if device_id and await redis.exists(f"ref:device:{device_id}"):
old = int(await redis.get(f"ref:device:{device_id}") or 0)
if old and old != user_id:
raise BizException(errors.INVITE_CODE_INVALID, "当前设备已注册其他账号")
# 风控规则与注册时一致(邀请人单日超限冻结 / 同 IP 批量注册标记)
status, risk_flag = 0, ""
risk_key = f"ref:risk:{inviter.id}:{datetime.now():%Y%m%d}"
bind_count = await redis.incr(risk_key)
await redis.expire(risk_key, 48 * 3600)
max_bind = (biz_config.get("referral") or {}).get("risk_max_bind_per_day", 10)
if bind_count > max_bind:
status, risk_flag = 3, "inviter_burst"
else:
max_reg = (biz_config.get("referral") or {}).get("risk_max_register_per_ip", 5)
cnt = (await db.execute(
select(func.count(User.id)).where(
User.register_ip == ip, User.created_at > datetime.now() - timedelta(hours=24)
)
)).scalar() or 0
if cnt > max_reg:
status, risk_flag = 3, "batch_register"
db.add(Referral(
inviter_id=inviter.id,
invitee_id=user_id,
invite_code=invite_code,
status=status,
risk_flag=risk_flag,
register_ip=ip,
))
await db.commit()
return {"bound": True, "status": status, "status_text": REFERRAL_STATUS_TEXT[status]}
# ---------- 资产 / 提现 ----------
async def get_my_assets(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> dict:
"""资产钱包(9.3)"""
wallet = await get_or_create_asset_wallet(db, user_id)
per_yuan = Decimal(str(_asset_cfg().get("assets_per_yuan", 10)))
has_pending = bool(await redis.exists(f"wd:pending:{user_id}")) or bool(
(await db.execute(
select(Withdrawal.id).where(Withdrawal.user_id == user_id, Withdrawal.status == 0)
)).first()
)
return {
"assets": float(wallet.assets),
"assets_value_yuan": float(wallet.assets / per_yuan),
"total_earned": float(wallet.total_earned),
"total_withdrawn": float(wallet.total_withdrawn),
"min_withdraw_assets": int(_withdraw_cfg().get("min_assets", 100)),
"has_pending_withdraw": has_pending,
}
async def list_asset_records(
db: AsyncSession, user_id: int, page: int, page_size: int, biz_type: int | None
) -> dict:
"""资产流水(9.4)"""
stmt = select(AssetTransaction).where(AssetTransaction.user_id == user_id)
if biz_type is not None:
stmt = stmt.where(AssetTransaction.biz_type == biz_type)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (
await db.execute(
stmt.order_by(AssetTransaction.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
).scalars().all()
return {
"total": total,
"list": [
{
"id": r.id,
"change_amount": float(r.change_amount),
"balance_after": float(r.balance_after),
"biz_type": r.biz_type,
"remark": r.remark,
"created_at": _fmt(r.created_at),
}
for r in rows
],
}
async def apply_withdraw(
db: AsyncSession, redis: aioredis.Redis, user_id: int, assets: Decimal, channel: int, account: str
) -> dict:
"""申请提现(9.5):在途锁防并发 → 事务内冻结扣减 + 建单;审核拒绝由后台退回并释放锁"""
min_assets = int(_withdraw_cfg().get("min_assets", 100))
if assets < min_assets:
raise BizException(errors.WITHDRAW_TOO_LOW)
lock_key = f"wd:pending:{user_id}"
if not await redis.set(lock_key, 1, nx=True, ex=WD_LOCK_TTL):
raise BizException(errors.WITHDRAW_PENDING)
try:
# 双保险:DB 层存在待审核单也拦截(锁丢失场景)
if (await db.execute(
select(Withdrawal.id).where(Withdrawal.user_id == user_id, Withdrawal.status == 0)
)).first():
raise BizException(errors.WITHDRAW_PENDING)
per_yuan = Decimal(str(_asset_cfg().get("assets_per_yuan", 10)))
amount_fen = int((assets / per_yuan) * 100) # 10资产=1元 → 1资产=10分
# 冻结扣减(余额不足抛 2102,事务回滚)
await change_assets(
db, user_id, -assets, ASSET_BIZ_WITHDRAW,
remark=f"申请提现冻结({account})",
)
wd = Withdrawal(
withdraw_no=membership_service.gen_order_no("W"),
user_id=user_id,
assets=assets,
amount_fen=amount_fen,
channel=channel,
account=account,
)
db.add(wd)
await db.commit()
except Exception:
await redis.delete(lock_key) # 建单失败释放锁
raise
return {
"withdraw_no": wd.withdraw_no,
"assets": float(wd.assets),
"amount_yuan": float(Decimal(wd.amount_fen) / 100),
"status": 0,
"status_text": WITHDRAW_STATUS_TEXT[0],
}
async def list_withdraw_records(db: AsyncSession, user_id: int, page: int, page_size: int) -> dict:
"""提现记录(9.6)"""
stmt = select(Withdrawal).where(Withdrawal.user_id == user_id)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (
await db.execute(
stmt.order_by(Withdrawal.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
).scalars().all()
return {
"total": total,
"list": [
{
"withdraw_no": w.withdraw_no,
"assets": float(w.assets),
"amount_yuan": float(Decimal(w.amount_fen) / 100),
"channel": w.channel,
"status": w.status,
"status_text": WITHDRAW_STATUS_TEXT.get(w.status, ""),
"created_at": _fmt(w.created_at),
}
for w in rows
],
}
@@ -0,0 +1,321 @@
"""互动服务:点赞 / 收藏 / 评论 / 我的收藏 / 观看历史(对应《03-接口文档》第 6 章)"""
from datetime import datetime
import redis.asyncio as aioredis
from sqlalchemy import func, select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config
from app.core import errors
from app.core.response import BizException
from app.models.billing import WatchRecord
from app.models.incentive import VideoEarning
from app.models.interaction import Comment, Favorite, Like
from app.models.user import User
from app.models.video import Video
from app.services import sensitive_service, video_service, wallet_service
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def _reward_cfg() -> dict:
return (biz_config.get("points") or {}).get("comment_reward") or {}
async def _get_published_video(db: AsyncSession, video_id: int) -> Video:
video = await db.get(Video, video_id)
if video is None or video.status != video_service.STATUS_PUBLISHED:
raise BizException(errors.VIDEO_NOT_AVAILABLE)
return video
# ---------- 点赞 / 收藏 ----------
async def toggle_like(db: AsyncSession, redis: aioredis.Redis, user_id: int, video_id: int) -> dict:
"""切换点赞:明细 + 计数 + 创作者收益刻度(likes_accrued)同事务,取消会回退"""
video = await _get_published_video(db, video_id)
existing = (await db.execute(
select(Like).where(Like.user_id == user_id, Like.video_id == video_id)
)).scalar_one_or_none()
if existing:
await db.delete(existing)
video.like_count = max(video.like_count - 1, 0)
is_liked = False
stmt = mysql_insert(VideoEarning).values(video_id=video_id, author_id=video.user_id, likes_accrued=0)
stmt = stmt.on_duplicate_key_update(likes_accrued=func.greatest(VideoEarning.likes_accrued - 1, 0))
await db.execute(stmt)
else:
db.add(Like(user_id=user_id, video_id=video_id))
video.like_count += 1
is_liked = True
stmt = mysql_insert(VideoEarning).values(video_id=video_id, author_id=video.user_id, likes_accrued=1)
stmt = stmt.on_duplicate_key_update(likes_accrued=VideoEarning.likes_accrued + 1)
await db.execute(stmt)
await db.commit()
await video_service.invalidate_detail_cache(redis, video_id)
return {"is_liked": is_liked, "like_count": video.like_count}
async def toggle_favorite(db: AsyncSession, redis: aioredis.Redis, user_id: int, video_id: int) -> dict:
"""切换收藏:明细 + 计数同事务"""
video = await _get_published_video(db, video_id)
existing = (await db.execute(
select(Favorite).where(Favorite.user_id == user_id, Favorite.video_id == video_id)
)).scalar_one_or_none()
if existing:
await db.delete(existing)
video.favorite_count = max(video.favorite_count - 1, 0)
is_favorited = False
else:
db.add(Favorite(user_id=user_id, video_id=video_id))
video.favorite_count += 1
is_favorited = True
await db.commit()
await video_service.invalidate_detail_cache(redis, video_id)
return {"is_favorited": is_favorited, "favorite_count": video.favorite_count}
# ---------- 评论 ----------
async def list_comments(
db: AsyncSession, video_id: int, page: int, page_size: int, parent_id: int
) -> dict:
"""评论列表:parent_id=0 查一级评论(带 reply_count),否则查二级回复"""
stmt = select(Comment).where(
Comment.video_id == video_id, Comment.parent_id == parent_id, Comment.status == 1
)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (
await db.execute(
stmt.order_by(Comment.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
).scalars().all()
users = {
u.id: u
for u in (await db.execute(
select(User).where(User.id.in_({c.user_id for c in rows} or {0}))
)).scalars()
}
# 一级评论批量统计回复数
reply_counts: dict[int, int] = {}
if parent_id == 0 and rows:
rc = await db.execute(
select(Comment.parent_id, func.count())
.where(Comment.parent_id.in_([c.id for c in rows]), Comment.status == 1)
.group_by(Comment.parent_id)
)
reply_counts = {pid: cnt for pid, cnt in rc.all()}
return {
"total": total,
"list": [
{
"comment_id": c.id,
"user": {
"user_id": c.user_id,
"nickname": users[c.user_id].nickname if c.user_id in users else "",
"avatar_url": users[c.user_id].avatar_url if c.user_id in users else "",
},
"content": c.content,
"like_count": c.like_count,
"reply_count": reply_counts.get(c.id, 0),
"created_at": _fmt(c.created_at),
}
for c in rows
],
}
async def _read_prog(redis: aioredis.Redis, user_id: int, today: str) -> tuple[int, int]:
"""读取当日评论奖励进度(count 当前进度 / daily 当日已发奖次数),跨日自动清零"""
prog = await redis.hgetall(f"comment:prog:{user_id}") # type: ignore[misc]
if prog.get("date") != today:
return 0, 0
return int(prog.get("count", 0)), int(prog.get("daily", 0))
async def _write_prog(redis: aioredis.Redis, user_id: int, today: str, count: int, daily: int) -> None:
key = f"comment:prog:{user_id}"
await redis.hset(key, mapping={"count": count, "daily": daily, "date": today}) # type: ignore[misc]
await redis.expire(key, 48 * 3600)
async def create_comment(
db: AsyncSession, redis: aioredis.Redis, user_id: int, video_id: int, req
) -> dict:
"""发表评论:敏感词 → 落库 + 计数 → ≥min_length 字计入积分进度,满 per_count 自动发奖"""
video = await _get_published_video(db, video_id)
if await sensitive_service.find_sensitive(db, redis, req.content):
raise BizException(errors.SENSITIVE_WORD)
parent_id = int(req.parent_id or 0)
if parent_id:
parent = await db.get(Comment, parent_id)
if parent is None or parent.video_id != video_id or parent.status != 1:
raise BizException(errors.NOT_FOUND, "回复的评论不存在")
cfg = _reward_cfg()
per_count = int(cfg.get("per_count", 10))
reward = int(cfg.get("reward", 1))
min_length = int(cfg.get("min_length", 5))
daily_limit = int(cfg.get("daily_limit", 5))
today = f"{datetime.now():%Y%m%d}"
comment = Comment(
video_id=video_id,
user_id=user_id,
parent_id=parent_id,
reply_to_user_id=req.reply_to_user_id,
content=req.content,
)
video.comment_count += 1
rewarded_this_time = False
counted = len(req.content) >= min_length # 字数达标才计入进度
count, daily = await _read_prog(redis, user_id, today)
if counted:
count += 1
comment.reward_counted = 1
if count >= per_count and daily < daily_limit:
# 满刻度自动发奖:清零进度,标记该评论触发过发奖(删评时扣回)
await wallet_service.change_points(
db, user_id, reward, wallet_service.BIZ_COMMENT,
biz_id=str(video_id), remark="评论奖励",
)
count, daily = 0, daily + 1
comment.reward_counted = 2
rewarded_this_time = True
await _write_prog(redis, user_id, today, count, daily)
db.add(comment)
await db.commit()
await video_service.invalidate_detail_cache(redis, video_id)
progress: dict = {"current": count, "target": per_count, "rewarded_this_time": rewarded_this_time}
if rewarded_this_time:
progress["reward_points"] = reward
return {"comment_id": comment.id, "reward_progress": progress}
async def remove_comment(db: AsyncSession, redis: aioredis.Redis, comment: Comment) -> dict:
"""删除评论核心逻辑(作者自删 / 视频作者删除 / 后台删除共用):已计入进度的回退,已发奖的扣回"""
video = await db.get(Video, comment.video_id)
cfg = _reward_cfg()
per_count = int(cfg.get("per_count", 10))
reward = int(cfg.get("reward", 1))
today = f"{datetime.now():%Y%m%d}"
# 进度归属评论作者(删除者可能是视频作者或管理员)
count, daily = await _read_prog(redis, comment.user_id, today)
if comment.reward_counted == 1:
# 仅计入进度:回退一格
count = max(count - 1, 0)
await _write_prog(redis, comment.user_id, today, count, daily)
elif comment.reward_counted == 2:
# 该评论触发过发奖:扣回积分(余额不足则扣到 0),当日已发次数回退
wallet = await wallet_service.get_or_create_wallet(db, comment.user_id, for_update=True)
clawback = min(reward, wallet.points)
if clawback > 0:
await wallet_service.change_points(
db, comment.user_id, -clawback, wallet_service.BIZ_CLAWBACK,
biz_id=str(comment.id), remark="删除评论扣回奖励",
)
daily = max(daily - 1, 0)
await _write_prog(redis, comment.user_id, today, count, daily)
comment.status = 2
comment.reward_counted = 0
if video is not None:
video.comment_count = max(video.comment_count - 1, 0)
await db.commit()
if video is not None:
await video_service.invalidate_detail_cache(redis, video.id)
return {"reward_progress": {"current": count, "target": per_count}}
async def delete_comment(
db: AsyncSession, redis: aioredis.Redis, user_id: int, comment_id: int
) -> dict:
"""删除评论(App 端):本人评论或本人视频下的评论可删"""
comment = await db.get(Comment, comment_id)
if comment is None or comment.status != 1:
raise BizException(errors.NOT_FOUND, "评论不存在")
video = await db.get(Video, comment.video_id)
if comment.user_id != user_id and (video is None or video.user_id != user_id):
raise BizException(errors.FORBIDDEN, "无权删除该评论")
return await remove_comment(db, redis, comment)
# ---------- 我的收藏 / 观看历史 ----------
async def list_my_favorites(
db: AsyncSession, user_id: int, page: int, page_size: int
) -> dict:
"""我的收藏:仅已发布视频,按收藏时间倒序,结构同 Feed 单条"""
stmt = (
select(Video, Favorite.created_at)
.join(Favorite, Favorite.video_id == Video.id)
.where(Favorite.user_id == user_id, Video.status == video_service.STATUS_PUBLISHED)
)
total = (await db.execute(
select(func.count()).select_from(
select(Favorite.id).join(Video, Video.id == Favorite.video_id).where(
Favorite.user_id == user_id, Video.status == video_service.STATUS_PUBLISHED
).subquery()
)
)).scalar() or 0
rows = (
await db.execute(
stmt.order_by(Favorite.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
).all()
return {
"total": total,
"list": await video_service.serialize_videos(db, [v for v, _ in rows], user_id),
}
async def list_my_history(
db: AsyncSession, user_id: int, page: int, page_size: int
) -> dict:
"""观看历史:每项 video(同 Feed 单条)+ watched_at + cost_type,按观看时间倒序"""
base = (
select(Video, WatchRecord.created_at, WatchRecord.cost_type)
.join(WatchRecord, WatchRecord.video_id == Video.id)
.where(
WatchRecord.subject_type == 1,
WatchRecord.subject_id == str(user_id),
Video.status == video_service.STATUS_PUBLISHED,
)
)
total = (await db.execute(
select(func.count()).select_from(
select(WatchRecord.id).join(Video, Video.id == WatchRecord.video_id).where(
WatchRecord.subject_type == 1,
WatchRecord.subject_id == str(user_id),
Video.status == video_service.STATUS_PUBLISHED,
).subquery()
)
)).scalar() or 0
rows = (
await db.execute(
base.order_by(WatchRecord.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
).all()
videos = await video_service.serialize_videos(db, [v for v, _, _ in rows], user_id)
items = []
for (v, watched_at, cost_type), serialized in zip(rows, videos):
items.append({"video": serialized, "watched_at": _fmt(watched_at), "cost_type": cost_type})
return {"total": total, "list": items}
@@ -0,0 +1,169 @@
"""推广归因服务:点击埋点 / 模糊匹配 / H5 中间页
三段式自建方案的辅助路径:
1. 分享口令(剪贴板)→ 注册时显式 invite_code,准确率 100%(主路径,App 端解析)
2. H5 中间页点击埋点(code+IP+UA 存 Redis 24h)→ App 首启上报 device_id 模糊匹配(本模块)
3. 注册页邀请码手填(兜底,沿用 /auth/register 的 invite_code 字段)
安全设计:模糊匹配结果由服务端按 device_id 缓存(invite:attr:{device_id}),
注册时服务端自取并一次性消费,不信任客户端回传,防止伪造归因。
"""
import html
import json
import time
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.services import config_service
CLICK_TTL = 24 * 3600 # 点击记录有效期(超过 24h 未安装不再归因)
ATTR_TTL = 7 * 24 * 3600 # 归因结果缓存(命中后允许延迟安装注册)
CLICK_DUP_TTL = 60 # 同 IP+code 60s 内重复埋点去重
MATCH_CANDIDATES = 5 # 同 IP 最近点击候选数
def _ip_key(ip: str) -> str:
return f"invite:ip:{ip}"
def _clk_key(code: str, ip: str) -> str:
return f"invite:clk:{code}:{ip}"
def attr_key(device_id: str) -> str:
return f"invite:attr:{device_id}"
# ---------- 点击埋点 ----------
async def record_click(redis: aioredis.Redis, code: str, ip: str, ua: str) -> dict:
"""H5 中间页点击埋点:IP 维度 zset(score=时间戳)+ 点击详情"""
if not ip:
return {"recorded": False}
# 60s 内同 IP+code 重复点击去重(页面刷新/重复打开)
dup = await redis.set(f"invite:dup:{ip}:{code}", 1, nx=True, ex=CLICK_DUP_TTL)
if not dup:
return {"recorded": True, "dedup": True}
now = time.time()
await redis.zadd(_ip_key(ip), {code: now})
await redis.expire(_ip_key(ip), CLICK_TTL)
await redis.set(_clk_key(code, ip), json.dumps({"ua": ua, "ts": now}), ex=CLICK_TTL)
return {"recorded": True}
# ---------- 归因匹配 ----------
async def match_and_cache(redis: aioredis.Redis, device_id: str, ip: str, ua: str) -> dict:
"""App 首启归因:按 IP 取最近一次点击的邀请码,缓存到 device_id 维度(幂等)"""
cached = await redis.get(attr_key(device_id))
if cached:
return {"matched": True, "invite_code": cached}
code = None
if ip:
# 最近点击优先(同 IP 可能点击过多个推广链接,时间近者为准)
for c in await redis.zrevrange(_ip_key(ip), 0, MATCH_CANDIDATES - 1):
if await redis.get(_clk_key(c, ip)):
code = c
break
if not code:
return {"matched": False}
await redis.set(attr_key(device_id), code, ex=ATTR_TTL)
return {"matched": True, "invite_code": code}
async def pop_attribution(redis: aioredis.Redis, device_id: str) -> str | None:
"""注册时取归因结果并一次性消费"""
key = attr_key(device_id)
code = await redis.get(key)
if code:
await redis.delete(key)
return code
# ---------- H5 中间页 ----------
_LANDING_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>邀请你加入短视频</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ background:#0f0f14; color:#fff; font-family:-apple-system,"PingFang SC","Helvetica Neue",sans-serif;
min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:32px 24px; }}
.logo {{ width:88px; height:88px; border-radius:20px; display:flex; align-items:center; justify-content:center;
background:linear-gradient(135deg,#fe2c55,#7c4dff); font-size:44px; margin-bottom:20px; }}
h1 {{ font-size:22px; margin-bottom:8px; }}
.sub {{ color:#9a9aa5; font-size:14px; margin-bottom:32px; text-align:center; line-height:1.6; }}
.inviter {{ color:#ffd700; }}
.btn {{ width:100%; max-width:320px; padding:15px 0; border:none; border-radius:12px; font-size:16px;
font-weight:600; cursor:pointer; margin-bottom:14px; }}
.btn-primary {{ background:linear-gradient(135deg,#fe2c55,#7c4dff); color:#fff; }}
.btn-ghost {{ background:#1e1e28; color:#e8e8ec; border:1px solid #33333f; }}
.code {{ color:#9a9aa5; font-size:13px; margin-top:10px; }}
.code b {{ color:#ffd700; font-size:16px; letter-spacing:2px; }}
#toast {{ position:fixed; left:50%; bottom:60px; transform:translateX(-50%); background:#333; color:#fff;
padding:10px 20px; border-radius:8px; font-size:14px; opacity:0; transition:opacity .3s; }}
</style>
</head>
<body>
<div class="logo">▶</div>
<h1>{title}</h1>
<p class="sub">新人注册立送体验会员<br>复制口令后打开 App 即可自动绑定邀请</p>
<button class="btn btn-primary" onclick="copyCmd()">复制邀请口令</button>
{download_btn}
<p class="code">我的邀请码 <b>{code}</b></p>
<div id="toast"></div>
<script>
var CODE = "{code}";
var CMD = "#短视频邀请码" + CODE + "#";
// 打开页面即埋点(记录 code+IP+UA,安装首启后据此归因)
fetch("/api/v1/invite/click", {{
method: "POST",
headers: {{ "Content-Type": "application/json" }},
body: JSON.stringify({{ code: CODE }})
}}).catch(function() {{}});
function toast(t) {{
var el = document.getElementById("toast");
el.textContent = t; el.style.opacity = 1;
setTimeout(function() {{ el.style.opacity = 0; }}, 1800);
}}
function copyCmd() {{
var done = function() {{ toast("口令已复制,打开 App 自动识别"); }};
if (navigator.clipboard && navigator.clipboard.writeText) {{
navigator.clipboard.writeText(CMD).then(done, function() {{ fallback(); }});
}} else {{ fallback(); }}
function fallback() {{
var ta = document.createElement("textarea");
ta.value = CMD; document.body.appendChild(ta);
ta.select(); try {{ document.execCommand("copy"); done(); }}
catch (e) {{ toast("复制失败,请手动输入邀请码"); }}
document.body.removeChild(ta);
}}
}}
</script>
</body>
</html>
"""
async def render_landing_page(db: AsyncSession, redis: aioredis.Redis, code: str) -> str:
"""推广落地页:展示邀请人 + 复制口令 + (可选)下载入口;页面加载即自动埋点"""
inviter = (await db.execute(select(User).where(User.invite_code == code))).scalar_one_or_none()
if inviter is not None:
title = f'<span class="inviter">{html.escape(inviter.nickname)}</span> 邀请你加入短视频'
else:
title = "邀请你加入短视频"
download_url = await config_service.get_config(db, redis, "app.download_url", "")
download_btn = (
f'<button class="btn btn-ghost" onclick="location.href=\'{html.escape(download_url, quote=True)}\'">下载 APP</button>'
if download_url else ""
)
safe_code = html.escape(code)
return _LANDING_HTML.format(title=title, code=safe_code, download_btn=download_btn)
@@ -0,0 +1,219 @@
"""会员服务:状态查询与开通(叠加/互斥规则对齐《01-开发文档》2.4.3)"""
import random
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config
from app.core import errors
from app.core.response import BizException
from app.models.user import MembershipOrder, UserMembership
# 会员开通记录来源
SOURCE_POINTS = 1 # 积分购买
SOURCE_GIFT = 2 # 活动赠送
PLAN_NAMES = {"trial": "体验会员", "month": "月卡", "year": "年卡", "lifetime": "终身卡"}
# 套餐展示元数据(价格以 config.yaml / 后台配置为准,前端不硬编码)
PLAN_META = {
"trial": {"name": "体验会员", "desc": "1天体验,活动同享"},
"month": {"name": "月卡", "desc": "30天无限观看"},
"year": {"name": "年卡", "desc": "365天无限观看"},
"lifetime": {"name": "终身卡", "desc": "永久无限观看"},
}
PLAN_BENEFITS = ["无限观看视频", "优先上传(转码/审核优先)"]
def gen_order_no(prefix: str = "M") -> str:
"""业务单号:前缀 + 时间戳 + 随机"""
return f"{prefix}{datetime.now():%Y%m%d%H%M%S}{random.randint(100000, 999999)}"
async def get_membership(db: AsyncSession, user_id: int) -> UserMembership:
ms = await db.get(UserMembership, user_id)
if ms is None:
ms = UserMembership(user_id=user_id)
db.add(ms)
await db.flush()
return ms
def membership_active(ms: UserMembership, now: datetime | None = None) -> bool:
"""会员有效性判定(以 expire_at 为准,终身卡永久有效)"""
now = now or datetime.now()
if ms.is_lifetime:
return True
return ms.expire_at is not None and ms.expire_at > now
async def is_member(db: AsyncSession, user_id: int) -> bool:
ms = await db.get(UserMembership, user_id)
return ms is not None and membership_active(ms)
async def grant_membership(
db: AsyncSession,
user_id: int,
plan_code: str,
points_cost: int,
source: int,
days: int | None = None,
) -> MembershipOrder:
"""开通会员并写开通记录,返回订单。
- 有效期内重复开通:有效期叠加(max(now, expire_at) + days)
- 体验会员期间开通付费卡:按付费卡生效,体验剩余时间不累加
- 终身卡不可再开通其它套餐
"""
plan_cfg = (biz_config.get("membership") or {}).get(plan_code)
if not plan_cfg or not plan_cfg.get("enabled", False):
raise BizException(errors.PARAM_ERROR, "套餐不存在或未上架")
duration_days = int(days if days is not None else plan_cfg.get("days", 0))
now = datetime.now()
ms = await get_membership(db, user_id)
if ms.is_lifetime:
raise BizException(errors.LIFETIME_MEMBER)
if duration_days == 0: # 终身卡
start_at, end_at = now, None
else:
base = now
# 付费卡叠加:当前为付费会员且未到期 → 在到期时间上叠加
# 体验会员转付费卡:从当前时间重新计算(体验剩余不累加)
if membership_active(ms, now) and ms.current_plan != "trial" and plan_code != "trial":
base = max(now, ms.expire_at) # type: ignore[arg-type]
start_at = base
end_at = base + timedelta(days=duration_days)
order = MembershipOrder(
order_no=gen_order_no(),
user_id=user_id,
plan_code=plan_code,
points_cost=points_cost,
duration_days=duration_days,
start_at=start_at,
end_at=end_at,
source=source,
)
db.add(order)
ms.is_member = 1
ms.current_plan = plan_code
ms.is_lifetime = 1 if duration_days == 0 else 0
ms.expire_at = end_at
await db.flush()
return order
async def grant_trial_gift(db: AsyncSession, user_id: int, days: int) -> MembershipOrder:
"""新注册赠送体验会员(新用户无既有会员状态,直接设置)"""
now = datetime.now()
end_at = now + timedelta(days=days)
order = MembershipOrder(
order_no=gen_order_no(),
user_id=user_id,
plan_code="trial",
points_cost=0,
duration_days=days,
start_at=now,
end_at=end_at,
source=SOURCE_GIFT,
)
db.add(order)
ms = await get_membership(db, user_id)
ms.is_member = 1
ms.current_plan = "trial"
ms.is_lifetime = 0
ms.expire_at = end_at
await db.flush()
return order
# ---------- 接口查询(第 7 章) ----------
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def list_plans() -> dict:
"""套餐列表(7.1):价格实时读 config.yaml,游客可访问"""
plans_cfg = biz_config.get("membership") or {}
plans = []
for code in ("trial", "month", "year", "lifetime"):
cfg = plans_cfg.get(code) or {}
meta = PLAN_META[code]
plans.append({
"plan_code": code,
"name": meta["name"],
"days": int(cfg.get("days", 0)),
"points": int(cfg.get("points", 0)),
"enabled": bool(cfg.get("enabled", False)),
"desc": meta["desc"],
})
return {"plans": plans, "benefits": PLAN_BENEFITS}
async def get_my_membership(db: AsyncSession, user_id: int) -> dict:
"""我的会员(7.2):当前状态 + 历史开通记录"""
ms = await get_membership(db, user_id)
active = membership_active(ms)
orders = (
await db.execute(
select(MembershipOrder).where(MembershipOrder.user_id == user_id)
.order_by(MembershipOrder.id.desc()).limit(50)
)
).scalars().all()
remaining_days = 0
if active and not ms.is_lifetime and ms.expire_at:
remaining_days = max((ms.expire_at - datetime.now()).days, 0)
return {
"is_member": active,
"is_lifetime": bool(ms.is_lifetime),
"current_plan": ms.current_plan if active else None,
"plan_name": PLAN_NAMES.get(ms.current_plan) if active else None,
"expire_at": _fmt(ms.expire_at) if active else None,
"remaining_days": remaining_days,
"history": [
{
"order_no": o.order_no,
"plan_name": PLAN_NAMES.get(o.plan_code, o.plan_code),
"points_cost": o.points_cost,
"start_at": _fmt(o.start_at),
"end_at": _fmt(o.end_at),
"source": o.source,
}
for o in orders
],
}
async def redeem(db: AsyncSession, user_id: int, plan_code: str) -> dict:
"""积分开通会员(7.3):扣积分 + 开通同一事务;2004 终身卡 / 2001 积分不足"""
from app.services import wallet_service # 避免循环引用
plan_cfg = (biz_config.get("membership") or {}).get(plan_code)
if not plan_cfg or not plan_cfg.get("enabled", False):
raise BizException(errors.PARAM_ERROR, "套餐不存在或未上架")
points_cost = int(plan_cfg.get("points", 0))
# 先开通(内部校验终身卡/叠加规则),再扣积分;积分不足抛 2001 整体回滚
order = await grant_membership(db, user_id, plan_code, points_cost, SOURCE_POINTS)
remaining = await wallet_service.get_balance(db, user_id)
if points_cost > 0:
remaining = await wallet_service.change_points(
db, user_id, -points_cost, wallet_service.BIZ_MEMBERSHIP,
biz_id=order.order_no, remark=f"开通{PLAN_NAMES.get(plan_code, plan_code)}",
)
await db.commit()
return {
"order_no": order.order_no,
"plan_name": PLAN_NAMES.get(plan_code, plan_code),
"points_cost": points_cost,
"remaining_points": remaining,
"expire_at": _fmt(order.end_at),
}
@@ -0,0 +1,255 @@
"""支付服务:充值套餐 / 下单 / 查单 / 取消 / 回调幂等入账 + 推荐返利(对应《03-接口文档》第 8 章)"""
from datetime import datetime, timedelta
from decimal import Decimal
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config
from app.core import errors
from app.core.response import BizException
from app.models.billing import RechargeOrder, RechargePackage
from app.models.incentive import Referral
from app.payment import alipay, usdt, wechat
from app.services import config_service, incentive_service, membership_service, wallet_service
ORDER_STATUS_TEXT = {0: "待支付", 1: "已支付", 2: "已关闭", 3: "已退款"}
PAY_METHODS = [
{"method": 1, "name": "支付宝"},
{"method": 2, "name": "微信支付"},
{"method": 3, "name": "USDT (TRC20)"},
]
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def _points_per_yuan() -> int:
return int((biz_config.get("points") or {}).get("points_per_yuan", 10))
def _usdt_cfg() -> dict:
return (biz_config.get("recharge") or {}).get("usdt") or {}
# ---------- 套餐 / 下单 ----------
async def list_packages(db: AsyncSession, redis: aioredis.Redis) -> dict:
"""充值套餐(8.1):档位读 recharge_packages 表(后台实时可配),自定义金额按汇率换算"""
rows = (await db.execute(
select(RechargePackage).where(RechargePackage.status == 1).order_by(RechargePackage.sort, RechargePackage.id)
)).scalars().all()
custom_enabled = str(await config_service.get_config(db, redis, "recharge.custom.enabled", "1")) == "1"
custom_min = await config_service.get_config_int(db, redis, "recharge.custom.min_yuan", 1)
custom_max = await config_service.get_config_int(db, redis, "recharge.custom.max_yuan", 10000)
return {
"packages": [
{"id": p.id, "amount_yuan": p.amount_yuan, "points": p.points, "bonus": p.bonus_points}
for p in rows
],
"custom": {
"enabled": custom_enabled,
"min_yuan": custom_min,
"max_yuan": custom_max,
"points_per_yuan": _points_per_yuan(),
},
"pay_methods": PAY_METHODS,
"usdt_rate": float(_usdt_cfg().get("rate", 7.2)),
}
async def _resolve_points(db: AsyncSession, redis: aioredis.Redis, amount_yuan: int) -> int:
"""金额 → 到账积分:优先匹配套餐档位(含赠送),否则走自定义金额按汇率换算"""
pkg = (await db.execute(
select(RechargePackage).where(RechargePackage.status == 1, RechargePackage.amount_yuan == amount_yuan)
)).scalar_one_or_none()
if pkg:
return pkg.points + pkg.bonus_points
custom_enabled = str(await config_service.get_config(db, redis, "recharge.custom.enabled", "1")) == "1"
custom_min = await config_service.get_config_int(db, redis, "recharge.custom.min_yuan", 1)
custom_max = await config_service.get_config_int(db, redis, "recharge.custom.max_yuan", 10000)
if not custom_enabled or not (custom_min <= amount_yuan <= custom_max):
raise BizException(errors.PARAM_ERROR, "充值金额不在可选档位")
return amount_yuan * _points_per_yuan()
async def create_recharge(db: AsyncSession, redis: aioredis.Redis, user_id: int, req, platform: str | None = None) -> dict:
"""创建充值订单(8.2):iOS 开关校验 → 档位校验 → 建单 → 按渠道产出支付参数"""
# iOS 虚拟支付合规开关(《01-开发文档》:审核期引导至网页端充值)
if platform == "ios" and str(await config_service.get_config(db, redis, "ios_payment.enabled", "1")) == "0":
raise BizException(errors.PARAM_ERROR, "iOS 端暂不支持应用内充值,请前往网页端充值")
points = await _resolve_points(db, redis, req.amount_yuan)
expire_min = int(_usdt_cfg().get("order_expire_min", 30))
expire_at = datetime.now() + timedelta(minutes=expire_min)
order = RechargeOrder(
order_no=membership_service.gen_order_no("R"),
user_id=user_id,
amount_fen=req.amount_yuan * 100,
points=points,
pay_method=req.pay_method,
expire_at=expire_at,
)
resp: dict = {}
if req.pay_method == 3:
# USDT:金额含随机尾差用于订单匹配,有效期内唯一
rate = Decimal(str(_usdt_cfg().get("rate", 7.2)))
for _ in range(10):
amount = usdt.gen_usdt_amount(req.amount_yuan, rate)
clash = (await db.execute(
select(RechargeOrder.id).where(
RechargeOrder.status == 0, RechargeOrder.usdt_amount == amount
)
)).first()
if not clash:
break
else:
raise BizException(errors.INTERNAL_ERROR, "USDT 金额分配失败,请重试")
order.usdt_amount = amount
order.usdt_rate = rate
db.add(order)
await db.commit()
resp = {
"usdt_rate": float(rate),
"usdt_amount": str(amount),
"receive_address": _usdt_cfg().get("address", ""),
"network": _usdt_cfg().get("network", "TRC20"),
}
else:
db.add(order)
await db.commit()
if req.pay_method == 1:
resp = {"pay_params": alipay.create_app_pay_params(order.order_no, order.amount_fen)}
else:
resp = {"pay_params": await wechat.create_app_pay_params(order.order_no, order.amount_fen)}
return {
"order_no": order.order_no,
"pay_method": order.pay_method,
"amount_yuan": req.amount_yuan,
"points": points,
"expire_at": _fmt(expire_at),
**resp,
}
# ---------- 查单 / 取消 ----------
async def _get_own_order(db: AsyncSession, user_id: int, order_no: str) -> RechargeOrder:
order = (await db.execute(
select(RechargeOrder).where(RechargeOrder.order_no == order_no)
)).scalar_one_or_none()
if order is None or order.user_id != user_id:
raise BizException(errors.ORDER_NOT_FOUND)
return order
async def get_order(db: AsyncSession, user_id: int, order_no: str) -> dict:
"""查询订单(8.3):App 轮询用,附当前钱包积分"""
order = await _get_own_order(db, user_id, order_no)
return {
"order_no": order.order_no,
"status": order.status,
"status_text": ORDER_STATUS_TEXT.get(order.status, ""),
"points": order.points,
"paid_at": _fmt(order.paid_at),
"wallet_points": await wallet_service.get_balance(db, user_id),
}
async def cancel_order(db: AsyncSession, user_id: int, order_no: str) -> None:
"""取消订单(8.4):仅待支付可取消"""
order = await _get_own_order(db, user_id, order_no)
if order.status != 0:
raise BizException(errors.ORDER_STATUS_ERROR, "仅待支付订单可取消")
order.status = 2
await db.commit()
# ---------- 入账(回调 / 人工补单共用) ----------
async def settle_order(
db: AsyncSession, order_no: str, third_trade_no: str, paid_amount_fen: int | None = None
) -> RechargeOrder:
"""幂等入账:行锁 → 状态/金额校验 → 置已支付 → 积分到账 → 推荐返利(事件触发,同步补偿)。
third_trade_no 有唯一索引,第三方支付商重复回调天然幂等。
"""
order = (await db.execute(
select(RechargeOrder).where(RechargeOrder.order_no == order_no).with_for_update()
)).scalar_one_or_none()
if order is None:
raise BizException(errors.ORDER_NOT_FOUND)
if order.status != 0:
return order # 已入账/已关闭:幂等直接返回
if paid_amount_fen is not None and paid_amount_fen != order.amount_fen:
raise BizException(errors.AMOUNT_MISMATCH)
order.status = 1
order.third_trade_no = third_trade_no
order.paid_at = datetime.now()
await wallet_service.change_points(
db, order.user_id, order.points, wallet_service.BIZ_RECHARGE,
biz_id=order.order_no, remark="充值到账",
)
await _rebate_for_order(db, order)
await db.commit()
return order
async def _rebate_for_order(db: AsyncSession, order: RechargeOrder) -> None:
"""推荐返利(《02-技术文档》6.6):被推荐人充值成功 → 推荐人为会员则发放 充值积分×5% 资产"""
referral = (await db.execute(
select(Referral).where(Referral.invitee_id == order.user_id, Referral.status == 1)
)).scalar_one_or_none()
if referral is None:
return
if not await membership_service.is_member(db, referral.inviter_id):
return # 发放时点校验推荐人会员身份
percent = Decimal(str((biz_config.get("referral") or {}).get("rebate_percent", 5)))
rebate = (Decimal(order.points) * percent / 100).quantize(Decimal("0.1"))
if rebate <= 0:
return
await incentive_service.change_assets(
db, referral.inviter_id, rebate, incentive_service.ASSET_BIZ_REBATE,
biz_id=order.order_no, remark="推荐用户充值返利",
)
# ---------- 支付平台回调 ----------
async def handle_alipay_callback(db: AsyncSession, form: dict) -> str:
"""支付宝回调(8.5):RSA2 验签 → 校验单号/金额 → 幂等入账 → 纯文本 success"""
if not alipay.verify_callback(form):
return "failure"
if form.get("trade_status") not in ("TRADE_SUCCESS", "TRADE_FINISHED"):
return "failure"
order_no = form.get("out_trade_no", "")
try:
paid_fen = int(Decimal(form.get("total_amount", "0")) * 100)
await settle_order(db, order_no, form.get("trade_no", ""), paid_fen)
except BizException:
return "failure"
return "success"
async def handle_wechat_callback(db: AsyncSession, headers: dict, body: bytes) -> dict:
"""微信支付回调(8.6):V3 验签 + AES-GCM 解密 → 幂等入账 → {"code": "SUCCESS"}"""
plain = wechat.verify_callback(headers, body)
if not plain:
return {"code": "FAIL", "message": "验签失败"}
if plain.get("trade_state") != "SUCCESS":
return {"code": "FAIL", "message": "非成功状态"}
try:
await settle_order(
db,
plain.get("out_trade_no", ""),
plain.get("transaction_id", ""),
int((plain.get("amount") or {}).get("total", 0)),
)
except BizException as e:
return {"code": "FAIL", "message": e.message}
return {"code": "SUCCESS"}
@@ -0,0 +1,83 @@
"""敏感词服务:DFA 算法,词库 Redis 缓存 60s(后台维护后即时生效,对齐《02-技术文档》6.7)"""
import json
import time
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.common import SensitiveWord
CACHE_KEY = "words:sensitive"
CACHE_TTL = 60
class DFAMatcher:
"""确定性有穷自动机敏感词匹配器"""
def __init__(self) -> None:
self.root: dict = {}
def add(self, word: str) -> None:
node = self.root
for ch in word.strip().lower():
if not ch:
continue
node = node.setdefault(ch, {})
node["\x00"] = True # 词结束标记
def find(self, text: str) -> str | None:
"""返回命中的第一个敏感词,未命中返回 None"""
text = text.lower()
n = len(text)
for i in range(n):
node = self.root
j = i
while j < n and text[j] in node:
node = node[text[j]]
j += 1
if "\x00" in node:
return text[i:j]
return None
# 进程内缓存(matcher, 构建时间戳)
_local_cache: tuple[DFAMatcher, float] | None = None
async def get_matcher(db: AsyncSession, redis: aioredis.Redis) -> DFAMatcher:
"""获取 DFA 匹配器:进程缓存 → Redis → DB,60s 重建"""
global _local_cache
now = time.time()
if _local_cache and now - _local_cache[1] < CACHE_TTL:
return _local_cache[0]
words: list[str] = []
cached = await redis.get(CACHE_KEY)
if cached:
words = json.loads(cached)
else:
rows = (await db.execute(select(SensitiveWord.word))).scalars().all()
words = [w for w in rows if w]
await redis.set(CACHE_KEY, json.dumps(words, ensure_ascii=False), ex=CACHE_TTL)
matcher = DFAMatcher()
for w in words:
matcher.add(w)
_local_cache = (matcher, now)
return matcher
async def find_sensitive(db: AsyncSession, redis: aioredis.Redis, text: str) -> str | None:
"""检查文本,返回命中的敏感词或 None"""
if not text:
return None
matcher = await get_matcher(db, redis)
return matcher.find(text)
async def invalidate_cache(redis: aioredis.Redis) -> None:
"""后台增删词库后调用:清 Redis 缓存,进程缓存等 60s 内自然过期"""
global _local_cache
_local_cache = None
await redis.delete(CACHE_KEY)
@@ -0,0 +1,166 @@
"""结算服务:周期任务(推荐激活 / 视频创作收益 / 订单超时关单)
由 main.py lifespan 内的 asyncio 周期协程驱动(开发环境单进程够用);
各结算函数均为幂等扫描,测试可直接调用。
"""
import asyncio
import logging
from datetime import datetime, timedelta
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config
from app.database import SessionFactory
from app.models.billing import RechargeOrder, WatchRecord
from app.models.incentive import Referral, VideoEarning
from app.models.user import User
from app.services import incentive_service, membership_service, wallet_service
logger = logging.getLogger(__name__)
# 单轮扫描批量上限
BATCH_SIZE = 500
# 周期间隔(秒)
INTERVAL_REFERRAL = 600 # 推荐激活结算 10min
INTERVAL_EARNINGS = 300 # 创作收益结算 5min
INTERVAL_ORDER_CLOSE = 60 # 订单超时关单 1min
def _referral_cfg() -> dict:
return biz_config.get("referral") or {}
def _asset_cfg() -> dict:
return biz_config.get("asset") or {}
# ---------- 推荐激活结算 ----------
async def settle_referral_activations(db: AsyncSession) -> dict:
"""待激活推荐关系结算(《01-开发文档》2.8:72h 内观看≥N 且次日登录 → 有效;超期 → 无效)。
激活时点校验推荐人会员身份,会员才发放注册奖励积分(point biz_type=8)。
"""
cfg = _referral_cfg()
hours = int(cfg.get("activate_hours", 72))
watch_count = int(cfg.get("activate_watch_count", 3))
reward = int(cfg.get("register_reward_points", 10))
now = datetime.now()
rows = (await db.execute(
select(Referral).where(Referral.status == 0).order_by(Referral.id).limit(BATCH_SIZE)
)).scalars().all()
activated = expired = 0
for r in rows:
if r.created_at + timedelta(hours=hours) < now:
r.status = 2 # 超期未达标
expired += 1
continue
watches = (await db.execute(
select(func.count(WatchRecord.id)).where(
WatchRecord.subject_type == 1, WatchRecord.subject_id == str(r.invitee_id)
)
)).scalar() or 0
if watches < watch_count:
continue
invitee = await db.get(User, r.invitee_id)
next_day_login = bool(
invitee and invitee.last_login_at and invitee.last_login_at.date() > r.created_at.date()
)
if not next_day_login:
continue
r.status = 1
r.activated_at = now
activated += 1
if reward > 0 and await membership_service.is_member(db, r.inviter_id):
await wallet_service.change_points(
db, r.inviter_id, reward, wallet_service.BIZ_REFERRAL,
biz_id=str(r.id), remark="推荐新用户激活奖励",
)
await db.commit()
return {"scanned": len(rows), "activated": activated, "expired": expired}
# ---------- 视频创作收益结算 ----------
async def settle_video_earnings(db: AsyncSession) -> dict:
"""创作收益按增量结算:完整观看每满 1000 发 1 资产、点赞每满 100 发 1 资产(余数保留)。
结算时点校验作者会员身份:非会员只保留计数,不推进结算刻度。
"""
cfg = _asset_cfg()
views_per = int(cfg.get("complete_views_per_asset", 1000))
likes_per = int(cfg.get("likes_per_asset", 100))
rows = (await db.execute(
select(VideoEarning).order_by(VideoEarning.video_id).limit(BATCH_SIZE)
)).scalars().all()
settled_videos = 0
granted = Decimal(0)
for e in rows:
if not await membership_service.is_member(db, e.author_id):
continue
view_assets = (e.complete_views - e.views_settled) // views_per if views_per > 0 else 0
like_assets = (e.likes_accrued - e.likes_settled) // likes_per if likes_per > 0 else 0
if view_assets <= 0 and like_assets <= 0:
continue
if view_assets > 0:
await incentive_service.change_assets(
db, e.author_id, Decimal(view_assets), incentive_service.ASSET_BIZ_COMPLETE,
biz_id=str(e.video_id), remark="完整观看收益",
)
e.views_settled += view_assets * views_per
if like_assets > 0:
await incentive_service.change_assets(
db, e.author_id, Decimal(like_assets), incentive_service.ASSET_BIZ_LIKE,
biz_id=str(e.video_id), remark="点赞收益",
)
e.likes_settled += like_assets * likes_per
settled_videos += 1
granted += view_assets + like_assets
await db.commit()
return {"scanned": len(rows), "settled_videos": settled_videos, "granted_assets": float(granted)}
# ---------- 订单超时关单 ----------
async def close_expired_orders(db: AsyncSession) -> dict:
"""待支付订单超过 expire_at → 已关闭"""
rows = (await db.execute(
select(RechargeOrder)
.where(RechargeOrder.status == 0, RechargeOrder.expire_at < datetime.now())
.order_by(RechargeOrder.id)
.limit(BATCH_SIZE)
)).scalars().all()
for o in rows:
o.status = 2
await db.commit()
return {"closed": len(rows)}
# ---------- 周期协程(lifespan 挂载) ----------
async def _run_loop(name: str, fn, interval: int) -> None:
while True:
try:
async with SessionFactory() as db:
result = await fn(db)
if result:
logger.info("周期任务[%s]完成: %s", name, result)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("周期任务[%s]执行失败", name)
await asyncio.sleep(interval)
def start_periodic_tasks() -> list[asyncio.Task]:
"""启动全部周期任务,返回 Task 列表(lifespan 退出时取消)"""
return [
asyncio.create_task(_run_loop("推荐激活结算", settle_referral_activations, INTERVAL_REFERRAL)),
asyncio.create_task(_run_loop("创作收益结算", settle_video_earnings, INTERVAL_EARNINGS)),
asyncio.create_task(_run_loop("订单超时关单", close_expired_orders, INTERVAL_ORDER_CLOSE)),
]
@@ -0,0 +1,68 @@
"""本地文件存储(开发环境):头像等小文件与上传视频存 server/uploads,经 /static 访问。
生产切换 OSS 时仅需替换本模块实现,调用方不变。
"""
import time
from fastapi import UploadFile
from app.config import BASE_DIR
from app.core import errors
from app.core.response import BizException
UPLOAD_DIR = BASE_DIR / "uploads"
MAX_AVATAR_SIZE = 5 * 1024 * 1024 # 5MB
# 允许上传的视频扩展名
VIDEO_EXTS = ("mp4", "mov", "m4v", "avi", "mkv", "webm")
# 上传视频大小上限(与 config.yaml upload.max_size_mb 对齐)
MAX_VIDEO_SIZE = 500 * 1024 * 1024 # 500MB
def video_ext_of(filename: str | None) -> str:
return (filename or "").rsplit(".", 1)[-1].lower() if filename and "." in filename else ""
async def save_avatar(file: UploadFile, user_id: int) -> str:
"""保存头像,返回可访问的 URL 路径"""
ext = (file.filename or "").rsplit(".", 1)[-1].lower()
if ext not in ("jpg", "jpeg", "png"):
raise BizException(errors.PARAM_ERROR, "仅支持 jpg/png 图片")
data = await file.read()
if not data:
raise BizException(errors.PARAM_ERROR, "文件内容为空")
if len(data) > MAX_AVATAR_SIZE:
raise BizException(errors.PARAM_ERROR, "图片不能超过 5MB")
(UPLOAD_DIR / "avatars").mkdir(parents=True, exist_ok=True)
name = f"{user_id}_{int(time.time())}.{ext}"
(UPLOAD_DIR / "avatars" / name).write_bytes(data)
return f"/static/avatars/{name}"
def validate_object_key(object_key: str) -> str:
"""校验上传凭证下发的 object_key 格式,防路径穿越;返回扩展名"""
if (
not object_key.startswith("upload/tmp/")
or ".." in object_key
or "\\" in object_key
or len(object_key) > 300
):
raise BizException(errors.PARAM_ERROR, "object_key 无效")
ext = video_ext_of(object_key)
if ext not in VIDEO_EXTS:
raise BizException(errors.PARAM_ERROR, "object_key 无效")
return ext
async def save_video(file: UploadFile, object_key: str) -> tuple[str, int]:
"""按 object_key 保存上传的视频文件,返回 (可访问 URL 路径, 文件字节数)"""
validate_object_key(object_key)
data = await file.read()
if not data:
raise BizException(errors.PARAM_ERROR, "文件内容为空")
if len(data) > MAX_VIDEO_SIZE:
raise BizException(errors.PARAM_ERROR, f"视频不能超过 {MAX_VIDEO_SIZE // 1024 // 1024}MB")
path = UPLOAD_DIR / object_key
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return f"/static/{object_key}", len(data)
@@ -0,0 +1,192 @@
"""用户服务:资料 / 钱包 / 积分明细 / 注销 / 公开主页(对应《03-接口文档》第 4 章)"""
from datetime import datetime, timedelta
import redis.asyncio as aioredis
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config
from app.core import errors
from app.core.response import BizException
from app.core.security import verify_password
from app.models.billing import PointTransaction
from app.models.user import User
from app.models.video import Video
from app.services import membership_service, sensitive_service, video_service, wallet_service
DEACTIVATE_FREEZE_DAYS = 7
def _mask_phone(phone: str | None) -> str | None:
"""手机号脱敏:138****8000"""
if not phone or len(phone) < 7:
return phone
return f"{phone[:3]}****{phone[-4:]}"
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
async def _get_user(db: AsyncSession, user_id: int) -> User:
user = await db.get(User, user_id)
if user is None:
raise BizException(errors.UNAUTHORIZED, http_status=401)
return user
async def _me_payload(db: AsyncSession, user: User) -> dict:
"""个人信息完整结构(4.1/4.2 共用)"""
ms = await membership_service.get_membership(db, user.id)
active = membership_service.membership_active(ms)
points = await wallet_service.get_balance(db, user.id)
return {
"user_id": user.id,
"phone": _mask_phone(user.phone),
"email": user.email,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"bio": user.bio,
"gender": user.gender,
"is_member": active,
"member_plan": ms.current_plan if active else None,
"member_expire_at": _fmt(ms.expire_at) if active else None,
"points": points,
}
async def get_me(db: AsyncSession, user_id: int) -> dict:
return await _me_payload(db, await _get_user(db, user_id))
async def update_me(db: AsyncSession, redis: aioredis.Redis, user_id: int, req) -> dict:
"""修改资料:昵称/简介过敏感词"""
user = await _get_user(db, user_id)
if req.nickname is not None:
if await sensitive_service.find_sensitive(db, redis, req.nickname):
raise BizException(errors.SENSITIVE_WORD)
user.nickname = req.nickname
if req.bio is not None:
if await sensitive_service.find_sensitive(db, redis, req.bio):
raise BizException(errors.SENSITIVE_WORD)
user.bio = req.bio
if req.gender is not None:
user.gender = req.gender
await db.commit()
return await _me_payload(db, user)
async def set_avatar(db: AsyncSession, user_id: int, avatar_url: str) -> dict:
user = await _get_user(db, user_id)
user.avatar_url = avatar_url
await db.commit()
return {"avatar_url": avatar_url}
# ---------- 钱包 / 积分明细 ----------
async def get_wallet(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> dict:
wallet = await wallet_service.get_or_create_wallet(db, user_id)
# 评论奖励进度(Redis Hash:count 进度 / daily 当日已发 / date 当日日期)
prog = await redis.hgetall(f"comment:prog:{user_id}") # type: ignore[misc]
reward_cfg = (biz_config.get("points") or {}).get("comment_reward") or {}
today = f"{datetime.now():%Y%m%d}"
return {
"points": wallet.points,
"total_recharge": wallet.total_recharge,
"total_earned": wallet.total_earned,
"total_consumed": wallet.total_consumed,
"comment_reward_progress": {
"current": int(prog.get("count", 0)),
"target": int(reward_cfg.get("per_count", 10)),
"today_rewarded": int(prog.get("daily", 0)) if prog.get("date") == today else 0,
"daily_limit": int(reward_cfg.get("daily_limit", 5)),
},
}
async def get_points_records(
db: AsyncSession, user_id: int, page: int, page_size: int, biz_type: int | None
) -> dict:
stmt = select(PointTransaction).where(PointTransaction.user_id == user_id)
if biz_type is not None:
stmt = stmt.where(PointTransaction.biz_type == biz_type)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = (
await db.execute(
stmt.order_by(PointTransaction.id.desc()).offset((page - 1) * page_size).limit(page_size)
)
).scalars().all()
return {
"total": total,
"list": [
{
"id": r.id,
"change_amount": r.change_amount,
"balance_after": r.balance_after,
"biz_type": r.biz_type,
"remark": r.remark,
"created_at": _fmt(r.created_at),
}
for r in rows
],
}
# ---------- 注销 ----------
async def deactivate(db: AsyncSession, redis: aioredis.Redis, user_id: int, password: str) -> dict:
"""申请注销:密码二次确认 → 进入 7 天冻结期 → 全端 Token 立即失效。
冻结期内登录返回 1009 + can_revoke(见 auth_service.login);到期匿名化由定时任务执行。
"""
user = await _get_user(db, user_id)
if not verify_password(password, user.password_hash):
raise BizException(errors.PARAM_ERROR, "密码错误")
if user.deactivate_at is not None:
raise BizException(errors.PARAM_ERROR, "账号已在注销冻结期内")
now = datetime.now()
user.deactivate_at = now
# 全端 Token 失效:递增令牌纪元 + 清 refresh 白名单
await redis.incr(f"auth:epoch:{user_id}")
await redis.delete(f"auth:refresh:{user_id}")
await db.commit()
revoke_before = now + timedelta(days=DEACTIVATE_FREEZE_DAYS)
return {"deactivate_at": _fmt(now), "revoke_before": _fmt(revoke_before)}
# ---------- 公开主页 ----------
async def get_public_profile(db: AsyncSession, user_id: int) -> dict:
user = await db.get(User, user_id)
if user is None or user.status != 1:
raise BizException(errors.NOT_FOUND, "用户不存在")
base = select(Video).where(Video.user_id == user_id, Video.status == video_service.STATUS_PUBLISHED)
video_count = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar() or 0
total_likes = (await db.execute(select(func.coalesce(func.sum(Video.like_count), 0)).where(
Video.user_id == user_id, Video.status == video_service.STATUS_PUBLISHED
))).scalar() or 0
return {
"user_id": user.id,
"nickname": user.nickname,
"avatar_url": user.avatar_url,
"bio": user.bio,
"video_count": video_count,
"total_likes": int(total_likes),
}
async def get_user_videos(
db: AsyncSession, user_id: int, page: int, page_size: int, viewer_user_id: int | None
) -> dict:
"""他人作品列表:仅已发布"""
stmt = select(Video).where(Video.user_id == user_id, Video.status == video_service.STATUS_PUBLISHED)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
videos = (
await db.execute(
stmt.order_by(Video.published_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
).scalars().all()
return {"total": total, "list": await video_service.serialize_videos(db, list(videos), viewer_user_id)}
@@ -0,0 +1,739 @@
"""视频服务:Feed / 详情 / 观看计费 / 完整观看(对应《03-接口文档》第 5 章、《02-技术文档》6.4)"""
import base64
import hashlib
import json
import random
import uuid
from datetime import date, datetime, timedelta
import redis.asyncio as aioredis
from fastapi import UploadFile
from sqlalchemy import func, select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import biz_config
from app.core import errors, ratelimit
from app.core.response import BizException
from app.deps import Identity
from app.models.billing import WatchRecord
from app.models.incentive import VideoEarning
from app.models.interaction import Favorite, Like
from app.models.user import User
from app.models.video import Category, Tag, Video, VideoTag
from app.schemas.video import PublishVideoReq, UploadTokenReq
from app.services import membership_service, sensitive_service, storage_service, wallet_service
STATUS_PUBLISHED = 2
STATUS_TEXT = {0: "转码中", 1: "待审核", 2: "已发布", 3: "拒绝", 4: "下架", 5: "转码失败"}
# 观看上报限流:60 次/分钟/计费主体
WATCH_RATE_LIMIT = 60
# 免费额度 Set / 已看 Set 过期时间 48h
SET_TTL = 48 * 3600
# 推荐候选池上限
FEED_POOL_SIZE = 500
# 视频详情缓存 5min
DETAIL_CACHE_TTL = 300
# 免费额度 + 去重 Lua 原子脚本(《02-技术文档》3.4-2)
FREE_QUOTA_LUA = """
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then return 1 end
local n = redis.call('SCARD', KEYS[1])
if n < tonumber(ARGV[2]) then redis.call('SADD', KEYS[1], ARGV[1]) redis.call('EXPIRE', KEYS[1], ARGV[3]) return 2 end
return 0
"""
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None
def _points_cfg() -> dict:
return biz_config.get("points") or {}
# ---------- Feed 单条序列化 ----------
def _author_brief(user: User | None) -> dict:
return {
"user_id": user.id if user else 0,
"nickname": user.nickname if user else "",
"avatar_url": user.avatar_url if user else "",
}
async def serialize_videos(
db: AsyncSession, videos: list[Video], viewer_user_id: int | None = None
) -> list[dict]:
"""Feed 单条结构(对齐《03-接口文档》5.1):批量取作者/标签/登录用户互动状态"""
if not videos:
return []
video_ids = [v.id for v in videos]
authors = {
u.id: u
for u in (await db.execute(select(User).where(User.id.in_({v.user_id for v in videos})))).scalars()
}
tags_map = await get_tags_map(db, video_ids)
liked: set[int] = set()
favorited: set[int] = set()
if viewer_user_id:
liked = set(
(await db.execute(
select(Like.video_id).where(Like.user_id == viewer_user_id, Like.video_id.in_(video_ids))
)).scalars()
)
favorited = set(
(await db.execute(
select(Favorite.video_id).where(Favorite.user_id == viewer_user_id, Favorite.video_id.in_(video_ids))
)).scalars()
)
return [
{
"video_id": v.id,
"title": v.title,
"cover_url": v.cover_url,
"video_url": v.video_url,
"duration": v.duration,
"category_id": v.category_id,
"author": _author_brief(authors.get(v.user_id)),
"view_count": v.view_count,
"like_count": v.like_count,
"comment_count": v.comment_count,
"favorite_count": v.favorite_count,
"tags": tags_map.get(v.id, []),
"is_liked": v.id in liked,
"is_favorited": v.id in favorited,
}
for v in videos
]
async def get_tags_map(db: AsyncSession, video_ids: list[int]) -> dict[int, list[str]]:
if not video_ids:
return {}
rows = await db.execute(
select(VideoTag.video_id, Tag.name)
.join(Tag, Tag.id == VideoTag.tag_id)
.where(VideoTag.video_id.in_(video_ids))
)
tags_map: dict[int, list[str]] = {}
for vid, name in rows.all():
tags_map.setdefault(vid, []).append(name)
return tags_map
async def get_tags_of_video(db: AsyncSession, video_id: int) -> list[str]:
return (await get_tags_map(db, [video_id])).get(video_id, [])
# ---------- 游标 ----------
def _encode_cursor(payload: dict) -> str:
return base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode()
def _decode_cursor(cursor: str | None) -> dict:
if not cursor:
return {}
try:
return json.loads(base64.urlsafe_b64decode(cursor.encode()))
except Exception:
raise BizException(errors.PARAM_ERROR, "cursor 无效")
# ---------- Feed ----------
async def get_feed(
db: AsyncSession,
redis: aioredis.Redis,
identity: Identity,
feed_type: str,
cursor: str | None,
limit: int,
category_id: int | None,
) -> dict:
if feed_type == "latest":
return await _feed_latest(db, identity, cursor, limit, category_id)
return await _feed_scored(db, redis, identity, feed_type, cursor, limit, category_id)
async def _base_pool_stmt(category_id: int | None):
stmt = select(Video).where(Video.status == STATUS_PUBLISHED, Video.published_at.isnot(None))
if category_id:
stmt = stmt.where(Video.category_id == category_id)
return stmt
async def _feed_latest(
db: AsyncSession, identity: Identity, cursor: str | None, limit: int, category_id: int | None
) -> dict:
"""latest:published_at 游标倒序(ts + id 打破并列)"""
stmt = await _base_pool_stmt(category_id)
c = _decode_cursor(cursor)
if c:
ts, vid = datetime.fromtimestamp(c["p"]), int(c["i"])
stmt = stmt.where((Video.published_at < ts) | ((Video.published_at == ts) & (Video.id < vid)))
rows = list(
(await db.execute(stmt.order_by(Video.published_at.desc(), Video.id.desc()).limit(limit + 1))).scalars()
)
page, rows = (len(rows) > limit), rows[:limit]
next_cursor = None
if page and rows:
last = rows[-1]
next_cursor = _encode_cursor({"p": int(last.published_at.timestamp()), "i": last.id})
return {
"next_cursor": next_cursor,
"list": await serialize_videos(db, rows, identity.user_id),
}
def _hot_score(v: Video, now: datetime) -> float:
"""热度分:view×1 + like×5 + comment×10,随发布天数衰减"""
age_days = max((now - (v.published_at or now)).days, 0)
base = v.view_count + v.like_count * 5 + v.comment_count * 10
return base / (1 + age_days)
async def _user_tag_prefs(db: AsyncSession, user_id: int) -> dict[str, float]:
"""近 7 天观看/点赞/收藏视频的标签偏好(观看+1 点赞+3 收藏+5);无偏好返回空"""
since = datetime.now() - timedelta(days=7)
video_ids: list[tuple[int, float]] = []
watched = (await db.execute(
select(WatchRecord.video_id).where(
WatchRecord.subject_type == 1, WatchRecord.subject_id == str(user_id), WatchRecord.created_at > since
)
)).scalars().all()
video_ids += [(vid, 1.0) for vid in watched]
liked = (await db.execute(select(Like.video_id).where(Like.user_id == user_id))).scalars().all()
video_ids += [(vid, 3.0) for vid in liked]
fav = (await db.execute(select(Favorite.video_id).where(Favorite.user_id == user_id))).scalars().all()
video_ids += [(vid, 5.0) for vid in fav]
if not video_ids:
return {}
tags_map = await get_tags_map(db, list({vid for vid, _ in video_ids}))
prefs: dict[str, float] = {}
for vid, w in video_ids:
for tag in tags_map.get(vid, []):
prefs[tag] = prefs.get(tag, 0.0) + w
return prefs
async def _feed_scored(
db: AsyncSession,
redis: aioredis.Redis,
identity: Identity,
feed_type: str,
cursor: str | None,
limit: int,
category_id: int | None,
) -> dict:
"""recommend / hot:候选池打分 → 当日种子确定性加权抽样 → 当日去重(不足兜底)→ 偏移游标"""
now = datetime.now()
today = f"{now:%Y%m%d}"
subject = identity.subject or "anon"
# 1. 候选池:近 30 天已发布,封顶 FEED_POOL_SIZE
stmt = (await _base_pool_stmt(category_id)).where(
Video.published_at > now - timedelta(days=30)
).order_by(Video.published_at.desc()).limit(FEED_POOL_SIZE)
pool = list((await db.execute(stmt)).scalars())
if not pool:
return {"next_cursor": None, "list": []}
# 2. 打分
scores: dict[int, float] = {}
if feed_type == "recommend" and identity.user_id:
prefs = await _user_tag_prefs(db, identity.user_id)
else:
prefs = {}
tags_map = await get_tags_map(db, [v.id for v in pool])
for v in pool:
hot = _hot_score(v, now)
if feed_type == "recommend" and prefs:
# 命中偏好标签加权,未命中取热度分
pref = sum(prefs.get(t, 0.0) for t in tags_map.get(v.id, []))
scores[v.id] = pref * 1000 + hot if pref > 0 else hot
else:
# hot 策略 / 游客 / 新用户:热度分
scores[v.id] = hot
# 3. 排序:hot = 热度分严格倒序;recommend = 当日种子确定性加权抽样(每日重排,同日稳定)
if feed_type == "hot":
ordered = sorted(pool, key=lambda v: (scores[v.id], v.id), reverse=True)
else:
seed = int(hashlib.md5(f"{today}{subject}".encode()).hexdigest()[:8], 16)
rng = random.Random(seed)
ordered = sorted(pool, key=lambda v: rng.random() ** (1.0 / max(scores[v.id], 1e-6)), reverse=True)
# 4. 当日去重(观看上报写入 watched Set);候选不足时放开过滤兜底
watched = await redis.smembers(f"watched:{subject}:{today}") # type: ignore[misc]
filtered = [v for v in ordered if str(v.id) not in watched]
offset = int(_decode_cursor(cursor).get("o", 0))
items = filtered if len(filtered) > offset else ordered
page_items = items[offset: offset + limit]
next_cursor = _encode_cursor({"o": offset + limit}) if len(items) > offset + limit else None
return {
"next_cursor": next_cursor,
"list": await serialize_videos(db, page_items, identity.user_id),
}
# ---------- 视频详情 ----------
async def get_detail(db: AsyncSession, redis: aioredis.Redis, identity: Identity, video_id: int) -> dict:
"""详情:基础数据缓存 5min(中性,不含观看者态);登录用户叠加 my_position/互动状态"""
cache_key = f"video:detail:{video_id}"
cached = await redis.get(cache_key)
if cached:
data = json.loads(cached)
else:
video = await db.get(Video, video_id)
if video is None or video.status != STATUS_PUBLISHED:
raise BizException(errors.VIDEO_NOT_AVAILABLE)
base = (await serialize_videos(db, [video]))[0]
category = await db.get(Category, video.category_id) if video.category_id else None
base.update({
"description": video.description,
"category": {"category_id": category.id, "name": category.name} if category else None,
"published_at": _fmt(video.published_at),
})
data = base
await redis.set(cache_key, json.dumps(data, ensure_ascii=False), ex=DETAIL_CACHE_TTL)
data = dict(data)
data["my_position"] = 0
if identity.user_id:
# 登录用户:互动状态 + 续播进度(最近一次观看记录)
video_id_int = int(data["video_id"])
data["is_liked"] = bool((await db.execute(
select(Like.id).where(Like.user_id == identity.user_id, Like.video_id == video_id_int)
)).first())
data["is_favorited"] = bool((await db.execute(
select(Favorite.id).where(Favorite.user_id == identity.user_id, Favorite.video_id == video_id_int)
)).first())
last = (await db.execute(
select(WatchRecord.position).where(
WatchRecord.subject_type == 1, WatchRecord.subject_id == str(identity.user_id),
WatchRecord.video_id == video_id_int,
).order_by(WatchRecord.id.desc()).limit(1)
)).scalar()
data["my_position"] = int(last or 0)
return data
async def invalidate_detail_cache(redis: aioredis.Redis, video_id: int) -> None:
"""状态变更(编辑/上下架/审核)时失效详情缓存"""
await redis.delete(f"video:detail:{video_id}")
# ---------- 观看上报(计费) ----------
async def _get_published_video(db: AsyncSession, video_id: int) -> Video:
video = await db.get(Video, video_id)
if video is None or video.status != STATUS_PUBLISHED:
raise BizException(errors.VIDEO_NOT_AVAILABLE)
return video
async def report_watch(
db: AsyncSession, redis: aioredis.Redis, identity: Identity, video_id: int, position: int
) -> dict:
"""计费判定链:同日去重 → 会员 → 本人 → 免费额度 Lua → 游客拦截 → 扣积分"""
video = await _get_published_video(db, video_id)
if not identity.subject:
raise BizException(errors.PARAM_ERROR, "缺少 X-Device-ID")
today = date.today()
ymd = f"{today:%Y%m%d}"
subject = identity.subject
watched_key = f"watched:{subject}:{ymd}"
await ratelimit.hit(redis, f"rl:watch:{subject}:{datetime.now():%Y%m%d%H%M}", WATCH_RATE_LIMIT, 60)
# 1. 同日去重:重复观看不计费,仅更新续播进度
if await redis.sismember(watched_key, str(video_id)): # type: ignore[misc]
await _upsert_watch_record(db, identity, video_id, today, 3, 0, position)
await db.commit()
return await _watch_resp(db, redis, identity, 3, 0, ymd)
is_author = bool(identity.user_id and video.user_id == identity.user_id)
cost_type, cost_points = 3, 0
if is_author:
pass # 本人作品不计费
elif identity.user_id and await membership_service.is_member(db, identity.user_id):
cost_type = 2 # 会员免费
else:
# 2. 免费额度(Lua 原子:去重 + 计数 + 占位)
free_limit = int(_points_cfg().get("daily_free_count", 10))
free_key = f"free:watch:{subject}:{ymd}"
res = await redis.eval(FREE_QUOTA_LUA, 1, free_key, str(video_id), str(free_limit), str(SET_TTL)) # type: ignore[misc]
if res == 2:
cost_type = 0 # 占用免费额度
elif res == 1:
cost_type = 3 # 并发下已在免费集合 → 按重复处理
else:
# 3. 额度用尽:游客拦截 / 用户扣积分
if identity.is_guest:
raise BizException(errors.FREE_LIMIT_EXCEEDED, "今日免费观看次数已用完,登录后继续观看",
data={"need_login": True})
cost_type = 1
cost_points = int(_points_cfg().get("watch_cost", 1))
try:
await wallet_service.change_points(
db, identity.user_id, -cost_points, wallet_service.BIZ_WATCH,
biz_id=str(video_id), remark="观看视频",
)
except BizException as e:
if e.code == errors.POINTS_NOT_ENOUGH:
# 文档 5.3:积分不足响应 data 带当前积分
e.data = {"points": await wallet_service.get_balance(db, identity.user_id)}
raise
first_today = await _upsert_watch_record(db, identity, video_id, today, cost_type, cost_points, position)
if first_today:
video.view_count += 1
await redis.sadd(watched_key, str(video_id)) # type: ignore[misc]
await redis.expire(watched_key, SET_TTL)
await db.commit()
return await _watch_resp(db, redis, identity, cost_type, cost_points, ymd)
async def _upsert_watch_record(
db: AsyncSession, identity: Identity, video_id: int, watch_date: date,
cost_type: int, cost_points: int, position: int, watch_duration: int = 0,
) -> bool:
"""按 uk_dedup 天然键幂等写入;返回是否当日首次(用于播放量与收益计数)"""
insert_stmt = mysql_insert(WatchRecord).values(
subject_type=1 if identity.user_id else 2,
subject_id=identity.subject,
video_id=video_id,
watch_date=watch_date,
cost_type=cost_type,
cost_points=cost_points,
position=position,
watch_duration=watch_duration,
)
stmt = insert_stmt.on_duplicate_key_update(
position=insert_stmt.inserted.position,
watch_duration=insert_stmt.inserted.watch_duration,
)
result = await db.execute(stmt)
await db.flush()
return result.rowcount == 1
async def _watch_resp(db: AsyncSession, redis: aioredis.Redis, identity: Identity,
cost_type: int, cost_points: int, ymd: str) -> dict:
free_limit = int(_points_cfg().get("daily_free_count", 10))
used = await redis.scard(f"free:watch:{identity.subject}:{ymd}") # type: ignore[misc]
remaining_points = await wallet_service.get_balance(db, identity.user_id) if identity.user_id else 0
return {
"cost_type": cost_type,
"cost_points": cost_points,
"remaining_points": remaining_points,
"free_remaining_today": max(free_limit - int(used or 0), 0),
}
# ---------- 完整观看上报 ----------
async def report_complete(
db: AsyncSession, redis: aioredis.Redis, identity: Identity, video_id: int, watch_duration: int
) -> dict:
"""完整观看:本人/当日重复/时长明显不足 → credited=false;有效则累计 video_earnings"""
video = await _get_published_video(db, video_id)
if not identity.subject:
raise BizException(errors.PARAM_ERROR, "缺少 X-Device-ID")
credited = False
is_author = bool(identity.user_id and video.user_id == identity.user_id)
# 时长合理性:不得明显低于视频时长(低于 50% 视为异常)
duration_ok = video.duration <= 0 or watch_duration >= video.duration * 0.5
if not is_author and duration_ok and watch_duration > 0:
ymd = f"{datetime.now():%Y%m%d}"
dedup_key = f"cview:{identity.subject}:{video_id}:{ymd}"
if await redis.set(dedup_key, 1, nx=True, ex=SET_TTL):
stmt = mysql_insert(VideoEarning).values(
video_id=video_id, author_id=video.user_id, complete_views=1
).on_duplicate_key_update(complete_views=VideoEarning.complete_views + 1)
await db.execute(stmt)
credited = True
# 记录实际观看时长(续播记录复用)
await _upsert_watch_record(db, identity, video_id, date.today(), 3, 0, 0, watch_duration)
await db.commit()
return {"credited": credited}
# ---------- 分类 ----------
async def list_categories(db: AsyncSession) -> list[dict]:
rows = (await db.execute(
select(Category).where(Category.status == 1).order_by(Category.sort, Category.id)
)).scalars().all()
return [{"category_id": c.id, "name": c.name} for c in rows]
# ---------- 上传发布(5.5 / 5.6 / 5.7) ----------
# 上传凭证(object_key 占位)有效期 24h
UPLOAD_TOKEN_TTL = 24 * 3600
def _upload_cfg() -> dict:
return biz_config.get("upload") or {}
async def get_upload_token(redis: aioredis.Redis, identity: Identity, req: UploadTokenReq) -> dict:
"""5.5 获取上传凭证:开发环境无 OSS,返回 server 中转模式,客户端改走 upload/file"""
ext = storage_service.video_ext_of(req.file_name)
if ext not in storage_service.VIDEO_EXTS:
raise BizException(errors.PARAM_ERROR, "仅支持 mp4/mov/m4v/avi/mkv/webm 格式")
max_mb = int(_upload_cfg().get("max_size_mb", 500))
if req.file_size > max_mb * 1024 * 1024:
raise BizException(errors.PARAM_ERROR, f"文件不能超过 {max_mb}MB")
object_key = f"upload/tmp/{datetime.now():%Y%m%d}/{uuid.uuid4().hex}.{ext}"
# 占位登记:upload/file 与发布时校验归属与有效期
await redis.set(
f"upload:obj:{object_key}", json.dumps({"uid": identity.user_id, "url": ""}), ex=UPLOAD_TOKEN_TTL
)
return {"upload_type": "server", "object_key": object_key, "upload_url": "/videos/upload/file"}
async def _load_pending_upload(redis: aioredis.Redis, object_key: str, user_id: int) -> dict:
raw = await redis.get(f"upload:obj:{object_key}")
pending = json.loads(raw) if raw else None
if not pending or pending.get("uid") != user_id:
raise BizException(errors.PARAM_ERROR, "object_key 无效或已过期")
return pending
async def register_upload(
redis: aioredis.Redis, identity: Identity, object_key: str, file: UploadFile
) -> dict:
"""upload/token 配套:multipart 中转上传,保存到本地并登记可访问 url"""
storage_service.validate_object_key(object_key)
await _load_pending_upload(redis, object_key, identity.user_id)
url, size = await storage_service.save_video(file, object_key)
await redis.set(
f"upload:obj:{object_key}",
json.dumps({"uid": identity.user_id, "url": url, "file_size": size}),
ex=UPLOAD_TOKEN_TTL,
)
return {"object_key": object_key, "url": url, "file_size": size}
async def publish_video(
db: AsyncSession, redis: aioredis.Redis, identity: Identity, req: PublishVideoReq
) -> dict:
"""5.6 发布:敏感词过滤 → 分类/上传校验 → 创建 Video(开发环境无转码器,直接待审核)"""
for text in [req.title, req.description, *req.tags]:
if text and await sensitive_service.find_sensitive(db, redis, text):
raise BizException(errors.SENSITIVE_WORD)
_check_tags(req.tags)
category = await db.get(Category, req.category_id)
if category is None or category.status != 1:
raise BizException(errors.PARAM_ERROR, "分类不存在")
pending = await _load_pending_upload(redis, req.object_key, identity.user_id)
if not pending.get("url"):
raise BizException(errors.PARAM_ERROR, "视频文件尚未上传")
is_priority = 1 if await membership_service.is_member(db, identity.user_id) else 0
video = Video(
user_id=identity.user_id,
category_id=req.category_id,
title=req.title,
description=req.description,
video_url=pending["url"],
file_size=int(pending.get("file_size") or 0),
status=1, # 待审核:开发环境无转码器,由后台审核通过后直接上架
is_priority=is_priority,
)
db.add(video)
await db.flush()
await _upsert_tags(db, video.id, req.tags)
await redis.delete(f"upload:obj:{req.object_key}")
await db.commit()
return {
"video_id": video.id,
"status": video.status,
"status_text": STATUS_TEXT[video.status],
"is_priority": bool(is_priority),
}
def _check_tags(names: list[str]) -> None:
"""标签数量与长度校验(0~5 个,每个 1~20 字)"""
if len(names) > 5:
raise BizException(errors.PARAM_ERROR, "标签最多 5 个")
if any(not (1 <= len(t) <= 20) for t in names):
raise BizException(errors.PARAM_ERROR, "每个标签 1~20 字")
async def _upsert_tags(db: AsyncSession, video_id: int, names: list[str]) -> None:
"""标签 upsert + 关联(去重保序,维护冗余 video_count)"""
for name in dict.fromkeys(names):
tag = (await db.execute(select(Tag).where(Tag.name == name))).scalar_one_or_none()
if tag is None:
tag = Tag(name=name)
db.add(tag)
await db.flush()
db.add(VideoTag(video_id=video_id, tag_id=tag.id))
tag.video_count += 1
async def _replace_tags(db: AsyncSession, video_id: int, names: list[str]) -> None:
"""编辑视频时重建标签关联:旧关联删除并回退计数 → 写新关联"""
old = (await db.execute(
select(VideoTag, Tag)
.join(Tag, Tag.id == VideoTag.tag_id)
.where(VideoTag.video_id == video_id)
)).all()
for link, tag in old:
tag.video_count = max(tag.video_count - 1, 0)
await db.delete(link)
await _upsert_tags(db, video_id, names)
# 软删除标记(模型 status:5 转码失败/已删除)
STATUS_DELETED = 5
async def _get_own_video(db: AsyncSession, identity: Identity, video_id: int) -> Video:
"""取本人作品:不存在/已删除 → 2003;非作者 → 2107"""
video = await db.get(Video, video_id)
if video is None or video.status == STATUS_DELETED:
raise BizException(errors.VIDEO_NOT_AVAILABLE)
if video.user_id != identity.user_id:
raise BizException(errors.NOT_AUTHOR)
return video
async def update_video(
db: AsyncSession, redis: aioredis.Redis, identity: Identity, video_id: int, req
) -> dict:
"""5.12 编辑视频(仅作者):敏感词过滤 → 分类/标签校验 → 逐字段更新 → 失效详情缓存"""
video = await _get_own_video(db, identity, video_id)
texts = [t for t in (req.title, req.description) if t] + list(req.tags or [])
for text in texts:
if await sensitive_service.find_sensitive(db, redis, text):
raise BizException(errors.SENSITIVE_WORD)
if req.title is not None:
video.title = req.title
if req.description is not None:
video.description = req.description
if req.category_id is not None:
category = await db.get(Category, req.category_id)
if category is None or category.status != 1:
raise BizException(errors.PARAM_ERROR, "分类不存在")
video.category_id = req.category_id
if req.tags is not None:
_check_tags(req.tags)
await _replace_tags(db, video.id, req.tags)
await db.commit()
await invalidate_detail_cache(redis, video.id)
return {"video_id": video.id}
async def delete_video(
db: AsyncSession, redis: aioredis.Redis, identity: Identity, video_id: int
) -> None:
"""5.13 删除视频(仅作者,软删除):置已删除状态 → 立即下架不可见,互动计数保留"""
video = await _get_own_video(db, identity, video_id)
video.status = STATUS_DELETED
await db.commit()
await invalidate_detail_cache(redis, video.id)
async def list_my_videos(
db: AsyncSession, user_id: int, page: int, page_size: int, status: int | None
) -> dict:
"""5.7 我的作品:按创建倒序(排除已删除),列表项附加 status/status_text/reject_reason"""
stmt = select(Video).where(Video.user_id == user_id, Video.status != STATUS_DELETED)
if status is not None:
stmt = stmt.where(Video.status == status)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
rows = list(
(await db.execute(stmt.order_by(Video.id.desc()).offset((page - 1) * page_size).limit(page_size))).scalars()
)
items = await serialize_videos(db, rows, user_id)
for item, v in zip(items, rows):
item.update({
"status": v.status,
"status_text": STATUS_TEXT.get(v.status, ""),
"reject_reason": v.reject_reason,
"created_at": _fmt(v.created_at),
})
return {"total": total, "list": items}
# ---------- 搜索 / 热搜 / 标签(5.9 / 5.10 / 5.11) ----------
# 热搜 ZSet(搜索计数,对齐《03-接口文档》5.10/5.11)
HOT_SEARCH_KEY = "search:hot"
# 热搜榜返回条数
HOT_SEARCH_TOP = 10
def _hot_order(stmt):
"""hot 排序:view×1 + like×5 + comment×10(与 Feed 热度分同权,SQL 内计算)"""
hot = Video.view_count + Video.like_count * 5 + Video.comment_count * 10
return stmt.order_by(hot.desc(), Video.id.desc())
def _latest_order(stmt):
return stmt.order_by(Video.published_at.desc(), Video.id.desc())
async def search_videos(
db: AsyncSession, redis: aioredis.Redis, identity: Identity,
keyword: str, sort: str, page: int, page_size: int,
) -> dict:
"""5.10 视频搜索:标题/标签 LIKE 匹配;命中敏感词返回空结果;搜索行为计入热搜 ZSet"""
if await sensitive_service.find_sensitive(db, redis, keyword):
return {"total": 0, "list": []}
await redis.zincrby(HOT_SEARCH_KEY, 1, keyword)
kw = f"%{keyword}%"
tag_video_ids = select(VideoTag.video_id).join(Tag, Tag.id == VideoTag.tag_id).where(Tag.name.like(kw))
stmt = select(Video).where(
Video.status == STATUS_PUBLISHED,
Video.title.like(kw) | Video.id.in_(tag_video_ids),
)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
stmt = _latest_order(stmt) if sort == "latest" else _hot_order(stmt)
rows = list((await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars())
return {"total": total, "list": await serialize_videos(db, rows, identity.user_id)}
async def get_hot_search(redis: aioredis.Redis) -> dict:
"""5.11 热搜榜:search:hot ZSet Top10"""
words = await redis.zrevrange(HOT_SEARCH_KEY, 0, HOT_SEARCH_TOP - 1)
return {"words": list(words)}
async def get_tag_videos(
db: AsyncSession, identity: Identity, tag_name: str, sort: str, page: int, page_size: int
) -> dict:
"""5.9 标签视频列表:标签不存在时返回空(tag 字段回显原名)"""
tag = (await db.execute(select(Tag).where(Tag.name == tag_name))).scalar_one_or_none()
if tag is None:
return {"tag": tag_name, "total": 0, "list": []}
stmt = (
select(Video)
.join(VideoTag, VideoTag.video_id == Video.id)
.where(VideoTag.tag_id == tag.id, Video.status == STATUS_PUBLISHED)
)
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0
stmt = _latest_order(stmt) if sort == "latest" else _hot_order(stmt)
rows = list((await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars())
return {"tag": tag.name, "total": total, "list": await serialize_videos(db, rows, identity.user_id)}
@@ -0,0 +1,81 @@
"""积分钱包服务:统一余额变更入口(行锁 + 流水 + 乐观锁,对齐《02-技术文档》3.4)"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import errors
from app.core.response import BizException
from app.models.billing import PointTransaction
from app.models.user import UserWallet
# 流水业务类型
BIZ_RECHARGE = 1 # 充值
BIZ_WATCH = 2 # 观看消耗
BIZ_COMMENT = 3 # 评论奖励
BIZ_MEMBERSHIP = 4 # 开通会员
BIZ_GIFT = 5 # 活动赠送
BIZ_ADJUST = 6 # 后台调整
BIZ_CLAWBACK = 7 # 奖励扣回/退款扣回
BIZ_REFERRAL = 8 # 推荐奖励
async def get_or_create_wallet(db: AsyncSession, user_id: int, for_update: bool = False) -> UserWallet:
stmt = select(UserWallet).where(UserWallet.user_id == user_id)
if for_update:
stmt = stmt.with_for_update()
wallet = (await db.execute(stmt)).scalar_one_or_none()
if wallet is None:
wallet = UserWallet(user_id=user_id)
db.add(wallet)
await db.flush()
if for_update:
# 新建后再加行锁,保证后续并发读到同一行
wallet = (await db.execute(stmt)).scalar_one()
return wallet
async def change_points(
db: AsyncSession,
user_id: int,
amount: int,
biz_type: int,
biz_id: str = "",
remark: str = "",
allow_negative: bool = False,
) -> int:
"""变更积分余额,返回变动后余额。
- amount 正增负减;扣减默认不允许透支(退款扣回场景传 allow_negative=True,余额可为负)
- 同事务内:行锁 → 校验/更新余额与累计 → 写流水
"""
wallet = await get_or_create_wallet(db, user_id, for_update=True)
new_balance = wallet.points + amount
if amount < 0 and new_balance < 0 and not allow_negative:
raise BizException(errors.POINTS_NOT_ENOUGH)
wallet.points = new_balance
wallet.version += 1
if amount > 0:
if biz_type == BIZ_RECHARGE:
wallet.total_recharge += amount
else:
wallet.total_earned += amount
else:
wallet.total_consumed += -amount
db.add(
PointTransaction(
user_id=user_id,
change_amount=amount,
balance_after=new_balance,
biz_type=biz_type,
biz_id=biz_id,
remark=remark,
)
)
await db.flush()
return new_balance
async def get_balance(db: AsyncSession, user_id: int) -> int:
wallet = await get_or_create_wallet(db, user_id)
return wallet.points