This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+299
View File
@@ -0,0 +1,299 @@
"""管理后台路由(/admin/*,对应《03-接口文档》第 11 章)
角色矩阵(super 拥有全部权限):
- reviewer:仅审核 → 待审核列表 / 审核视频
- operator:套餐、活动、看板 → 仪表盘 / 套餐 CRUD / 赠送
- 其余接口仅 super
"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.database import get_db
from app.deps import AdminIdentity, require_roles
from app.redis_client import get_redis
from app.schemas import admin as schema
from app.services import admin_service
router = APIRouter(prefix="/admin", tags=["管理后台"])
# 角色依赖:super 自动放行
require_reviewer = require_roles("reviewer")
require_operator = require_roles("operator")
require_super = require_roles()
def _ip(request: Request) -> str:
return request.client.host if request.client else ""
# ---------- 认证 / 看板 ----------
@router.post("/auth/login")
async def login(req: schema.AdminLoginReq, request: Request, db: AsyncSession = Depends(get_db)):
return ok(await admin_service.login(db, req, _ip(request)))
@router.get("/dashboard")
async def dashboard(db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.dashboard(db))
# ---------- 视频 ----------
@router.get("/videos/pending")
async def list_pending_videos(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_reviewer)):
return ok(await admin_service.list_pending_videos(db, page, page_size))
@router.get("/videos")
async def list_videos(keyword: str | None = Query(None), status: int | None = Query(None),
author_id: int | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_videos(db, keyword, status, author_id, page, page_size))
@router.post("/videos/{video_id}/review")
async def review_video(video_id: int, req: schema.VideoReviewReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_reviewer)):
return ok(await admin_service.review_video(db, redis, admin, video_id, req, _ip(request)))
@router.post("/videos/{video_id}/offline")
async def offline_video(video_id: int, req: schema.ReasonReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
await admin_service.offline_video(db, redis, admin, video_id, req.reason, _ip(request))
return ok()
# ---------- 用户 / 积分 ----------
@router.get("/users")
async def list_users(keyword: str | None = Query(None), status: int | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_users(db, keyword, status, page, page_size))
@router.get("/users/{user_id}")
async def get_user_detail(user_id: int, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.get_user_detail(db, user_id))
@router.put("/users/{user_id}/status")
async def set_user_status(user_id: int, req: schema.UserStatusReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.set_user_status(db, redis, admin, user_id, req, _ip(request)))
@router.post("/points/adjust")
async def adjust_points(req: schema.PointsAdjustReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.adjust_points(db, admin, req, _ip(request)))
@router.get("/points/records")
async def list_point_records(user_id: int | None = Query(None), biz_type: int | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_point_records(db, user_id, biz_type, page, page_size))
# ---------- 订单 / 套餐 ----------
@router.get("/orders")
async def list_orders(order_no: str | None = Query(None), user_id: int | None = Query(None),
pay_method: int | None = Query(None), status: int | None = Query(None),
start_date: str | None = Query(None), end_date: str | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_orders(
db, order_no, user_id, pay_method, status, start_date, end_date, page, page_size
))
@router.post("/orders/{order_no}/resettle")
async def resettle_order(order_no: str, req: schema.ResettleReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.resettle_order(db, admin, order_no, req, _ip(request)))
@router.post("/orders/{order_no}/refund")
async def refund_order(order_no: str, req: schema.RefundReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.refund_order(db, admin, order_no, req, _ip(request)))
@router.get("/packages")
async def list_packages(db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.list_packages(db))
@router.post("/packages")
async def create_package(req: schema.PackageCreateReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.create_package(db, admin, req, _ip(request)))
@router.put("/packages/{package_id}")
async def update_package(package_id: int, req: schema.PackageUpdateReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.update_package(db, admin, package_id, req, _ip(request)))
@router.delete("/packages/{package_id}")
async def delete_package(package_id: int, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.delete_package(db, admin, package_id, _ip(request)))
# ---------- 赠送 / 日志 ----------
@router.post("/gift")
async def gift(req: schema.GiftReq, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
return ok(await admin_service.gift(db, admin, req, _ip(request)))
@router.get("/logs")
async def list_logs(admin_id: int | None = Query(None), action: str | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_logs(db, admin_id, action, page, page_size))
# ---------- 提现 ----------
@router.get("/withdrawals")
async def list_withdrawals(status: int | None = Query(None), user_id: int | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_withdrawals(db, status, user_id, page, page_size))
@router.post("/withdrawals/{withdraw_no}/review")
async def review_withdrawal(withdraw_no: str, req: schema.WithdrawReviewReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.review_withdrawal(db, redis, admin, withdraw_no, req, _ip(request)))
# ---------- 推荐 ----------
@router.get("/referrals")
async def list_referrals(inviter_id: int | None = Query(None), status: int | None = Query(None),
risk_flag: str | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_referrals(db, inviter_id, status, risk_flag, page, page_size))
@router.post("/referrals/{referral_id}/review")
async def review_referral(referral_id: int, req: schema.ReferralReviewReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.review_referral(db, admin, referral_id, req, _ip(request)))
# ---------- 配置 ----------
@router.get("/configs")
async def list_configs(db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_configs(db))
@router.put("/configs/{config_key}")
async def update_config(config_key: str, req: schema.ConfigUpdateReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.update_config(db, redis, admin, config_key, req.value, _ip(request)))
# ---------- 评论 / 举报 ----------
@router.get("/comments")
async def list_comments(video_id: int | None = Query(None), user_id: int | None = Query(None),
keyword: str | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_comments(db, video_id, user_id, keyword, page, page_size))
@router.delete("/comments/{comment_id}")
async def delete_comment(comment_id: int, req: schema.ReasonReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
await admin_service.delete_comment(db, redis, admin, comment_id, req.reason, _ip(request))
return ok()
@router.get("/reports")
async def list_reports(status: int | None = Query(None), target_type: int | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_reports(db, status, target_type, page, page_size))
@router.post("/reports/{report_id}/review")
async def review_report(report_id: int, req: schema.ReportReviewReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.review_report(db, redis, admin, report_id, req, _ip(request)))
# ---------- 敏感词 ----------
@router.get("/sensitive-words")
async def list_sensitive_words(keyword: str | None = Query(None),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.list_sensitive_words(db, keyword, page, page_size))
@router.post("/sensitive-words")
async def add_sensitive_word(req: schema.SensitiveWordAddReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
return ok(await admin_service.add_sensitive_word(db, redis, admin, req.word, _ip(request)))
@router.delete("/sensitive-words/{word_id}")
async def delete_sensitive_word(word_id: int, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
await admin_service.delete_sensitive_word(db, redis, admin, word_id, _ip(request))
return ok()
+77
View File
@@ -0,0 +1,77 @@
"""认证模块路由(/auth/*,对应《03-接口文档》第 3 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Header, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.core.security import decode_token
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.auth import (
EmailSendReq,
LoginReq,
PasswordResetReq,
RefreshReq,
RegisterReq,
SmsSendReq,
)
from app.services import auth_service
router = APIRouter(prefix="/auth", tags=["认证"])
def _client_ip(request: Request) -> str:
# Nginx 反代后取真实 IP
return request.headers.get("X-Real-IP") or (request.client.host if request.client else "")
@router.post("/sms/send")
async def sms_send(req: SmsSendReq, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
data = await auth_service.send_code(db, redis, "sms", req.phone, req.scene, _client_ip(request))
return ok(data)
@router.post("/email/send")
async def email_send(req: EmailSendReq, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
data = await auth_service.send_code(db, redis, "email", req.email, req.scene, _client_ip(request))
return ok(data)
@router.post("/register")
async def register(req: RegisterReq, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
data = await auth_service.register(db, redis, req, identity.device_id, _client_ip(request))
return ok(data)
@router.post("/login")
async def login(req: LoginReq, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
data = await auth_service.login(db, redis, req, _client_ip(request))
return ok(data)
@router.post("/token/refresh")
async def token_refresh(req: RefreshReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
return ok(await auth_service.refresh(db, redis, req.refresh_token))
@router.post("/logout")
async def logout(authorization: str = Header(...),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
payload = decode_token(authorization.removeprefix("Bearer ").strip())
await auth_service.logout(redis, identity.user_id, payload.get("jti", ""), int(payload.get("exp", 0)))
return ok()
@router.post("/password/reset")
async def password_reset(req: PasswordResetReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
await auth_service.reset_password(db, redis, req)
return ok()
+54
View File
@@ -0,0 +1,54 @@
"""通用与消息模块路由(/app/version + /notifications + /reports,对应《03-接口文档》第 10 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import ratelimit
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.common import NotificationReadReq, ReportReq
from app.services import common_service
router = APIRouter(tags=["通用"])
@router.get("/app/version")
async def get_app_version(platform: str = Query(pattern="^(ios|android)$"),
version_code: int = Query(ge=1),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await common_service.get_app_version(db, redis, platform, version_code))
@router.get("/notifications")
async def list_notifications(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
type: int | None = Query(None),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await common_service.list_notifications(db, identity.user_id, page, page_size, type))
@router.get("/notifications/unread_count")
async def get_unread_count(db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok({"unread": await common_service.get_unread_count(db, redis, identity.user_id)})
@router.post("/notifications/read")
async def mark_read(req: NotificationReadReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await common_service.mark_read(db, redis, identity.user_id, req.ids, bool(req.all))
return ok()
@router.post("/reports")
async def create_report(req: ReportReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "report", 10, 86400, "今日举报次数已达上限")
await common_service.create_report(db, redis, identity.user_id, req)
return ok(message="已收到举报,我们会尽快处理")
+62
View File
@@ -0,0 +1,62 @@
"""激励模块路由(/referral/* + /assets/*,对应《03-接口文档》第 9 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import ratelimit
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, require_user
from app.redis_client import get_redis
from app.schemas.incentive import BindInviteReq, WithdrawReq
from app.services import incentive_service
router_referral = APIRouter(prefix="/referral", tags=["推荐"])
router_assets = APIRouter(prefix="/assets", tags=["资产"])
@router_referral.get("/me")
async def get_my_referral(db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await incentive_service.get_my_referral(db, redis, identity.user_id))
@router_referral.post("/bind")
async def bind_invite_code(req: BindInviteReq, request: Request, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
ip = request.client.host if request.client else ""
return ok(await incentive_service.bind_invite_code(
db, redis, identity.user_id, req.invite_code, identity.device_id, ip
))
@router_assets.get("/me")
async def get_my_assets(db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await incentive_service.get_my_assets(db, redis, identity.user_id))
@router_assets.get("/records")
async def list_asset_records(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
biz_type: int | None = Query(None),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await incentive_service.list_asset_records(db, identity.user_id, page, page_size, biz_type))
@router_assets.post("/withdraw")
async def apply_withdraw(req: WithdrawReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "withdraw", 1, 60, "提现申请过于频繁,请稍后再试")
return ok(await incentive_service.apply_withdraw(
db, redis, identity.user_id, req.assets, req.channel, req.account
))
@router_assets.get("/withdraw/records")
async def list_withdraw_records(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await incentive_service.list_withdraw_records(db, identity.user_id, page, page_size))
+55
View File
@@ -0,0 +1,55 @@
"""互动模块路由(对应《03-接口文档》第 6 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import ratelimit
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.interaction import CommentCreateReq
from app.services import interaction_service
# /videos/{video_id} 下的互动接口
router_videos = APIRouter(prefix="/videos", tags=["互动"])
# /comments 下的接口
router_comments = APIRouter(prefix="/comments", tags=["互动"])
@router_videos.post("/{video_id}/like")
async def toggle_like(video_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await interaction_service.toggle_like(db, redis, identity.user_id, video_id))
@router_videos.post("/{video_id}/favorite")
async def toggle_favorite(video_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await interaction_service.toggle_favorite(db, redis, identity.user_id, video_id))
@router_videos.get("/{video_id}/comments")
async def list_comments(video_id: int, page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=50),
parent_id: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(get_identity)):
return ok(await interaction_service.list_comments(db, video_id, page, page_size, parent_id))
@router_videos.post("/{video_id}/comments")
async def create_comment(video_id: int, req: CommentCreateReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "comment", 10, 60, "评论过于频繁,请稍后再试")
return ok(await interaction_service.create_comment(db, redis, identity.user_id, video_id, req))
@router_comments.delete("/{comment_id}")
async def delete_comment(comment_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await interaction_service.delete_comment(db, redis, identity.user_id, comment_id))
+45
View File
@@ -0,0 +1,45 @@
"""推广归因路由(/invite/* + H5 中间页 /r/{code})
三段式归因:分享口令(主)→ H5 点击埋点模糊匹配(辅)→ 注册手填邀请码(兜底)
"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.database import get_db
from app.redis_client import get_redis
from app.schemas.invite import InviteClickReq, InviteMatchReq
from app.services import invite_service
router = APIRouter(prefix="/invite", tags=["推广归因"])
router_page = APIRouter(tags=["推广落地页"], include_in_schema=False)
def _client_ip(request: Request) -> str:
# Nginx 反代后取真实 IP
return request.headers.get("X-Real-IP") or (request.client.host if request.client else "")
@router.post("/click")
async def invite_click(req: InviteClickReq, request: Request,
redis: aioredis.Redis = Depends(get_redis)):
"""H5 中间页点击埋点(打开页面即上报,记录 code+IP+UA,24h 有效)"""
ua = request.headers.get("User-Agent", "")
return ok(await invite_service.record_click(redis, req.code.upper(), _client_ip(request), ua))
@router.post("/match")
async def invite_match(req: InviteMatchReq, request: Request,
redis: aioredis.Redis = Depends(get_redis)):
"""App 首启归因匹配:按 IP+时间就近匹配点击记录,结果按 device_id 服务端缓存"""
ua = request.headers.get("User-Agent", "")
return ok(await invite_service.match_and_cache(redis, req.device_id, _client_ip(request), ua))
@router_page.get("/r/{code}", response_class=HTMLResponse)
async def invite_landing(code: str, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis)):
"""推广落地页(复制口令 + 下载入口,打开即自动埋点)"""
return await invite_service.render_landing_page(db, redis, code.upper())
+28
View File
@@ -0,0 +1,28 @@
"""会员模块路由(/membership/*,对应《03-接口文档》第 7 章)"""
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, require_user
from app.schemas.common import RedeemReq
from app.services import membership_service
router = APIRouter(prefix="/membership", tags=["会员"])
@router.get("/plans")
async def list_plans():
return ok(membership_service.list_plans())
@router.get("/me")
async def get_my_membership(db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await membership_service.get_my_membership(db, identity.user_id))
@router.post("/redeem")
async def redeem(req: RedeemReq, db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await membership_service.redeem(db, identity.user_id, req.plan_code))
+58
View File
@@ -0,0 +1,58 @@
"""支付模块路由(/payments/* + /callbacks/*,对应《03-接口文档》第 8 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Header, Request
from fastapi.responses import JSONResponse, PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, require_user
from app.redis_client import get_redis
from app.schemas.payment import RechargeReq
from app.services import payment_service
router = APIRouter(prefix="/payments", tags=["支付"])
router_callbacks = APIRouter(prefix="/callbacks", tags=["支付回调"])
@router.get("/packages")
async def list_packages(db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await payment_service.list_packages(db, redis))
@router.post("/recharge")
async def create_recharge(req: RechargeReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
x_platform: str | None = Header(default=None),
identity: Identity = Depends(require_user)):
return ok(await payment_service.create_recharge(db, redis, identity.user_id, req, x_platform))
@router.get("/orders/{order_no}")
async def get_order(order_no: str, db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await payment_service.get_order(db, identity.user_id, order_no))
@router.post("/orders/{order_no}/cancel")
async def cancel_order(order_no: str, db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
await payment_service.cancel_order(db, identity.user_id, order_no)
return ok()
@router_callbacks.post("/alipay", include_in_schema=False)
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
"""支付宝异步通知:平台要求纯文本 success/failure"""
form = dict(await request.form())
result = await payment_service.handle_alipay_callback(db, form)
return PlainTextResponse(result)
@router_callbacks.post("/wechat", include_in_schema=False)
async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
"""微信支付 V3 异步通知:{"code": "SUCCESS"} 或 {"code": "FAIL"}"""
body = await request.body()
result = await payment_service.handle_wechat_callback(db, dict(request.headers), body)
return JSONResponse(result)
+89
View File
@@ -0,0 +1,89 @@
"""用户模块路由(/users/*,对应《03-接口文档》第 4 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.user import DeactivateReq, UpdateMeReq
from app.services import interaction_service, storage_service, user_service, video_service
router = APIRouter(prefix="/users", tags=["用户"])
# 注意:/users/me 系列须先于 /users/{user_id} 注册,避免 "me" 被路径参数匹配
@router.get("/me")
async def get_me(db: AsyncSession = Depends(get_db), identity: Identity = Depends(require_user)):
return ok(await user_service.get_me(db, identity.user_id))
@router.put("/me")
async def update_me(req: UpdateMeReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await user_service.update_me(db, redis, identity.user_id, req))
@router.post("/me/avatar")
async def upload_avatar(file: UploadFile, db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
url = await storage_service.save_avatar(file, identity.user_id)
return ok(await user_service.set_avatar(db, identity.user_id, url))
@router.get("/me/wallet")
async def get_wallet(db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await user_service.get_wallet(db, redis, identity.user_id))
@router.get("/me/points/records")
async def get_points_records(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
biz_type: int | None = Query(None),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await user_service.get_points_records(db, identity.user_id, page, page_size, biz_type))
@router.post("/me/deactivate")
async def deactivate(req: DeactivateReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await user_service.deactivate(db, redis, identity.user_id, req.password))
@router.get("/me/favorites")
async def get_my_favorites(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await interaction_service.list_my_favorites(db, identity.user_id, page, page_size))
@router.get("/me/history")
async def get_my_history(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await interaction_service.list_my_history(db, identity.user_id, page, page_size))
@router.get("/me/videos")
async def get_my_videos(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
status: int | None = Query(None, ge=0, le=5),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user)):
return ok(await video_service.list_my_videos(db, identity.user_id, page, page_size, status))
@router.get("/{user_id}")
async def get_public_profile(user_id: int, db: AsyncSession = Depends(get_db)):
return ok(await user_service.get_public_profile(db, user_id))
@router.get("/{user_id}/videos")
async def get_user_videos(user_id: int, page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(get_identity)):
return ok(await user_service.get_user_videos(db, user_id, page, page_size, identity.user_id))
+117
View File
@@ -0,0 +1,117 @@
"""视频模块路由(/videos/* + /tags/* + /search/*,对应《03-接口文档》第 5 章)"""
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Form, Query, Request, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import ratelimit
from app.core.response import ok
from app.database import get_db
from app.deps import Identity, get_identity, require_user
from app.redis_client import get_redis
from app.schemas.video import EditVideoReq, PublishVideoReq, UploadTokenReq, WatchCompleteReq, WatchReq
from app.services import video_service
router = APIRouter(prefix="/videos", tags=["视频"])
router_tags = APIRouter(prefix="/tags", tags=["视频"])
router_search = APIRouter(prefix="/search", tags=["搜索"])
# 搜索限流:30 次/分钟(登录按用户,游客按 IP)
SEARCH_RATE_LIMIT = 30
# 上传凭证 + 发布限流:5 次/小时(同一动作桶)
UPLOAD_RATE_LIMIT = 5
# 注意:/videos/feed、/videos/categories、/videos/search、/videos/upload/* 须先于 /videos/{video_id} 注册
@router.get("/feed")
async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20),
category_id: int | None = Query(None),
type: str = Query("recommend", pattern="^(recommend|latest|hot)$"),
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.get_feed(db, redis, identity, type, cursor, limit, category_id))
@router.get("/categories")
async def get_categories(db: AsyncSession = Depends(get_db)):
return ok(await video_service.list_categories(db))
@router.get("/search")
async def search_videos(request: Request, keyword: str = Query(min_length=1, max_length=30),
sort: str = Query("hot", pattern="^(hot|latest)$"),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
subject = identity.subject or (request.client.host if request.client else "anon")
await ratelimit.hit_subject(redis, subject, "search", SEARCH_RATE_LIMIT, 60)
return ok(await video_service.search_videos(db, redis, identity, keyword, sort, page, page_size))
@router_search.get("/hot")
async def get_hot_search(redis: aioredis.Redis = Depends(get_redis)):
return ok(await video_service.get_hot_search(redis))
@router_tags.get("/{tag_name}/videos")
async def get_tag_videos(tag_name: str, sort: str = Query("hot", pattern="^(hot|latest)$"),
page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50),
db: AsyncSession = Depends(get_db), identity: Identity = Depends(get_identity)):
return ok(await video_service.get_tag_videos(db, identity, tag_name, sort, page, page_size))
@router.post("/upload/token")
async def get_upload_token(req: UploadTokenReq, redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "upload", UPLOAD_RATE_LIMIT, 3600, "上传过于频繁,请稍后再试")
return ok(await video_service.get_upload_token(redis, identity, req))
@router.post("/upload/file")
async def upload_file(file: UploadFile, object_key: str = Form(...),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await video_service.register_upload(redis, identity, object_key, file))
@router.post("")
async def publish_video(req: PublishVideoReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await ratelimit.hit_subject(redis, identity.subject, "upload", UPLOAD_RATE_LIMIT, 3600, "发布过于频繁,请稍后再试")
return ok(await video_service.publish_video(db, redis, identity, req))
@router.get("/{video_id}")
async def get_detail(video_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.get_detail(db, redis, identity, video_id))
@router.put("/{video_id}")
async def update_video(video_id: int, req: EditVideoReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
return ok(await video_service.update_video(db, redis, identity, video_id, req))
@router.delete("/{video_id}")
async def delete_video(video_id: int, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user)):
await video_service.delete_video(db, redis, identity, video_id)
return ok()
@router.post("/{video_id}/watch")
async def report_watch(video_id: int, req: WatchReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.report_watch(db, redis, identity, video_id, req.position))
@router.post("/{video_id}/watch/complete")
async def report_complete(video_id: int, req: WatchCompleteReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.report_complete(db, redis, identity, video_id, req.watch_duration))