up
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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="已收到举报,我们会尽快处理")
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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: "服务器内部错误",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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, "服务器内部错误"))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"),)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""SQLAlchemy 声明式基类"""
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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"),)
|
||||
@@ -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"),)
|
||||
@@ -0,0 +1 @@
|
||||
"""支付适配层(《02-技术文档》6.5):三渠道统一产出 PaymentResult 交 settle 入账"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user