Merge pull request #7 from KYJCASTER/optimize/17-items
Optimize/17 items
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# feedsystem_video_go 环境变量模板
|
||||
# 复制此文件为 .env 后修改实际值
|
||||
# .env 已被 .gitignore 忽略,不会提交到仓库
|
||||
|
||||
# MySQL
|
||||
MYSQL_ROOT_PASSWORD=123456
|
||||
MYSQL_DATABASE=feedsystem
|
||||
|
||||
# Redis
|
||||
REDIS_PASSWORD=123456
|
||||
|
||||
# RabbitMQ
|
||||
RABBITMQ_USER=admin
|
||||
RABBITMQ_PASS=password123
|
||||
|
||||
# JWT (生产环境务必修改为随机强密钥)
|
||||
JWT_SECRET=feedsystem-dev-secret-key
|
||||
1014
.sisyphus/plans/optimization-plan.md
Normal file
1014
.sisyphus/plans/optimization-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
122
README.md
122
README.md
@@ -1,13 +1,21 @@
|
||||
# feedsystem_video_go
|
||||
|
||||
基于 Go 的短视频 Feed 系统(后端 + 前端),包含账号、视频、点赞、评论、关注与 Feed 流;支持 Redis 缓存与 RabbitMQ 异步 Worker(API 进程与 Worker 进程可拆分部署)。
|
||||
基于 Go + Vue 3 的短视频 Feed 系统,含账号、视频、点赞、评论、关注、Feed 流,支持 Redis 缓存与 RabbitMQ 异步 Worker(API 与 Worker 可拆分部署)。
|
||||
|
||||
详细设计与接口说明请阅读:`feedsystem_video_go项目设计.md`(包含模块设计、表结构、流程图与接口清单)。
|
||||
## [项目演示](https://www.bilibili.com/video/BV1Dti7B9E6Y?vd_source=4b2884373b2c4c4147b10162c1709276)
|
||||
## 功能
|
||||
|
||||
## Docker Compose 一键启动(推荐)
|
||||
| 模块 | 功能 |
|
||||
|------|------|
|
||||
| 账号 | 注册/登录/改名/改密/登出,头像上传,个人简介,Refresh Token 双 Token 鉴权 |
|
||||
| 视频 | 上传/发布/删除,按作者查看,详情(三级缓存),#话题标签 |
|
||||
| 点赞 | 点赞/取消/是否已赞/已赞列表,SSE 实时通知 |
|
||||
| 评论 | 发布/删除/列表,@提及 通知 |
|
||||
| 关注 | 关注/取关/粉丝列表/关注列表/粉丝计数,SSE 实时通知 |
|
||||
| Feed | 最新/点赞榜/热度榜/关注流/话题标签流,冷热分离+游标分页,虚拟滚动 |
|
||||
| 私信 | 发送/对话列表 |
|
||||
| 通知 | SSE 实时推送,未读计数,已读标记 |
|
||||
|
||||
要求:已安装 Docker Desktop / Docker Engine + Docker Compose。
|
||||
## Docker Compose 一键启动
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
@@ -16,44 +24,100 @@ docker compose up -d --build
|
||||
访问:
|
||||
- 前端:`http://localhost:5173`
|
||||
- 后端 API:`http://localhost:8080`
|
||||
- RabbitMQ 管理台:`http://localhost:15672`(默认账号 `admin` / `password123`)
|
||||
- RabbitMQ 管理台:`http://localhost:15672`(`admin` / `password123`)
|
||||
|
||||
说明:
|
||||
- Compose 会启动 `mysql`、`redis`、`rabbitmq`、`backend`(API)、`worker`、`frontend`。
|
||||
- 容器内后端配置使用 `backend/configs/config.docker.yaml`(会挂载到 `/app/configs/config.yaml`)。
|
||||
默认 `.env` 自动生成 JWT 密钥。生产环境请修改 `JWT_SECRET`。
|
||||
|
||||
## 本地开发启动(不容器化)
|
||||
## 测试数据
|
||||
|
||||
启动后内置 100 个测试用户(`user001` ~ `user100`,密码均为 `123456`),`user001` 已发布视频并拥有粉丝/点赞数据。
|
||||
|
||||
## 本地开发
|
||||
|
||||
1) 先启动依赖(也可以只用 compose 拉起依赖):
|
||||
```bash
|
||||
# 启动依赖
|
||||
docker compose up -d mysql redis rabbitmq
|
||||
```
|
||||
|
||||
2) 用 compose 启 MySQL 时,宿主机端口是 `3307`。本地 Go 进程请使用面向 compose 依赖的配置文件 `backend/configs/config.compose-local.yaml`。
|
||||
|
||||
3) 启动后端 API:
|
||||
```bash
|
||||
# 后端
|
||||
cd backend
|
||||
CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd
|
||||
```
|
||||
|
||||
4) 启动 Worker(消费 MQ、异步落库/更新 Redis 热榜):
|
||||
```bash
|
||||
cd backend
|
||||
# Worker
|
||||
CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd/worker
|
||||
```
|
||||
|
||||
5) 启动前端(开发模式):
|
||||
```bash
|
||||
# 前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
npm install && npm run dev
|
||||
```
|
||||
|
||||
前端默认使用 Vite 代理 `/api` 到 `http://127.0.0.1:8080`(见 `frontend/vite.config.ts`)。
|
||||
## 接口清单
|
||||
|
||||
`backend/configs/config.yaml` 继续适用于本机原生 MySQL `3306` 的场景。
|
||||
### 账号 `/account`
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| POST | `/register` | 否 | 注册(限流 5次/时/IP) |
|
||||
| POST | `/login` | 否 | 登录,返回 access_token + refresh_token |
|
||||
| POST | `/refresh` | 否 | 刷新 access_token(用 refresh_token) |
|
||||
| POST | `/changePassword` | 否 | 改密码(需旧密码) |
|
||||
| POST | `/findByID` | 否 | 按 ID 查用户 |
|
||||
| POST | `/findByUsername` | 否 | 按用户名查 |
|
||||
| POST | `/getProfile` | 否 | 用户主页(视频数/获赞/粉丝数) |
|
||||
| POST | `/logout` | JWT | 登出(同时失效双 token) |
|
||||
| POST | `/rename` | JWT | 改名 |
|
||||
| POST | `/uploadAvatar` | JWT | 上传头像(jpg/png/webp,≤10MB) |
|
||||
| POST | `/updateProfile` | JWT | 更新简介/头像 |
|
||||
|
||||
## Star History
|
||||
### 视频 `/video`
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| POST | `/publish` | JWT | 发布视频(自动提取 #话题) |
|
||||
| POST | `/uploadVideo` | JWT | 上传视频文件(mp4,≤200MB) |
|
||||
| POST | `/uploadCover` | JWT | 上传封面(jpg/png/webp,≤10MB) |
|
||||
| POST | `/listByAuthorID` | 否 | 按作者查视频 |
|
||||
| POST | `/getDetail` | 否 | 视频详情(三级缓存) |
|
||||
|
||||
[](https://www.star-history.com/#LeoninCS/feedsystem_video_go&Date)
|
||||
### 点赞 `/like`
|
||||
| POST | `/like` | JWT | 点赞 |
|
||||
| POST | `/unlike` | JWT | 取消点赞 |
|
||||
| POST | `/isLiked` | JWT | 是否已赞 |
|
||||
| POST | `/listMyLikedVideos` | JWT | 我赞过的视频 |
|
||||
|
||||
### 评论 `/comment`
|
||||
| POST | `/listAll` | 否 | 评论列表(分页200,按时间升序) |
|
||||
| POST | `/publish` | JWT | 发布评论(支持 @username 提及) |
|
||||
| POST | `/delete` | JWT | 删除评论 |
|
||||
|
||||
### 关注 `/social`
|
||||
| POST | `/follow` | JWT | 关注 |
|
||||
| POST | `/unfollow` | JWT | 取关 |
|
||||
| POST | `/getAllFollowers` | JWT | 粉丝列表(含粉丝数) |
|
||||
| POST | `/getAllVloggers` | JWT | 关注列表(含关注数) |
|
||||
| POST | `/getCounts` | JWT | 粉丝/关注计数 |
|
||||
|
||||
### Feed `/feed`
|
||||
| POST | `/listLatest` | 软鉴权 | 最新视频(游标分页) |
|
||||
| POST | `/listLikesCount` | 软鉴权 | 点赞排行(复合游标) |
|
||||
| POST | `/listByPopularity` | 软鉴权 | 热度榜(快照分页) |
|
||||
| POST | `/listByFollowing` | JWT | 关注流 |
|
||||
| POST | `/listByTag` | 软鉴权 | 按 #话题 浏览 |
|
||||
|
||||
### 通知 `/notification`
|
||||
| GET | `/stream?token=` | 是 | SSE 实时推送 |
|
||||
| POST | `/list` | 是 | 通知列表 |
|
||||
| POST | `/markRead` | 是 | 标记已读(传 id 单条,不传全标) |
|
||||
| POST | `/unreadCount` | 是 | 未读计数 |
|
||||
|
||||
### 私信 `/message`
|
||||
| POST | `/send` | JWT | 发送私信 |
|
||||
| POST | `/list` | JWT | 对话列表 |
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `JWT_SECRET` | `feedsystem-dev-secret-key` | JWT 签名密钥,生产须改 |
|
||||
| `MYSQL_ROOT_PASSWORD` | `123456` | MySQL root 密码 |
|
||||
| `REDIS_PASSWORD` | `123456` | Redis 密码 |
|
||||
| `RABBITMQ_USER` / `RABBITMQ_PASS` | `admin` / `password123` | RabbitMQ 账号 |
|
||||
|
||||
详见 `.env.example`。
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"feedsystem_video_go/internal/social"
|
||||
"feedsystem_video_go/internal/video"
|
||||
"feedsystem_video_go/internal/worker"
|
||||
mqrabbit "feedsystem_video_go/internal/middleware/rabbitmq"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -37,6 +39,21 @@ const (
|
||||
popularityBindingKey = "video.popularity.*"
|
||||
)
|
||||
|
||||
func connectWithRetry(name string, maxRetries int, fn func() error) {
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
if err := fn(); err == nil {
|
||||
return
|
||||
}
|
||||
wait := time.Duration(1<<i) * time.Second
|
||||
if wait > 30*time.Second {
|
||||
wait = 30 * time.Second
|
||||
}
|
||||
log.Printf("%s 不可用,%v 后重试 (%d/%d)...", name, wait, i+1, maxRetries)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
log.Fatalf("%s: 超过最大重试次数", name)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 加载配置
|
||||
configPath := os.Getenv("CONFIG_PATH")
|
||||
@@ -53,11 +70,13 @@ func main() {
|
||||
} else {
|
||||
log.Printf("Config loaded from file: %s", configPath)
|
||||
}
|
||||
// 连接数据库
|
||||
sqlDB, err := db.NewDB(cfg.Database)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect database: %v", err)
|
||||
}
|
||||
// 连接数据库(带重试)
|
||||
var sqlDB *gorm.DB
|
||||
connectWithRetry("MySQL", 10, func() error {
|
||||
var err error
|
||||
sqlDB, err = db.NewDB(cfg.Database)
|
||||
return err
|
||||
})
|
||||
defer db.CloseDB(sqlDB)
|
||||
|
||||
// 连接 Redis(用于流行度更新)
|
||||
@@ -77,12 +96,14 @@ func main() {
|
||||
log.Printf("Redis connected (popularity worker enabled)")
|
||||
}
|
||||
}
|
||||
// 连接 RabbitMQ
|
||||
// 连接 RabbitMQ(带重试)
|
||||
url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/"
|
||||
conn, err := amqp.Dial(url)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect rabbitmq: %v", err)
|
||||
}
|
||||
var conn *amqp.Connection
|
||||
connectWithRetry("RabbitMQ", 10, func() error {
|
||||
var err error
|
||||
conn, err = amqp.Dial(url)
|
||||
return err
|
||||
})
|
||||
defer conn.Close()
|
||||
// 创建 RabbitMQ 通道
|
||||
ch, err := conn.Channel()
|
||||
@@ -174,7 +195,7 @@ func declareSocialTopology(ch *amqp.Channel) error {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -211,7 +232,7 @@ func declarePopularityTopology(ch *amqp.Channel) error {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -245,7 +266,7 @@ func declareLikeTopology(ch *amqp.Channel) error {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -279,7 +300,7 @@ func declareCommentTopology(ch *amqp.Channel) error {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -5,6 +5,9 @@ type Account struct {
|
||||
Username string `gorm:"unique" json:"username"`
|
||||
Password string `json:"-"`
|
||||
Token string `json:"-"`
|
||||
RefreshToken string `json:"-"`
|
||||
AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"`
|
||||
Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"`
|
||||
}
|
||||
|
||||
type CreateAccountRequest struct {
|
||||
@@ -23,6 +26,8 @@ type FindByIDRequest struct {
|
||||
type FindByIDResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
}
|
||||
|
||||
type FindByUsernameRequest struct {
|
||||
@@ -44,3 +49,31 @@ type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Bio string `json:"bio"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type GetProfileRequest struct {
|
||||
AccountID uint `json:"account_id"`
|
||||
}
|
||||
|
||||
type GetProfileResponse struct {
|
||||
Account FindByIDResponse `json:"account"`
|
||||
VideoCount int64 `json:"video_count"`
|
||||
TotalLikes int64 `json:"total_likes"`
|
||||
FollowerCount int64 `json:"follower_count"`
|
||||
VloggerCount int64 `json:"vlogger_count"`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -17,7 +28,7 @@ func NewAccountHandler(accountService *AccountService) *AccountHandler {
|
||||
func (h *AccountHandler) CreateAccount(c *gin.Context) {
|
||||
var req CreateAccountRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.accountService.CreateAccount(c.Request.Context(), &Account{
|
||||
@@ -33,18 +44,18 @@ func (h *AccountHandler) CreateAccount(c *gin.Context) {
|
||||
func (h *AccountHandler) Rename(c *gin.Context) {
|
||||
var req RenameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
accountID, err := getAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNewUsernameRequired) {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrUsernameTaken) {
|
||||
@@ -64,7 +75,7 @@ func (h *AccountHandler) Rename(c *gin.Context) {
|
||||
func (h *AccountHandler) ChangePassword(c *gin.Context) {
|
||||
var req ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil {
|
||||
@@ -77,7 +88,7 @@ func (h *AccountHandler) ChangePassword(c *gin.Context) {
|
||||
func (h *AccountHandler) FindByID(c *gin.Context) {
|
||||
var req FindByIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil {
|
||||
@@ -91,7 +102,7 @@ func (h *AccountHandler) FindByID(c *gin.Context) {
|
||||
func (h *AccountHandler) FindByUsername(c *gin.Context) {
|
||||
var req FindByUsernameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil {
|
||||
@@ -105,21 +116,26 @@ func (h *AccountHandler) FindByUsername(c *gin.Context) {
|
||||
func (h *AccountHandler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if token, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password); err != nil {
|
||||
account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
} else {
|
||||
c.JSON(200, gin.H{"token": token})
|
||||
}
|
||||
accessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, LoginResponse{Token: accessToken, RefreshToken: refreshToken, AccountID: account.ID, Username: account.Username})
|
||||
}
|
||||
|
||||
func (h *AccountHandler) Logout(c *gin.Context) {
|
||||
accountID, err := getAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil {
|
||||
@@ -129,6 +145,105 @@ func (h *AccountHandler) Logout(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "account logged out"})
|
||||
}
|
||||
|
||||
func (h *AccountHandler) UploadAvatar(c *gin.Context) {
|
||||
accountID, err := getAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
f, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
const maxSize = 10 << 20
|
||||
if f.Size <= 0 || f.Size > maxSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(f.Filename))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp":
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp allowed"})
|
||||
return
|
||||
}
|
||||
dir := filepath.Join(".run", "uploads", "avatars", strconv.FormatUint(uint64(accountID), 10))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
filename, err := randHex(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
filename = filename + ext
|
||||
absPath := filepath.Join(dir, filename)
|
||||
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
urlPath := path.Join("/static", "avatars", strconv.FormatUint(uint64(accountID), 10), filename)
|
||||
avatarURL := buildAbsoluteURL(c, urlPath)
|
||||
if err := h.accountService.UpdateAvatar(c.Request.Context(), accountID, avatarURL); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"avatar_url": avatarURL})
|
||||
}
|
||||
|
||||
func (h *AccountHandler) UpdateProfile(c *gin.Context) {
|
||||
accountID, err := getAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var req UpdateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.accountService.UpdateProfile(c.Request.Context(), accountID, &req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
|
||||
}
|
||||
|
||||
func (h *AccountHandler) Refresh(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
newToken, accountID, username, err := h.accountService.RefreshAccessToken(c.Request.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, LoginResponse{Token: newToken, AccountID: accountID, Username: username})
|
||||
}
|
||||
|
||||
func randHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("rand.Read: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func buildAbsoluteURL(c *gin.Context, p string) string {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" {
|
||||
scheme = xf
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p)
|
||||
}
|
||||
|
||||
func getAccountID(c *gin.Context) (uint, error) {
|
||||
value, exists := c.Get("accountID")
|
||||
if !exists {
|
||||
|
||||
@@ -71,16 +71,36 @@ func (ar *AccountRepository) FindByUsername(ctx context.Context, username string
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (ar *AccountRepository) Login(ctx context.Context, id uint, token string) error {
|
||||
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil {
|
||||
func (ar *AccountRepository) Login(ctx context.Context, id uint, token, refreshToken string) error {
|
||||
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": token, "refresh_token": refreshToken}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ar *AccountRepository) Logout(ctx context.Context, id uint) error {
|
||||
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", "").Error; err != nil {
|
||||
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(map[string]interface{}{"token": "", "refresh_token": ""}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ar *AccountRepository) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error {
|
||||
return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", accountID).Update("avatar_url", avatarURL).Error
|
||||
}
|
||||
|
||||
func (ar *AccountRepository) UpdateToken(ctx context.Context, id uint, token string) error {
|
||||
return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", token).Error
|
||||
}
|
||||
|
||||
func (ar *AccountRepository) UpdateFields(ctx context.Context, id uint, updates map[string]interface{}) error {
|
||||
return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (ar *AccountRepository) FindAll(ctx context.Context) ([]*Account, error) {
|
||||
var accounts []*Account
|
||||
if err := ar.db.WithContext(ctx).Find(&accounts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"feedsystem_video_go/internal/auth"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||
@@ -65,7 +66,7 @@ func (as *AccountService) Rename(ctx context.Context, accountID uint, newUsernam
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := as.cache.SetBytes(cacheCtx, fmt.Sprintf("account:%d", accountID), []byte(token), 24*time.Hour); err != nil {
|
||||
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", accountID), []byte(token), 24*time.Hour); err != nil {
|
||||
log.Printf("failed to set cache: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -109,31 +110,40 @@ func (as *AccountService) FindByUsername(ctx context.Context, username string) (
|
||||
}
|
||||
}
|
||||
|
||||
func (as *AccountService) Login(ctx context.Context, username, password string) (string, error) {
|
||||
func (as *AccountService) Login(ctx context.Context, username, password string) (string, string, error) {
|
||||
account, err := as.FindByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)); err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
// generate token
|
||||
token, err := auth.GenerateToken(account.ID, account.Username)
|
||||
accessToken, err := auth.GenerateToken(account.ID, account.Username)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
if err := as.accountRepository.Login(ctx, account.ID, token); err != nil {
|
||||
return "", err
|
||||
refreshToken, err := auth.GenerateRefreshToken(account.ID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := as.accountRepository.Login(ctx, account.ID, accessToken, refreshToken); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if as.cache != nil {
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := as.cache.SetBytes(cacheCtx, fmt.Sprintf("account:%d", account.ID), []byte(token), 24*time.Hour); err != nil {
|
||||
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(accessToken), 24*time.Hour); err != nil {
|
||||
log.Printf("failed to set cache: %v", err)
|
||||
}
|
||||
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d:refresh", account.ID), []byte(refreshToken), 7*24*time.Hour); err != nil {
|
||||
log.Printf("failed to set refresh cache: %v", err)
|
||||
}
|
||||
return token, nil
|
||||
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken), []byte(strconv.FormatUint(uint64(account.ID), 10)), 7*24*time.Hour); err != nil {
|
||||
log.Printf("failed to set refresh lookup: %v", err)
|
||||
}
|
||||
}
|
||||
return accessToken, refreshToken, nil
|
||||
}
|
||||
|
||||
func (as *AccountService) Logout(ctx context.Context, accountID uint) error {
|
||||
@@ -148,9 +158,79 @@ func (as *AccountService) Logout(ctx context.Context, accountID uint) error {
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := as.cache.Del(cacheCtx, fmt.Sprintf("account:%d", account.ID)); err != nil {
|
||||
if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d", account.ID)); err != nil {
|
||||
log.Printf("failed to del cache: %v", err)
|
||||
}
|
||||
if err := as.cache.Del(cacheCtx, as.cache.Key("account:%d:refresh", account.ID)); err != nil {
|
||||
log.Printf("failed to del refresh cache: %v", err)
|
||||
}
|
||||
if account.RefreshToken != "" {
|
||||
as.cache.Del(cacheCtx, as.cache.Key("refresh:%s", account.RefreshToken))
|
||||
}
|
||||
}
|
||||
return as.accountRepository.Logout(ctx, account.ID)
|
||||
}
|
||||
|
||||
func (as *AccountService) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error {
|
||||
return as.accountRepository.UpdateAvatar(ctx, accountID, avatarURL)
|
||||
}
|
||||
|
||||
func (as *AccountService) FindAll(ctx context.Context) ([]*Account, error) {
|
||||
return as.accountRepository.FindAll(ctx)
|
||||
}
|
||||
|
||||
func (as *AccountService) UpdateProfile(ctx context.Context, accountID uint, req *UpdateProfileRequest) error {
|
||||
updates := map[string]interface{}{}
|
||||
if req.Bio != "" {
|
||||
updates["bio"] = strings.TrimSpace(req.Bio)
|
||||
}
|
||||
if req.AvatarURL != "" {
|
||||
updates["avatar_url"] = strings.TrimSpace(req.AvatarURL)
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return errors.New("nothing to update")
|
||||
}
|
||||
return as.accountRepository.UpdateFields(ctx, accountID, updates)
|
||||
}
|
||||
|
||||
func (as *AccountService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, uint, string, error) {
|
||||
if refreshToken == "" {
|
||||
return "", 0, "", errors.New("refresh token is empty")
|
||||
}
|
||||
if as.cache != nil {
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
b, err := as.cache.GetBytes(cacheCtx, as.cache.Key("refresh:%s", refreshToken))
|
||||
if err == nil {
|
||||
idStr := string(b)
|
||||
id, parseErr := strconv.ParseUint(idStr, 10, 64)
|
||||
if parseErr == nil {
|
||||
account, err := as.FindByID(ctx, uint(id))
|
||||
if err == nil && account != nil && account.RefreshToken == refreshToken {
|
||||
newToken, err := auth.GenerateToken(account.ID, account.Username)
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
as.accountRepository.UpdateToken(ctx, account.ID, newToken)
|
||||
as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d", account.ID), []byte(newToken), 24*time.Hour)
|
||||
return newToken, account.ID, account.Username, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
accounts, err := as.FindAll(ctx)
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
for _, acc := range accounts {
|
||||
if acc.RefreshToken == refreshToken {
|
||||
newToken, err := auth.GenerateToken(acc.ID, acc.Username)
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
as.accountRepository.UpdateToken(ctx, acc.ID, newToken)
|
||||
return newToken, acc.ID, acc.Username, nil
|
||||
}
|
||||
}
|
||||
return "", 0, "", errors.New("invalid refresh token")
|
||||
}
|
||||
|
||||
28
backend/internal/apierror/errors.go
Normal file
28
backend/internal/apierror/errors.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package apierror
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrValidation = errors.New("validation error")
|
||||
)
|
||||
|
||||
func ClassifyHTTPStatus(err error) int {
|
||||
switch {
|
||||
case err == nil:
|
||||
return http.StatusOK
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
return http.StatusUnauthorized
|
||||
case errors.Is(err, ErrValidation):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return http.StatusNotFound
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -12,7 +15,13 @@ import (
|
||||
func jwtSecret() []byte {
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" {
|
||||
secret = "change-me-in-env"
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
log.Printf("FATAL: cannot generate JWT secret: %v", err)
|
||||
return []byte("fallback-unsafe-key-change-me")
|
||||
}
|
||||
secret = hex.EncodeToString(b)
|
||||
log.Printf("WARNING: JWT_SECRET not set, generated random key. All tokens invalid on restart.")
|
||||
}
|
||||
return []byte(secret)
|
||||
}
|
||||
@@ -30,7 +39,7 @@ func GenerateToken(accountID uint, username string) (string, error) {
|
||||
AccountID: accountID,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
@@ -41,6 +50,14 @@ func GenerateToken(accountID uint, username string) (string, error) {
|
||||
return token.SignedString(jwtSecret())
|
||||
}
|
||||
|
||||
func GenerateRefreshToken(accountID uint) (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenString,
|
||||
|
||||
@@ -3,8 +3,10 @@ package db
|
||||
import (
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/config"
|
||||
"feedsystem_video_go/internal/message"
|
||||
"feedsystem_video_go/internal/social"
|
||||
"feedsystem_video_go/internal/video"
|
||||
"feedsystem_video_go/internal/worker"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
@@ -24,7 +26,11 @@ func NewDB(dbcfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||
}
|
||||
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(&account.Account{}, &video.Video{}, &video.Like{}, &video.Comment{}, &social.Social{}, &video.OutboxMsg{})
|
||||
return db.AutoMigrate(
|
||||
&account.Account{}, &video.Video{}, &video.Like{}, &video.Comment{},
|
||||
&social.Social{}, &video.OutboxMsg{}, &video.Tag{}, &video.VideoTag{},
|
||||
&message.Message{}, &worker.Notification{},
|
||||
)
|
||||
}
|
||||
|
||||
func CloseDB(db *gorm.DB) error {
|
||||
|
||||
@@ -2,6 +2,7 @@ package feed
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -18,7 +19,7 @@ func NewFeedHandler(service *FeedService) *FeedHandler {
|
||||
func (f *FeedHandler) ListLatest(c *gin.Context) {
|
||||
var req ListLatestRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 50 {
|
||||
@@ -44,7 +45,7 @@ func (f *FeedHandler) ListLatest(c *gin.Context) {
|
||||
func (f *FeedHandler) ListLikesCount(c *gin.Context) {
|
||||
var req ListLikesCountRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 50 {
|
||||
@@ -93,7 +94,7 @@ func (f *FeedHandler) ListLikesCount(c *gin.Context) {
|
||||
func (f *FeedHandler) ListByFollowing(c *gin.Context) {
|
||||
var req ListByFollowingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 50 {
|
||||
@@ -119,7 +120,7 @@ func (f *FeedHandler) ListByFollowing(c *gin.Context) {
|
||||
func (f *FeedHandler) ListByPopularity(c *gin.Context) {
|
||||
var req ListByPopularityRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 50 {
|
||||
@@ -173,3 +174,28 @@ func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem {
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (h *FeedHandler) ListByTag(c *gin.Context) {
|
||||
var req struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.TagName == "" {
|
||||
c.JSON(400, gin.H{"error": "tag_name is required"})
|
||||
return
|
||||
}
|
||||
if req.Limit <= 0 || req.Limit > 50 {
|
||||
req.Limit = 10
|
||||
}
|
||||
viewerAccountID, _ := jwt.GetAccountID(c)
|
||||
items, err := h.service.ListByTag(c.Request.Context(), req.TagName, req.Limit, viewerAccountID)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"video_list": nonNilFeedVideoItems(items)})
|
||||
}
|
||||
|
||||
@@ -101,3 +101,15 @@ func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.
|
||||
}
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
func (repo *FeedRepository) ListByTag(ctx context.Context, tagName string, limit int) ([]*video.Video, error) {
|
||||
var videos []*video.Video
|
||||
err := repo.db.WithContext(ctx).Model(&video.Video{}).Table("videos").
|
||||
Joins("JOIN video_tags ON video_tags.video_id = videos.id").
|
||||
Joins("JOIN tags ON tags.id = video_tags.tag_id").
|
||||
Where("tags.name = ?", tagName).
|
||||
Order("videos.create_time desc").
|
||||
Limit(limit).
|
||||
Find(&videos).Error
|
||||
return videos, err
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*vi
|
||||
//L1:本地缓存
|
||||
var missedL1 []uint
|
||||
for _, id := range videoIDs {
|
||||
cacheKey := fmt.Sprintf("video:entity:%d", id)
|
||||
cacheKey := f.rediscache.Key("video:entity:%d", id)
|
||||
if f.localcache != nil {
|
||||
if v, found := f.localcache.Get(cacheKey); found {
|
||||
if data, ok := v.(video.Video); ok {
|
||||
@@ -66,7 +66,7 @@ func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*vi
|
||||
if len(missedL1) > 0 {
|
||||
cacheKeys := make([]string, len(missedL1))
|
||||
for i, id := range missedL1 {
|
||||
cacheKeys[i] = fmt.Sprintf("video:entity:%d", id)
|
||||
cacheKeys[i] = f.rediscache.Key("video:entity:%d", id)
|
||||
}
|
||||
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
@@ -109,7 +109,7 @@ func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*vi
|
||||
wg.Add(1)
|
||||
go func(videoID uint) {
|
||||
defer wg.Done()
|
||||
sfKey := fmt.Sprintf("sf:entity:%d", videoID)
|
||||
sfKey := f.rediscache.Key("sf:entity:%d", videoID)
|
||||
|
||||
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
|
||||
videoList, err := f.repo.GetByIDs(ctx, []uint{videoID})
|
||||
@@ -119,7 +119,7 @@ func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*vi
|
||||
}
|
||||
|
||||
safeCopy := *videoList[0]
|
||||
cachekey := fmt.Sprintf("video:entity:%d", safeCopy.ID)
|
||||
cachekey := f.rediscache.Key("video:entity:%d", safeCopy.ID)
|
||||
if b, err := json.Marshal(safeCopy); err == nil {
|
||||
//异步回写redis
|
||||
go func(k string, b []byte) {
|
||||
@@ -137,7 +137,7 @@ func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*vi
|
||||
mu.Lock()
|
||||
videoMap[id] = &safeCopy
|
||||
mu.Unlock()
|
||||
f.localcache.Set(fmt.Sprintf("video:entity:%d", safeCopy.ID), safeCopy, 5*time.Second)
|
||||
f.localcache.Set(f.rediscache.Key("video:entity:%d", safeCopy.ID), safeCopy, 5*time.Second)
|
||||
}
|
||||
}(id)
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*vi
|
||||
// 查询最新视频 (冷热分离 + 游标分页)
|
||||
func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore time.Time, viewerAccountID uint) (ListLatestResponse, error) {
|
||||
// 获取 ZSET 中最老的一条数据
|
||||
zsetTail, err := f.rediscache.ZRangeWithScores(ctx, "feed:global_timeline", 0, 0)
|
||||
zsetTail, err := f.rediscache.ZRangeWithScores(ctx, f.rediscache.Key("feed:global_timeline"), 0, 0)
|
||||
|
||||
if err != nil {
|
||||
return ListLatestResponse{}, err
|
||||
@@ -158,7 +158,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
|
||||
|
||||
if isZsetEmpty {
|
||||
//全局静态锁:无视所有用户的不同时间戳游标
|
||||
sfKey := "sf:fallback:global_timeline_rebuild"
|
||||
sfKey := f.rediscache.Key("sf:fallback:global_timeline_rebuild")
|
||||
|
||||
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
|
||||
// 无视游标,直接去 MySQL 捞最新的 1000 条
|
||||
@@ -180,7 +180,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
|
||||
Member: fmt.Sprintf("%d", vid.ID),
|
||||
})
|
||||
}
|
||||
f.rediscache.ZAdd(bgCtx, "feed:global_timeline", zElements...)
|
||||
f.rediscache.ZAdd(bgCtx, f.rediscache.Key("feed:global_timeline"), zElements...)
|
||||
return "SUCCESS", nil
|
||||
})
|
||||
|
||||
@@ -207,7 +207,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
|
||||
//冷数据降级查库
|
||||
|
||||
// 针对个别用户的防并发(此时可以用时间戳做锁,因为冷尾流量极小)
|
||||
sfKey := fmt.Sprintf("sf:cold:listLatest:%d:%d", limit, reqTime)
|
||||
sfKey := f.rediscache.Key("sf:cold:listLatest:%d:%d", limit, reqTime)
|
||||
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
|
||||
return f.repo.ListLatest(ctx, limit, latestBefore)
|
||||
})
|
||||
@@ -224,7 +224,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
|
||||
maxScore = fmt.Sprintf("%d", reqTime-1) // 防重复
|
||||
}
|
||||
|
||||
videoIDsStr, err := f.rediscache.ZRevRangeByScore(ctx, "feed:global_timeline", maxScore, "-inf", 0, int64(limit))
|
||||
videoIDsStr, err := f.rediscache.ZRevRangeByScore(ctx, f.rediscache.Key("feed:global_timeline"), maxScore, "-inf", 0, int64(limit))
|
||||
if err != nil {
|
||||
return ListLatestResponse{}, err
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
|
||||
coldCursor = latestBefore
|
||||
}
|
||||
|
||||
sfKey := fmt.Sprintf("sf:stitch:listLatest:%d:%d", remainLimit, coldCursor.UnixMilli())
|
||||
sfKey := f.rediscache.Key("sf:stitch:listLatest:%d:%d", remainLimit, coldCursor.UnixMilli())
|
||||
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
|
||||
return f.repo.ListLatest(ctx, remainLimit, coldCursor)
|
||||
})
|
||||
@@ -343,7 +343,7 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo
|
||||
if !latestBefore.IsZero() {
|
||||
before = latestBefore.Unix()
|
||||
}
|
||||
cacheKey = fmt.Sprintf("feed:listByFollowing:limit=%d:accountID=%d:before=%d", limit, viewerAccountID, before)
|
||||
cacheKey = f.rediscache.Key("feed:listByFollowing:limit=%d:accountID=%d:before=%d", limit, viewerAccountID, before)
|
||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
@@ -413,10 +413,10 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
|
||||
const win = 60
|
||||
keys := make([]string, 0, win)
|
||||
for i := 0; i < win; i++ {
|
||||
keys = append(keys, "hot:video:1m:"+asOf.Add(-time.Duration(i)*time.Minute).Format("200601021504"))
|
||||
keys = append(keys, f.rediscache.Key("hot:video:1m:%s", asOf.Add(-time.Duration(i)*time.Minute).Format("200601021504")))
|
||||
}
|
||||
|
||||
dest := "hot:video:merge:1m:" + asOf.Format("200601021504") // 快照key:同一个as_of页内复用
|
||||
dest := f.rediscache.Key("hot:video:merge:1m:%s", asOf.Format("200601021504")) // 快照key:同一个as_of页内复用
|
||||
opCtx, cancel := context.WithTimeout(ctx, 80*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
@@ -545,3 +545,11 @@ func buildOrderedResult(orderedIDs []uint, dataMap map[uint]*video.Video) []*vid
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (f *FeedService) ListByTag(ctx context.Context, tagName string, limit int, viewerAccountID uint) ([]FeedVideoItem, error) {
|
||||
videos, err := f.repo.ListByTag(ctx, tagName, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.buildFeedVideos(ctx, videos, viewerAccountID)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/feed"
|
||||
"feedsystem_video_go/internal/message"
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
"feedsystem_video_go/internal/middleware/ratelimit"
|
||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||
@@ -41,12 +43,15 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g
|
||||
accountGroup.POST("/changePassword", accountHandler.ChangePassword)
|
||||
accountGroup.POST("/findByID", accountHandler.FindByID)
|
||||
accountGroup.POST("/findByUsername", accountHandler.FindByUsername)
|
||||
accountGroup.POST("/refresh", accountHandler.Refresh)
|
||||
}
|
||||
protectedAccountGroup := accountGroup.Group("")
|
||||
protectedAccountGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
||||
{
|
||||
protectedAccountGroup.POST("/logout", accountHandler.Logout)
|
||||
protectedAccountGroup.POST("/rename", accountHandler.Rename)
|
||||
protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar)
|
||||
protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile)
|
||||
}
|
||||
// video
|
||||
videoRepository := video.NewVideoRepository(db)
|
||||
@@ -123,7 +128,35 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g
|
||||
protectedSocialGroup.POST("/unfollow", socialLimiter, socialHandler.Unfollow)
|
||||
protectedSocialGroup.POST("/getAllFollowers", socialHandler.GetAllFollowers)
|
||||
protectedSocialGroup.POST("/getAllVloggers", socialHandler.GetAllVloggers)
|
||||
protectedSocialGroup.POST("/getCounts", socialHandler.GetCounts)
|
||||
}
|
||||
|
||||
accountGroup.POST("/getProfile", func(c *gin.Context) {
|
||||
var req account.GetProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.AccountID == 0 {
|
||||
c.JSON(400, gin.H{"error": "account_id is required"})
|
||||
return
|
||||
}
|
||||
acc, err := accountService.FindByID(c.Request.Context(), req.AccountID)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
videoCount, _ := videoRepository.CountByAuthor(c.Request.Context(), req.AccountID)
|
||||
totalLikes, _ := videoRepository.TotalLikesByAuthor(c.Request.Context(), req.AccountID)
|
||||
followerCount, _ := socialRepository.CountFollowers(c.Request.Context(), req.AccountID)
|
||||
vloggerCount, _ := socialRepository.CountVloggers(c.Request.Context(), req.AccountID)
|
||||
|
||||
c.JSON(200, account.GetProfileResponse{
|
||||
Account: account.FindByIDResponse{ID: acc.ID, Username: acc.Username, AvatarURL: acc.AvatarURL, Bio: acc.Bio},
|
||||
VideoCount: videoCount, TotalLikes: totalLikes,
|
||||
FollowerCount: followerCount, VloggerCount: vloggerCount,
|
||||
})
|
||||
})
|
||||
// feed
|
||||
feedRepository := feed.NewFeedRepository(db)
|
||||
feedService := feed.NewFeedService(feedRepository, likeRepository, cache)
|
||||
@@ -134,19 +167,71 @@ func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *g
|
||||
feedGroup.POST("/listLatest", feedHandler.ListLatest)
|
||||
feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount)
|
||||
feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity)
|
||||
feedGroup.POST("/listByTag", feedHandler.ListByTag)
|
||||
}
|
||||
protectedFeedGroup := feedGroup.Group("")
|
||||
protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
||||
{
|
||||
protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing)
|
||||
}
|
||||
// message
|
||||
messageRepo := message.NewRepository(db)
|
||||
messageService := message.NewService(messageRepo)
|
||||
messageHandler := message.NewHandler(messageService)
|
||||
messageGroup := r.Group("/message")
|
||||
protectedMessageGroup := messageGroup.Group("")
|
||||
protectedMessageGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
||||
{
|
||||
protectedMessageGroup.POST("/send", messageHandler.Send)
|
||||
protectedMessageGroup.POST("/list", messageHandler.List)
|
||||
}
|
||||
//worker
|
||||
timelineMQ, err := rabbitmq.NewTimelineMQ(rmq)
|
||||
if err != nil {
|
||||
log.Printf("timelineMQ init failed (mq disabled): %v", err)
|
||||
socialMQ = nil
|
||||
timelineMQ = nil
|
||||
}
|
||||
worker.StartOutboxPoller(db, timelineMQ)
|
||||
worker.StartConsumer(timelineMQ, "video.timeline.update.queue", cache)
|
||||
|
||||
// SSE notification
|
||||
if rmq != nil && rmq.Ch != nil {
|
||||
rmq.DeclareTopic("like.events", "notification.like", "like.like")
|
||||
rmq.DeclareTopic("comment.events", "notification.comment", "comment.publish")
|
||||
rmq.DeclareTopic("social.events", "notification.social", "social.follow")
|
||||
}
|
||||
sseHub := worker.NewSSEHub(db)
|
||||
notifGroup := r.Group("/notification")
|
||||
notifGroup.Use(sseHub.SSERequireAuth())
|
||||
sseHub.RegisterRoutes(r, notifGroup)
|
||||
|
||||
go func() {
|
||||
if rmq != nil && rmq.Ch != nil {
|
||||
hub := sseHub
|
||||
ctx := context.Background()
|
||||
// consume from like queue
|
||||
go func() {
|
||||
w := worker.NewNotificationWorker(rmq.Ch, db, "notification.like", hub)
|
||||
if err := w.Run(ctx); err != nil {
|
||||
log.Printf("notification-like worker: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
w := worker.NewNotificationWorker(rmq.Ch, db, "notification.comment", hub)
|
||||
if err := w.Run(ctx); err != nil {
|
||||
log.Printf("notification-comment worker: %v", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
w := worker.NewNotificationWorker(rmq.Ch, db, "notification.social", hub)
|
||||
if err := w.Run(ctx); err != nil {
|
||||
log.Printf("notification-social worker: %v", err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Printf("Notification SSE disabled (MQ not available)")
|
||||
}
|
||||
}()
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
25
backend/internal/message/entity.go
Normal file
25
backend/internal/message/entity.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package message
|
||||
|
||||
import "time"
|
||||
|
||||
type Message struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
FromID uint `gorm:"index:idx_message_from;not null" json:"from_id"`
|
||||
ToID uint `gorm:"index:idx_message_to;not null" json:"to_id"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
type SendRequest struct {
|
||||
ToID uint `json:"to_id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
PeerID uint `json:"peer_id"`
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
95
backend/internal/message/handler.go
Normal file
95
backend/internal/message/handler.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository struct{ db *gorm.DB }
|
||||
type Service struct{ repo *Repository }
|
||||
type Handler struct{ service *Service }
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
|
||||
func NewService(repo *Repository) *Service { return &Service{repo: repo} }
|
||||
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
|
||||
|
||||
func (r *Repository) AutoMigrate(ctx context.Context) error {
|
||||
return r.db.WithContext(ctx).AutoMigrate(&Message{})
|
||||
}
|
||||
|
||||
func (r *Repository) Send(ctx context.Context, m *Message) error {
|
||||
m.Content = strings.TrimSpace(m.Content)
|
||||
if m.Content == "" {
|
||||
return errors.New("content is required")
|
||||
}
|
||||
m.CreatedAt = time.Now()
|
||||
return r.db.WithContext(ctx).Create(m).Error
|
||||
}
|
||||
|
||||
func (r *Repository) List(ctx context.Context, userID, peerID uint, limit int) ([]Message, error) {
|
||||
var msgs []Message
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("(from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?)", userID, peerID, peerID, userID).
|
||||
Order("created_at desc").
|
||||
Limit(limit).
|
||||
Find(&msgs).Error
|
||||
return msgs, err
|
||||
}
|
||||
|
||||
func (h *Handler) Send(c *gin.Context) {
|
||||
fromID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var req SendRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.ToID == 0 || strings.TrimSpace(req.Content) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "to_id and content are required"})
|
||||
return
|
||||
}
|
||||
m := &Message{FromID: fromID, ToID: req.ToID, Content: req.Content}
|
||||
if err := h.service.repo.Send(c.Request.Context(), m); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
userID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var req ListRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.PeerID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "peer_id is required"})
|
||||
return
|
||||
}
|
||||
msgs, err := h.service.repo.List(c.Request.Context(), userID, req.PeerID, 50)
|
||||
if err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if msgs == nil {
|
||||
msgs = []Message{}
|
||||
}
|
||||
c.JSON(http.StatusOK, ListResponse{Messages: msgs})
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package jwt
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -69,7 +68,7 @@ func SoftJWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Clien
|
||||
}
|
||||
|
||||
func check(c *gin.Context, claims *auth.Claims, tokenString string, accountRepo *account.AccountRepository, cache *rediscache.Client) {
|
||||
key := fmt.Sprintf("account:%d", claims.AccountID)
|
||||
key := cache.Key("account:%d", claims.AccountID)
|
||||
|
||||
// 先查 Redis
|
||||
if cache != nil {
|
||||
|
||||
53
backend/internal/middleware/rabbitmq/dlx.go
Normal file
53
backend/internal/middleware/rabbitmq/dlx.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package rabbitmq
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
const (
|
||||
DLXExchange = "dlx.events"
|
||||
MaxRetryCount = 3
|
||||
)
|
||||
|
||||
// DeclareDLX 声明死信交换机和对应的死信队列
|
||||
func DeclareDLX(ch *amqp.Channel, queueName string) error {
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
if err := ch.ExchangeDeclare(
|
||||
DLXExchange, "topic", true, false, false, false, nil,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
dlxQueue := queueName + ".dlx"
|
||||
_, err := ch.QueueDeclare(
|
||||
dlxQueue, true, false, false, false, nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ch.QueueBind(dlxQueue, "#", DLXExchange, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("DLX ready: exchange=%s queue=%s", DLXExchange, dlxQueue)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRetryCount 从 AMQP x-death header 中提取当前消息已被重试的次数
|
||||
func GetRetryCount(d amqp.Delivery) int {
|
||||
deaths, ok := d.Headers["x-death"].([]interface{})
|
||||
if !ok || len(deaths) == 0 {
|
||||
return 0
|
||||
}
|
||||
death, ok := deaths[0].(amqp.Table)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
count, ok := death["count"].(int64)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int(count)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"feedsystem_video_go/internal/config"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -73,19 +74,25 @@ func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
amqp.Table{"x-dead-letter-exchange": DLXExchange},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.Ch.QueueBind(
|
||||
if err := r.Ch.QueueBind(
|
||||
q.Name,
|
||||
bindingKey,
|
||||
exchange,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := DeclareDLX(r.Ch, queue); err != nil {
|
||||
log.Printf("DLX declare failed for %s: %v", queue, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RabbitMQ) PublishJSON(ctx context.Context, exchange string, routingKey string, payload any) error {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"feedsystem_video_go/internal/config"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -13,15 +14,18 @@ import (
|
||||
|
||||
type Client struct {
|
||||
rdb *redis.Client
|
||||
keyPrefix string
|
||||
}
|
||||
|
||||
const defaultKeyPrefix = "v1:"
|
||||
|
||||
func NewFromEnv(cfg *config.RedisConfig) (*Client, error) {
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port),
|
||||
Password: cfg.Password,
|
||||
DB: cfg.DB,
|
||||
})
|
||||
return &Client{rdb: rdb}, nil
|
||||
return &Client{rdb: rdb, keyPrefix: defaultKeyPrefix}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
@@ -42,6 +46,14 @@ func IsMiss(err error) bool {
|
||||
return err == redis.Nil
|
||||
}
|
||||
|
||||
func (c *Client) Key(format string, args ...any) string {
|
||||
prefix := ""
|
||||
if c != nil {
|
||||
prefix = c.keyPrefix
|
||||
}
|
||||
return prefix + fmt.Sprintf(format, args...)
|
||||
}
|
||||
|
||||
func randToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -22,12 +22,19 @@ type GetAllFollowersRequest struct {
|
||||
|
||||
type GetAllFollowersResponse struct {
|
||||
Followers []*account.Account `json:"followers"`
|
||||
FollowerCount int64 `json:"follower_count"`
|
||||
}
|
||||
|
||||
type GetAllVloggersResponse struct {
|
||||
Vloggers []*account.Account `json:"vloggers"`
|
||||
VloggerCount int64 `json:"vlogger_count"`
|
||||
}
|
||||
|
||||
type SocialCounts struct {
|
||||
FollowerCount int64 `json:"follower_count"`
|
||||
VloggerCount int64 `json:"vlogger_count"`
|
||||
}
|
||||
|
||||
type GetAllVloggersRequest struct {
|
||||
FollowerID uint `json:"follower_id"`
|
||||
}
|
||||
|
||||
type GetAllVloggersResponse struct {
|
||||
Vloggers []*account.Account `json:"vloggers"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package social
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
"net/http"
|
||||
|
||||
@@ -19,7 +20,7 @@ func NewSocialHandler(service *SocialService) *SocialHandler {
|
||||
func (h *SocialHandler) Follow(c *gin.Context) {
|
||||
var req FollowRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VloggerID <= 0 {
|
||||
@@ -36,7 +37,7 @@ func (h *SocialHandler) Follow(c *gin.Context) {
|
||||
VloggerID: req.VloggerID,
|
||||
}
|
||||
if err := h.service.Follow(c.Request.Context(), social); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "followed"})
|
||||
@@ -45,7 +46,7 @@ func (h *SocialHandler) Follow(c *gin.Context) {
|
||||
func (h *SocialHandler) Unfollow(c *gin.Context) {
|
||||
var req UnfollowRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VloggerID <= 0 {
|
||||
@@ -62,7 +63,7 @@ func (h *SocialHandler) Unfollow(c *gin.Context) {
|
||||
VloggerID: req.VloggerID,
|
||||
}
|
||||
if err := h.service.Unfollow(c.Request.Context(), social); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "unfollowed"})
|
||||
@@ -71,7 +72,7 @@ func (h *SocialHandler) Unfollow(c *gin.Context) {
|
||||
func (h *SocialHandler) GetAllFollowers(c *gin.Context) {
|
||||
var req GetAllFollowersRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,19 +88,20 @@ func (h *SocialHandler) GetAllFollowers(c *gin.Context) {
|
||||
|
||||
followers, err := h.service.GetAllFollowers(c.Request.Context(), vloggerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if followers == nil {
|
||||
followers = []*account.Account{}
|
||||
}
|
||||
c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers})
|
||||
followerCount, _ := h.service.CountFollowers(c.Request.Context(), vloggerID)
|
||||
c.JSON(http.StatusOK, GetAllFollowersResponse{Followers: followers, FollowerCount: followerCount})
|
||||
}
|
||||
|
||||
func (h *SocialHandler) GetAllVloggers(c *gin.Context) {
|
||||
var req GetAllVloggersRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -115,11 +117,23 @@ func (h *SocialHandler) GetAllVloggers(c *gin.Context) {
|
||||
|
||||
vloggers, err := h.service.GetAllVloggers(c.Request.Context(), followerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if vloggers == nil {
|
||||
vloggers = []*account.Account{}
|
||||
}
|
||||
c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers})
|
||||
vloggerCount, _ := h.service.CountVloggers(c.Request.Context(), followerID)
|
||||
c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers, VloggerCount: vloggerCount})
|
||||
}
|
||||
|
||||
func (h *SocialHandler) GetCounts(c *gin.Context) {
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
followerCount, _ := h.service.CountFollowers(c.Request.Context(), accountID)
|
||||
vloggerCount, _ := h.service.CountVloggers(c.Request.Context(), accountID)
|
||||
c.JSON(http.StatusOK, SocialCounts{FollowerCount: followerCount, VloggerCount: vloggerCount})
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func (r *SocialRepository) GetAllFollowers(ctx context.Context, VloggerID uint)
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&Social{}).
|
||||
Where("vlogger_id = ?", VloggerID).
|
||||
Limit(200).
|
||||
Find(&relations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -57,6 +58,7 @@ func (r *SocialRepository) GetAllVloggers(ctx context.Context, FollowerID uint)
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&Social{}).
|
||||
Where("follower_id = ?", FollowerID).
|
||||
Limit(200).
|
||||
Find(&relations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,3 +91,19 @@ func (r *SocialRepository) IsFollowed(ctx context.Context, social *Social) (bool
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *SocialRepository) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.WithContext(ctx).Model(&Social{}).Where("vlogger_id = ?", vloggerID).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *SocialRepository) CountVloggers(ctx context.Context, followerID uint) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.WithContext(ctx).Model(&Social{}).Where("follower_id = ?", followerID).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -80,6 +80,14 @@ func (s *SocialService) GetAllVloggers(ctx context.Context, FollowerID uint) ([]
|
||||
return s.repo.GetAllVloggers(ctx, FollowerID)
|
||||
}
|
||||
|
||||
func (s *SocialService) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) {
|
||||
return s.repo.CountFollowers(ctx, vloggerID)
|
||||
}
|
||||
|
||||
func (s *SocialService) CountVloggers(ctx context.Context, followerID uint) (int64, error) {
|
||||
return s.repo.CountVloggers(ctx, followerID)
|
||||
}
|
||||
|
||||
func (s *SocialService) IsFollowed(ctx context.Context, social *Social) (bool, error) {
|
||||
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package video
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -18,7 +19,7 @@ func NewCommentHandler(service *CommentService, accountService *account.AccountS
|
||||
func (h *CommentHandler) PublishComment(c *gin.Context) {
|
||||
var req PublishCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
@@ -31,12 +32,12 @@ func (h *CommentHandler) PublishComment(c *gin.Context) {
|
||||
}
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.accountService.FindByID(c.Request.Context(), authorId)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
comment := &Comment{
|
||||
@@ -46,7 +47,7 @@ func (h *CommentHandler) PublishComment(c *gin.Context) {
|
||||
Content: req.Content,
|
||||
}
|
||||
if err := h.service.Publish(c.Request.Context(), comment); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "comment published successfully"})
|
||||
@@ -55,12 +56,12 @@ func (h *CommentHandler) PublishComment(c *gin.Context) {
|
||||
func (h *CommentHandler) DeleteComment(c *gin.Context) {
|
||||
var req DeleteCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.CommentID <= 0 {
|
||||
@@ -68,7 +69,7 @@ func (h *CommentHandler) DeleteComment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,7 +79,7 @@ func (h *CommentHandler) DeleteComment(c *gin.Context) {
|
||||
func (h *CommentHandler) GetAllComments(c *gin.Context) {
|
||||
var req GetAllCommentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID == 0 {
|
||||
@@ -87,7 +88,7 @@ func (h *CommentHandler) GetAllComments(c *gin.Context) {
|
||||
}
|
||||
comments, err := h.service.GetAll(c.Request.Context(), req.VideoID)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if comments == nil {
|
||||
|
||||
@@ -24,7 +24,11 @@ func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment)
|
||||
|
||||
func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) {
|
||||
var comments []Comment
|
||||
err := r.db.WithContext(ctx).Where("video_id = ?", videoID).Find(&comments).Error
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("video_id = ?", videoID).
|
||||
Order("created_at asc").
|
||||
Limit(200).
|
||||
Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -56,6 +58,7 @@ func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
|
||||
}
|
||||
}
|
||||
if mysqlEnqueued && redisEnqueued {
|
||||
s.notifyMentions(ctx, comment)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -82,6 +85,7 @@ func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
|
||||
if !redisEnqueued {
|
||||
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
|
||||
}
|
||||
s.notifyMentions(ctx, comment)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,7 +98,7 @@ func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID u
|
||||
return errors.New("comment not found")
|
||||
}
|
||||
if comment.AuthorID != accountID {
|
||||
return errors.New("permission denied")
|
||||
return apierror.ErrUnauthorized
|
||||
}
|
||||
if s.commentMQ != nil {
|
||||
if err := s.commentMQ.Delete(ctx, commentID); err == nil {
|
||||
@@ -114,3 +118,38 @@ func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, e
|
||||
}
|
||||
return s.repo.GetAllComments(ctx, videoID)
|
||||
}
|
||||
|
||||
var mentionRegex = regexp.MustCompile(`@(\w+)`)
|
||||
|
||||
func (s *CommentService) notifyMentions(ctx context.Context, comment *Comment) {
|
||||
matches := mentionRegex.FindAllStringSubmatch(comment.Content, -1)
|
||||
if len(matches) == 0 {
|
||||
return
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range matches {
|
||||
username := m[1]
|
||||
if seen[username] || username == comment.Username {
|
||||
continue
|
||||
}
|
||||
seen[username] = true
|
||||
var accID uint
|
||||
if err := s.repo.db.WithContext(ctx).Table("accounts").Where("username = ?", username).Select("id").Scan(&accID).Error; err != nil || accID == 0 {
|
||||
continue
|
||||
}
|
||||
notif := struct {
|
||||
RecipientID uint
|
||||
SenderID uint
|
||||
Type string
|
||||
TargetID uint
|
||||
Content string
|
||||
}{
|
||||
RecipientID: accID,
|
||||
SenderID: comment.AuthorID,
|
||||
Type: "mention",
|
||||
TargetID: comment.VideoID,
|
||||
Content: comment.Username + " 在评论中提到了你",
|
||||
}
|
||||
s.repo.db.WithContext(ctx).Table("notifications").Create(¬if)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package video
|
||||
|
||||
import (
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -17,7 +18,7 @@ func NewLikeHandler(service *LikeService) *LikeHandler {
|
||||
func (lh *LikeHandler) Like(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
@@ -27,7 +28,7 @@ func (lh *LikeHandler) Like(c *gin.Context) {
|
||||
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -45,7 +46,7 @@ func (lh *LikeHandler) Like(c *gin.Context) {
|
||||
func (lh *LikeHandler) Unlike(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
@@ -55,7 +56,7 @@ func (lh *LikeHandler) Unlike(c *gin.Context) {
|
||||
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -73,7 +74,7 @@ func (lh *LikeHandler) Unlike(c *gin.Context) {
|
||||
func (lh *LikeHandler) IsLiked(c *gin.Context) {
|
||||
var req LikeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.VideoID <= 0 {
|
||||
@@ -83,7 +84,7 @@ func (lh *LikeHandler) IsLiked(c *gin.Context) {
|
||||
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID)
|
||||
@@ -97,7 +98,7 @@ func (lh *LikeHandler) IsLiked(c *gin.Context) {
|
||||
func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) {
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ func (r *LikeRepository) ListLikedVideos(ctx context.Context, accountID uint) ([
|
||||
Joins("JOIN likes ON likes.video_id = videos.id").
|
||||
Where("likes.account_id = ?", accountID).
|
||||
Order("likes.created_at desc").
|
||||
Limit(200).
|
||||
Find(&videos).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -2,7 +2,6 @@ package video
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -15,10 +14,10 @@ func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uin
|
||||
return
|
||||
}
|
||||
|
||||
_ = cache.Del(context.Background(), fmt.Sprintf("video:detail:id=%d", id))
|
||||
_ = cache.Del(context.Background(), cache.Key("video:detail:id=%d", id))
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
windowKey := "hot:video:1m:" + now.Format("200601021504")
|
||||
windowKey := cache.Key("hot:video:1m:%s", now.Format("200601021504"))
|
||||
member := strconv.FormatUint(uint64(id), 10)
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
|
||||
30
backend/internal/video/tag_entity.go
Normal file
30
backend/internal/video/tag_entity.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package video
|
||||
|
||||
import "regexp"
|
||||
|
||||
type Tag struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;type:varchar(100);not null" json:"name"`
|
||||
}
|
||||
|
||||
type VideoTag struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
VideoID uint `gorm:"index;not null"`
|
||||
TagID uint `gorm:"index;not null"`
|
||||
}
|
||||
|
||||
var tagRegex = regexp.MustCompile(`#([\p{L}\p{N}_]+)`)
|
||||
|
||||
func ExtractTags(text string) []string {
|
||||
matches := tagRegex.FindAllStringSubmatch(text, -1)
|
||||
seen := make(map[string]bool)
|
||||
var tags []string
|
||||
for _, m := range matches {
|
||||
tag := m[1]
|
||||
if !seen[tag] {
|
||||
seen[tag] = true
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
@@ -10,9 +10,9 @@ type Video struct {
|
||||
Description string `gorm:"type:varchar(255);" json:"description,omitempty"`
|
||||
PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"`
|
||||
CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"`
|
||||
CreateTime time.Time `gorm:"autoCreateTime" json:"create_time"`
|
||||
LikesCount int64 `gorm:"column:likes_count;not null;default:0" json:"likes_count"`
|
||||
Popularity int64 `gorm:"column:popularity;not null;default:0" json:"popularity"`
|
||||
CreateTime time.Time `gorm:"autoCreateTime;index:idx_videos_create_time,sort:desc;index:idx_videos_popularity_time_id,priority:2,sort:desc" json:"create_time"`
|
||||
LikesCount int64 `gorm:"column:likes_count;not null;default:0;index:idx_videos_likes_count_id,priority:1,sort:desc" json:"likes_count"`
|
||||
Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"`
|
||||
}
|
||||
|
||||
type PublishVideoRequest struct {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"feedsystem_video_go/internal/account"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
"feedsystem_video_go/internal/middleware/jwt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -29,18 +30,18 @@ func NewVideoHandler(service *VideoService, accountService *account.AccountServi
|
||||
func (vh *VideoHandler) PublishVideo(c *gin.Context) {
|
||||
var req PublishVideoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
username, err := jwt.GetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
video := &Video{
|
||||
@@ -53,7 +54,7 @@ func (vh *VideoHandler) PublishVideo(c *gin.Context) {
|
||||
CreateTime: time.Now(),
|
||||
}
|
||||
if err := vh.service.Publish(c.Request.Context(), video); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, video)
|
||||
@@ -93,7 +94,12 @@ func (vh *VideoHandler) UploadVideo(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := randHex(16) + ext
|
||||
filename, err := randHex(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||
return
|
||||
}
|
||||
filename = filename + ext
|
||||
absPath := filepath.Join(absDir, filename)
|
||||
|
||||
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
||||
@@ -145,7 +151,12 @@ func (vh *VideoHandler) UploadCover(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := randHex(16) + ext
|
||||
filename, err := randHex(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||
return
|
||||
}
|
||||
filename = filename + ext
|
||||
absPath := filepath.Join(absDir, filename)
|
||||
|
||||
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
||||
@@ -161,10 +172,12 @@ func (vh *VideoHandler) UploadCover(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
func randHex(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("rand.Read: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func buildAbsoluteURL(c *gin.Context, p string) string {
|
||||
@@ -181,16 +194,16 @@ func buildAbsoluteURL(c *gin.Context, p string) string {
|
||||
func (vh *VideoHandler) DeleteVideo(c *gin.Context) {
|
||||
var req DeleteVideoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authorId, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "video deleted"})
|
||||
@@ -199,12 +212,12 @@ func (vh *VideoHandler) DeleteVideo(c *gin.Context) {
|
||||
func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
|
||||
var req ListByAuthorIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if videos == nil {
|
||||
@@ -216,12 +229,12 @@ func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
|
||||
func (vh *VideoHandler) GetDetail(c *gin.Context) {
|
||||
var req GetDetailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
video, err := vh.service.GetDetail(c.Request.Context(), req.ID)
|
||||
if err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, video)
|
||||
@@ -230,11 +243,11 @@ func (vh *VideoHandler) GetDetail(c *gin.Context) {
|
||||
func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) {
|
||||
var req UpdateLikesCountRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil {
|
||||
c.JSON(400, gin.H{"error": err.Error()})
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "likes count updated"})
|
||||
|
||||
@@ -41,7 +41,7 @@ func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) (
|
||||
if err := vr.db.WithContext(ctx).
|
||||
Where("author_id = ?", authorID).
|
||||
Order("create_time desc").
|
||||
Offset(0).
|
||||
Limit(200).
|
||||
Find(&videos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -102,3 +102,19 @@ func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) CountByAuthor(ctx context.Context, authorID uint) (int64, error) {
|
||||
var count int64
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (vr *VideoRepository) TotalLikesByAuthor(ctx context.Context, authorID uint) (int64, error) {
|
||||
var total int64
|
||||
if err := vr.db.WithContext(ctx).Model(&Video{}).Where("author_id = ?", authorID).Select("COALESCE(SUM(likes_count), 0)").Scan(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||
"feedsystem_video_go/internal/apierror"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -60,8 +60,14 @@ func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
|
||||
if err := tx.Create(&msg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
tags := ExtractTags(video.Title + " " + video.Description)
|
||||
for _, tagName := range tags {
|
||||
var tag Tag
|
||||
tx.Where("name = ?", tagName).FirstOrCreate(&tag, Tag{Name: tagName})
|
||||
tx.Create(&VideoTag{VideoID: video.ID, TagID: tag.ID})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
|
||||
@@ -76,13 +82,13 @@ func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) erro
|
||||
return errors.New("video not found")
|
||||
}
|
||||
if video.AuthorID != authorID {
|
||||
return errors.New("unauthorized")
|
||||
return apierror.ErrUnauthorized
|
||||
}
|
||||
if err := vs.repo.DeleteVideo(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if vs.cache != nil {
|
||||
cacheKey := fmt.Sprintf("video:detail:id=%d", id)
|
||||
cacheKey := vs.cache.Key("video:detail:id=%d", id)
|
||||
_ = vs.cache.Del(context.Background(), cacheKey)
|
||||
}
|
||||
return nil
|
||||
@@ -97,7 +103,7 @@ func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Vi
|
||||
}
|
||||
|
||||
func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) {
|
||||
cacheKey := fmt.Sprintf("video:detail:id=%d", id)
|
||||
cacheKey := vs.cache.Key("video:detail:id=%d", id)
|
||||
|
||||
getCached := func() (*Video, bool) {
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
@@ -203,11 +209,11 @@ func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change in
|
||||
|
||||
if vs.cache != nil {
|
||||
// 1) 详情缓存:直接失效(最简单靠谱)
|
||||
_ = vs.cache.Del(context.Background(), fmt.Sprintf("video:detail:id=%d", id))
|
||||
_ = vs.cache.Del(context.Background(), vs.cache.Key("video:detail:id=%d", id))
|
||||
|
||||
// 2) 热榜:写到“时间窗ZSET”,不要用 detail key
|
||||
now := time.Now().UTC().Truncate(time.Minute)
|
||||
windowKey := "hot:video:1m:" + now.Format("200601021504")
|
||||
windowKey := vs.cache.Key("hot:video:1m:%s", now.Format("200601021504"))
|
||||
member := strconv.FormatUint(uint64(id), 10)
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
|
||||
@@ -59,7 +59,13 @@ func (w *CommentWorker) Run(ctx context.Context) error {
|
||||
|
||||
func (w *CommentWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
||||
if err := w.process(ctx, d.Body); err != nil {
|
||||
log.Printf("comment worker: failed to process message: %v", err)
|
||||
retryCount := rabbitmq.GetRetryCount(d)
|
||||
if retryCount >= rabbitmq.MaxRetryCount {
|
||||
log.Printf("comment worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
log.Printf("comment worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,7 +58,13 @@ func (w *LikeWorker) Run(ctx context.Context) error {
|
||||
|
||||
func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
||||
if err := w.process(ctx, d.Body); err != nil {
|
||||
log.Printf("like worker: failed to process message: %v", err)
|
||||
retryCount := rabbitmq.GetRetryCount(d)
|
||||
if retryCount >= rabbitmq.MaxRetryCount {
|
||||
log.Printf("like worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
log.Printf("like worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
|
||||
141
backend/internal/worker/notificationworker.go
Normal file
141
backend/internal/worker/notificationworker.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Notification struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
RecipientID uint `gorm:"index;not null" json:"recipient_id"`
|
||||
SenderID uint `gorm:"not null" json:"sender_id"`
|
||||
Type string `gorm:"type:varchar(50);not null" json:"type"`
|
||||
TargetID uint `json:"target_id"`
|
||||
Content string `gorm:"type:varchar(255)" json:"content"`
|
||||
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
type NotificationWorker struct {
|
||||
ch *amqp.Channel
|
||||
db *gorm.DB
|
||||
queue string
|
||||
hub NotificationHub
|
||||
}
|
||||
|
||||
type NotificationHub interface {
|
||||
Push(userID uint, n *Notification)
|
||||
}
|
||||
|
||||
func NewNotificationWorker(ch *amqp.Channel, db *gorm.DB, queue string, hub NotificationHub) *NotificationWorker {
|
||||
return &NotificationWorker{ch: ch, db: db, queue: queue, hub: hub}
|
||||
}
|
||||
|
||||
func (w *NotificationWorker) Run(ctx context.Context) error {
|
||||
if w == nil || w.ch == nil || w.db == nil {
|
||||
return errors.New("notification worker is not initialized")
|
||||
}
|
||||
if err := w.db.WithContext(ctx).AutoMigrate(&Notification{}); err != nil {
|
||||
return err
|
||||
}
|
||||
deliveries, err := w.ch.Consume(w.queue, "", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case d, ok := <-deliveries:
|
||||
if !ok {
|
||||
return errors.New("deliveries channel closed")
|
||||
}
|
||||
w.handleDelivery(ctx, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *NotificationWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
||||
retryCount := rabbitmq.GetRetryCount(d)
|
||||
if err := w.process(ctx, d); err != nil {
|
||||
if retryCount >= rabbitmq.MaxRetryCount {
|
||||
log.Printf("notification worker: max retries, dropping: %v", err)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
log.Printf("notification worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
_ = d.Ack(false)
|
||||
}
|
||||
|
||||
func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error {
|
||||
body := d.Body
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
routingKey := d.RoutingKey
|
||||
|
||||
var notif *Notification
|
||||
|
||||
switch {
|
||||
case routingKey == "like.like":
|
||||
var evt rabbitmq.LikeEvent
|
||||
if err := json.Unmarshal(body, &evt); err != nil {
|
||||
return nil
|
||||
}
|
||||
if evt.UserID == 0 || evt.VideoID == 0 {
|
||||
return nil
|
||||
}
|
||||
var authorID uint
|
||||
w.db.WithContext(ctx).Model(&struct{ ID uint; AuthorID uint }{}).Table("videos").Where("id = ?", evt.VideoID).Select("author_id").Scan(&authorID)
|
||||
if authorID == 0 || authorID == evt.UserID {
|
||||
return nil
|
||||
}
|
||||
notif = &Notification{RecipientID: authorID, SenderID: evt.UserID, Type: "like", TargetID: evt.VideoID, Content: "点赞了你的视频"}
|
||||
|
||||
case routingKey == "comment.publish":
|
||||
var evt rabbitmq.CommentEvent
|
||||
if err := json.Unmarshal(body, &evt); err != nil {
|
||||
return nil
|
||||
}
|
||||
if evt.AuthorID == 0 || evt.VideoID == 0 {
|
||||
return nil
|
||||
}
|
||||
var authorID uint
|
||||
w.db.WithContext(ctx).Model(&struct{ ID uint; AuthorID uint }{}).Table("videos").Where("id = ?", evt.VideoID).Select("author_id").Scan(&authorID)
|
||||
if authorID == 0 || authorID == evt.AuthorID {
|
||||
return nil
|
||||
}
|
||||
notif = &Notification{RecipientID: authorID, SenderID: evt.AuthorID, Type: "comment", TargetID: evt.VideoID, Content: "评论了你的视频"}
|
||||
|
||||
case routingKey == "social.follow":
|
||||
var evt rabbitmq.SocialEvent
|
||||
if err := json.Unmarshal(body, &evt); err != nil {
|
||||
return nil
|
||||
}
|
||||
if evt.FollowerID == 0 || evt.VloggerID == 0 {
|
||||
return nil
|
||||
}
|
||||
notif = &Notification{RecipientID: evt.VloggerID, SenderID: evt.FollowerID, Type: "follow", TargetID: evt.FollowerID, Content: "关注了你"}
|
||||
}
|
||||
|
||||
if notif == nil {
|
||||
return nil
|
||||
}
|
||||
if err := w.db.WithContext(ctx).Create(notif).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if w.hub != nil {
|
||||
w.hub.Push(notif.RecipientID, notif)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func StartConsumer(tmq *rabbitmq.TimelineMQ, queueName string, redisClient *redi
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
timelineKey := "feed:global_timeline"
|
||||
timelineKey := redisClient.Key("feed:global_timeline")
|
||||
err = redisClient.ZAdd(ctx, timelineKey, oredis.Z{
|
||||
Score: float64(event.CreateTime),
|
||||
Member: fmt.Sprintf("%d", event.VideoID),
|
||||
|
||||
@@ -58,7 +58,13 @@ func (w *PopularityWorker) Run(ctx context.Context) error {
|
||||
|
||||
func (w *PopularityWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
||||
if err := w.process(ctx, d.Body); err != nil {
|
||||
log.Printf("popularity worker: failed to process message: %v", err)
|
||||
retryCount := rabbitmq.GetRetryCount(d)
|
||||
if retryCount >= rabbitmq.MaxRetryCount {
|
||||
log.Printf("popularity worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
log.Printf("popularity worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,8 +58,13 @@ func (w *SocialWorker) Run(ctx context.Context) error {
|
||||
|
||||
func (w *SocialWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
||||
if err := w.process(ctx, d.Body); err != nil {
|
||||
log.Printf("social worker: failed to process message: %v", err)
|
||||
// 重新入队,稍后重试
|
||||
retryCount := rabbitmq.GetRetryCount(d)
|
||||
if retryCount >= rabbitmq.MaxRetryCount {
|
||||
log.Printf("social worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
|
||||
_ = d.Ack(false)
|
||||
return
|
||||
}
|
||||
log.Printf("social worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
|
||||
_ = d.Nack(false, true)
|
||||
return
|
||||
}
|
||||
|
||||
174
backend/internal/worker/ssehub.go
Normal file
174
backend/internal/worker/ssehub.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"feedsystem_video_go/internal/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SSEHub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[uint][]chan *Notification
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSSEHub(db *gorm.DB) *SSEHub {
|
||||
return &SSEHub{clients: make(map[uint][]chan *Notification), db: db}
|
||||
}
|
||||
|
||||
func (h *SSEHub) Push(userID uint, n *Notification) {
|
||||
h.mu.RLock()
|
||||
chs, ok := h.clients[userID]
|
||||
h.mu.RUnlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, ch := range chs {
|
||||
select {
|
||||
case ch <- n:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SSEHub) Subscribe(userID uint) chan *Notification {
|
||||
ch := make(chan *Notification, 20)
|
||||
h.mu.Lock()
|
||||
h.clients[userID] = append(h.clients[userID], ch)
|
||||
h.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (h *SSEHub) Unsubscribe(userID uint, ch chan *Notification) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
chs := h.clients[userID]
|
||||
for i, c := range chs {
|
||||
if c == ch {
|
||||
h.clients[userID] = append(chs[:i], chs[i+1:]...)
|
||||
close(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SSEHub) SSERequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
token = c.GetHeader("Authorization")
|
||||
if len(token) > 7 && token[:7] == "Bearer " {
|
||||
token = token[7:]
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
c.Set("accountID", claims.AccountID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SSEHub) SSEHandler(c *gin.Context) {
|
||||
accountID, _ := c.Get("accountID")
|
||||
userID := accountID.(uint)
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
|
||||
ch := h.Subscribe(userID)
|
||||
defer h.Unsubscribe(userID, ch)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case n, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(n)
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", b)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
fmt.Fprintf(c.Writer, ": keepalive\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SSEHub) ListHandler(c *gin.Context) {
|
||||
accountID, _ := c.Get("accountID")
|
||||
userID := accountID.(uint)
|
||||
|
||||
var notifications []Notification
|
||||
if err := h.db.WithContext(c.Request.Context()).
|
||||
Where("recipient_id = ?", userID).
|
||||
Order("created_at desc").
|
||||
Limit(50).
|
||||
Find(¬ifications).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if notifications == nil {
|
||||
notifications = []Notification{}
|
||||
}
|
||||
c.JSON(200, gin.H{"notifications": notifications})
|
||||
}
|
||||
|
||||
func (h *SSEHub) MarkReadHandler(c *gin.Context) {
|
||||
accountID, _ := c.Get("accountID")
|
||||
userID := accountID.(uint)
|
||||
|
||||
var req struct {
|
||||
ID *uint `json:"id"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
if req.ID != nil {
|
||||
h.db.WithContext(c.Request.Context()).Model(&Notification{}).Where("id = ? AND recipient_id = ?", *req.ID, userID).Update("is_read", true)
|
||||
} else {
|
||||
h.db.WithContext(c.Request.Context()).Model(&Notification{}).Where("recipient_id = ?", userID).Update("is_read", true)
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "ok"})
|
||||
}
|
||||
|
||||
func (h *SSEHub) UnreadCountHandler(c *gin.Context) {
|
||||
accountID, _ := c.Get("accountID")
|
||||
userID := accountID.(uint)
|
||||
|
||||
var count int64
|
||||
h.db.WithContext(c.Request.Context()).Model(&Notification{}).Where("recipient_id = ? AND is_read = ?", userID, false).Count(&count)
|
||||
c.JSON(200, gin.H{"count": count})
|
||||
}
|
||||
|
||||
func (h *SSEHub) RegisterRoutes(r *gin.Engine, group *gin.RouterGroup) {
|
||||
group.GET("/stream", h.SSEHandler)
|
||||
group.POST("/list", h.ListHandler)
|
||||
group.POST("/markRead", h.MarkReadHandler)
|
||||
group.POST("/unreadCount", h.UnreadCountHandler)
|
||||
}
|
||||
|
||||
var _ NotificationHub = (*SSEHub)(nil)
|
||||
@@ -5,8 +5,8 @@ services:
|
||||
image: mysql:8.0
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: "123456"
|
||||
MYSQL_DATABASE: "feedsystem"
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem}
|
||||
TZ: "Asia/Shanghai"
|
||||
ports:
|
||||
- "3307:3306"
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: always
|
||||
command: ["redis-server", "--appendonly", "yes", "--requirepass", "123456"]
|
||||
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-123456}"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
@@ -43,8 +43,8 @@ services:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: admin
|
||||
RABBITMQ_DEFAULT_PASS: password123
|
||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin}
|
||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-password123}
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
healthcheck:
|
||||
@@ -59,6 +59,8 @@ services:
|
||||
dockerfile: backend/Dockerfile
|
||||
target: api
|
||||
restart: always
|
||||
environment:
|
||||
JWT_SECRET: ${JWT_SECRET:-feedsystem-dev-secret-key}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
@@ -71,6 +73,11 @@ services:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep api || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
worker:
|
||||
build:
|
||||
@@ -78,6 +85,8 @@ services:
|
||||
dockerfile: backend/Dockerfile
|
||||
target: worker
|
||||
restart: always
|
||||
environment:
|
||||
JWT_SECRET: ${JWT_SECRET:-feedsystem-dev-secret-key}
|
||||
volumes:
|
||||
- ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro
|
||||
depends_on:
|
||||
@@ -87,6 +96,11 @@ services:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep worker || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -97,6 +111,11 @@ services:
|
||||
- "5173:80"
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:80/ || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
|
||||
90
docs/plans/2025-04-25-features-design.md
Normal file
90
docs/plans/2025-04-25-features-design.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 用户体系 + 社交深化 设计文档
|
||||
|
||||
> **日期**: 2025-04-25
|
||||
> **状态**: 待实施
|
||||
> **方案**: 依赖驱动分批(3 阶段)
|
||||
|
||||
## 概述
|
||||
|
||||
在现有短视频 Feed 系统基础上,扩展用户 profile 体系(头像、简介、Refresh Token、主页统计)和社交互动能力(通知、私信、话题、@提及)。
|
||||
|
||||
---
|
||||
|
||||
## P1 — 用户基石(4 项)
|
||||
|
||||
### 1. 头像上传 + 个人简介
|
||||
|
||||
**Account 模型扩展**:
|
||||
```go
|
||||
AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"`
|
||||
Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"`
|
||||
```
|
||||
|
||||
**新增接口**:
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/account/uploadAvatar` | multipart 上传头像,校验类型/大小,存 `.run/uploads/avatars/{id}/` |
|
||||
| POST | `/account/updateProfile` | JSON `{ avatar_url?, bio? }` 更新当前用户 |
|
||||
|
||||
**前端**: UserAvatar 组件支持 `src`,AccountView 加头像上传 + bio 编辑,Feed 卡片显示头像。
|
||||
|
||||
### 2. 登录态优化(Refresh Token)
|
||||
|
||||
**双 Token**:
|
||||
- Access Token: 15min 过期
|
||||
- Refresh Token: 7天过期,落库 + Redis 缓存
|
||||
|
||||
**新增接口**: `POST /account/refresh` — 接收 refresh token 返回新 access token
|
||||
|
||||
**前端**: auth store 存双 token,client.ts 401 自动刷新,登录页"记住我"。
|
||||
|
||||
---
|
||||
|
||||
## P2 — Feed 可见 + 通知(3 项)
|
||||
|
||||
### 3. 粉丝数 / 关注数展示
|
||||
|
||||
**后端**: 社交接口返回中加 `follower_count` / `vlogger_count`,通过聚合查询计数。
|
||||
|
||||
**前端**: UserProfileView 和 Feed 卡片显示粉丝数。
|
||||
|
||||
### 4. 用户主页增强
|
||||
|
||||
**后端**: `POST /account/getProfile` — 返回用户信息 + 视频列表 + 获赞总数。
|
||||
|
||||
**前端**: UserProfileView 加视频列表网格 + 统计卡片。
|
||||
|
||||
### 5. 实时消息通知
|
||||
|
||||
**架构**: 复用 MQ 事件(like.events / comment.events / social.events)→ NotificationWorker 消费 → 写 `Notification` 表 + WebSocket 推送。
|
||||
|
||||
**新增表**: `Notification` — id, recipient_id, sender_id, type, target_id, is_read, created_at。
|
||||
|
||||
**新增接口**:
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/notification/list` | 返回当前用户未读通知列表 |
|
||||
| POST | `/notification/markRead` | 标记单条/全部已读 |
|
||||
| GET | `/ws/notifications` | WebSocket 升级,实时推送 |
|
||||
|
||||
**前端**: AppShell 右上角通知铃铛 + 未读红点。
|
||||
|
||||
---
|
||||
|
||||
## P3 — 互动深化(3 项)
|
||||
|
||||
### 6. 私信 / 即时通讯
|
||||
|
||||
**新增表**: `Message` — id, from_id, to_id, content, is_read, created_at
|
||||
|
||||
**后端**: WebSocket 双向通道,`POST /message/send` + `POST /message/list`
|
||||
|
||||
### 7. #话题标签
|
||||
|
||||
**新增表**: `Tag` — id, name (unique);`VideoTag` — video_id, tag_id
|
||||
|
||||
**改动**: 视频发布时从 title/description 中提取 `#xxx`,写入 `VideoTag` 关联表;`POST /feed/listByTag` 按话题浏览。
|
||||
|
||||
### 8. @提及
|
||||
|
||||
**改动**: 评论发布时解析 `@username`,创建 Notification 并推送。
|
||||
149
docs/plans/2025-04-25-optimization-design.md
Normal file
149
docs/plans/2025-04-25-optimization-design.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# feedsystem_video_go 全项目优化设计文档
|
||||
|
||||
> **日期**: 2025-04-25
|
||||
> **状态**: 待实施
|
||||
> **方案**: 风险驱动分批(方案 A)
|
||||
|
||||
## 概述
|
||||
|
||||
基于全项目代码审查,识别出 17 项优化点,覆盖安全、数据库、MQ 可靠性、代码质量、架构、前端六大维度。按风险优先级分为三批实施。
|
||||
|
||||
---
|
||||
|
||||
## P1 止血(4项)— 消除生产风险
|
||||
|
||||
### 1. Router 变量赋值 Bug
|
||||
|
||||
- **文件**: `backend/internal/http/router.go:147`
|
||||
- **问题**: `timelineMQ` 初始化失败时错误地将 `socialMQ` 设为 nil
|
||||
- **修复**: `socialMQ = nil` → `timelineMQ = nil`
|
||||
- **影响**: 1 行
|
||||
|
||||
### 2. 数据库复合索引
|
||||
|
||||
- **文件**: `backend/internal/video/video_entity.go`
|
||||
- **问题**: Feed 流排序查询缺少索引,可能全表扫描
|
||||
- **修复**: 在 Video 模型 GORM tag 中添加 3 个复合索引
|
||||
- `idx_videos_create_time` — `ListLatest`
|
||||
- `idx_videos_likes_count_id` — `ListLikesCountWithCursor`
|
||||
- `idx_videos_popularity_time_id` — `ListByPopularity`
|
||||
- **实施**: 修改 model tag + AutoMigrate 自动创建
|
||||
|
||||
### 3. ListByAuthorID 加 LIMIT
|
||||
|
||||
- **文件**: `backend/internal/video/video_repo.go:39-48`
|
||||
- **问题**: 查询无上限,单作者海量视频可导致内存溢出
|
||||
- **修复**: 加 `Limit(200)` 硬上限
|
||||
|
||||
### 4. MQ Worker 死信队列 + 退避重试
|
||||
|
||||
- **文件**: `middleware/rabbitmq/` + 4 个 Worker 文件
|
||||
- **问题**: 所有 Worker 使用 `Nack(false, true)` 无限重试
|
||||
- **修复**:
|
||||
- 声明死信交换 + 死信队列
|
||||
- 利用 `x-death` header 判断重试次数,≥3 次 Ack 并告警
|
||||
|
||||
---
|
||||
|
||||
## P2 加固(6项)— 安全隐患 + 规范化
|
||||
|
||||
### 5. rand.Read 错误处理
|
||||
|
||||
- **文件**: `backend/internal/video/video_handler.go:164-168`
|
||||
- **问题**: 忽略 `rand.Read` 错误,失败时文件名全零可能覆盖
|
||||
- **修复**: `randHex()` 返回 error,调用方处理
|
||||
|
||||
### 6. Handler 错误码精确化
|
||||
|
||||
- **文件**: 所有 handler 文件
|
||||
- **问题**: DB/内部错误统一返回 400
|
||||
- **修复**: 新增 `classifyHTTPStatus()` 辅助函数,Service 层返回哨兵错误区分 400/401/404/500
|
||||
|
||||
### 7. JWT Secret 弱默认值
|
||||
|
||||
- **文件**: `backend/internal/auth/jwt.go`
|
||||
- **问题**: 默认值 `"change-me-in-env"` 过于明显
|
||||
- **修复**: 未设环境变量时生成随机密钥并警告
|
||||
|
||||
### 8. 配置密码集中管理
|
||||
|
||||
- **文件**: `docker-compose.yml` + 3 个 config YAML
|
||||
- **问题**: 多处重复硬编码密码
|
||||
- **修复**: docker-compose 引用 `.env`,创建 `.env.example`,config YAML 保持现状
|
||||
|
||||
### 9. 前端路由鉴权守卫
|
||||
|
||||
- **文件**: `frontend/src/router/index.ts`
|
||||
- **问题**: Settings/Video 页面无登录拦截
|
||||
- **修复**: 添加 `router.beforeEach` 守卫
|
||||
|
||||
### 10. pprof 生产保护
|
||||
|
||||
- **现状**: 已监听 `127.0.0.1`,`config.docker.yaml` 已禁用
|
||||
- **动作**: 确认安全,仅需注释说明
|
||||
|
||||
---
|
||||
|
||||
## P3 优化(7项)— 架构 + 可维护性
|
||||
|
||||
### 11. HomeView.vue 拆分
|
||||
|
||||
- **文件**: `frontend/src/views/HomeView.vue` (918 行)
|
||||
- **拆分目标**:
|
||||
- `composables/useVideoFeed.ts`
|
||||
- `composables/useVideoPlayer.ts`
|
||||
- `composables/useLikeFollow.ts`
|
||||
- `components/CommentDrawer.vue`
|
||||
- `views/HomeView.vue`(精简至 ~350 行)
|
||||
|
||||
### 12. Feed Service 策略拆分
|
||||
|
||||
- **文件**: `backend/internal/feed/service.go` (547 行)
|
||||
- **拆分目标**: 按查询策略拆为 4 个文件
|
||||
- `strategy_latest.go` — 热冷分离 + ZSET
|
||||
- `strategy_follow.go` — 缓存穿透防护
|
||||
- `strategy_hot.go` — 快照合并 + 降级
|
||||
- `build_feed.go` — 公共方法
|
||||
|
||||
### 13. 视频列表虚拟滚动
|
||||
|
||||
- **问题**: 所有视频渲染 DOM,内存压力大
|
||||
- **修复**: 仅保留当前 ±1 条 DOM,离屏 `display:none` + `pause()`
|
||||
|
||||
### 14. 缓存键版本化
|
||||
|
||||
- **文件**: `middleware/redis/redis.go` + 所有 service
|
||||
- **修复**: `Client` 增加 `keyPrefix`,所有键通过 `c.Key()` 生成
|
||||
|
||||
### 15. Docker 健康检查
|
||||
|
||||
- **文件**: `docker-compose.yml`
|
||||
- **修复**: 为 backend/worker/frontend 增加 healthcheck
|
||||
|
||||
### 16. 前端错误监控
|
||||
|
||||
- **文件**: `frontend/src/api/client.ts` + 新增 `utils/error-reporter.ts`
|
||||
- **修复**: 增加全局错误上报钩子
|
||||
|
||||
### 17. Worker 优雅重启
|
||||
|
||||
- **文件**: `backend/cmd/worker/main.go`
|
||||
- **修复**: 替换 `log.Fatal` 为指数退避重试
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
```
|
||||
P1 (第1周) P2 (第2周) P3 (第3-4周)
|
||||
────────────────────────────────────────────
|
||||
#1 Router Bug #5 rand.Read #11 HomeView 拆分
|
||||
#2 DB 索引 #6 错误码 #12 Feed 拆分
|
||||
#3 LIMIT #7 JWT #13 虚拟滚动
|
||||
#4 MQ 死信 #8 密码管理 #14 缓存版本化
|
||||
#9 路由守卫 #15 健康检查
|
||||
#10 pprof #16 错误监控
|
||||
#17 Worker 重启
|
||||
```
|
||||
|
||||
每批独立验证:`go test ./...` + `npm run build` + 冒烟测试。
|
||||
411
docs/plans/2025-04-25-p1-implementation.md
Normal file
411
docs/plans/2025-04-25-p1-implementation.md
Normal file
@@ -0,0 +1,411 @@
|
||||
# P1 用户基石 实施计划
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 扩展 Account 模型(头像+简介)、实现双 Token 登录态优化(Access + Refresh Token)
|
||||
|
||||
**Architecture:** Account 模型加 avatar_url/bio/refresh_token 字段;复用 UploadCover 的文件上传逻辑做头像;JWT 双 Token 机制 — Access 15min / Refresh 7天
|
||||
|
||||
**Tech Stack:** Go + Gin + GORM + JWT + Vue 3 + Pinia
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Account 模型扩展
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/account/entity.go:3-8`
|
||||
|
||||
**Step 1: 修改 Account struct**
|
||||
|
||||
```go
|
||||
type Account struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"unique" json:"username"`
|
||||
Password string `json:"-"`
|
||||
Token string `json:"-"`
|
||||
RefreshToken string `json:"-"`
|
||||
AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"`
|
||||
Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: 编译验证**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: 编译通过(AutoMigrate 自动加列)
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/account/entity.go
|
||||
git commit -m "feat: Account 模型加 avatar_url/bio/refresh_token 字段"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 头像上传 Handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/account/handler.go` — 新增 UploadAvatar 方法
|
||||
- Modify: `backend/internal/http/router.go` — 注册路由
|
||||
|
||||
**Step 1: 添加 UploadAvatar handler**
|
||||
|
||||
参考 `video/video_handler.go` 的 `UploadCover`,在 `account/handler.go` 中新增:
|
||||
|
||||
```go
|
||||
func (ah *AccountHandler) UploadAvatar(c *gin.Context) {
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
f, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
const maxSize = 10 << 20
|
||||
if f.Size <= 0 || f.Size > maxSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(f.Filename))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp":
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp allowed"})
|
||||
return
|
||||
}
|
||||
dir := filepath.Join(".run", "uploads", "avatars", strconv.FormatUint(uint64(accountID), 10))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
filename, err := randHex(16)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
filename = filename + ext
|
||||
absPath := filepath.Join(dir, filename)
|
||||
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
urlPath := path.Join("/static", "avatars", strconv.FormatUint(uint64(accountID), 10), filename)
|
||||
avatarURL := buildAbsoluteURL(c, urlPath)
|
||||
|
||||
// 更新数据库
|
||||
if err := ah.accountService.UpdateAvatar(c.Request.Context(), accountID, avatarURL); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"avatar_url": avatarURL})
|
||||
}
|
||||
```
|
||||
|
||||
需要新增 import: `"os"`, `"path"`, `"path/filepath"`, `"crypto/rand"`, `"encoding/hex"`, `"strconv"`, `"strings"`, `"net/http"` — 但 account/handler.go 已有部分,按需补。
|
||||
|
||||
同时需要从 `video_handler.go` 复制 `randHex` 和 `buildAbsoluteURL` 函数(或提取到公共 util)。
|
||||
|
||||
**Step 2: 在 router.go 注册路由**
|
||||
|
||||
```go
|
||||
protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar)
|
||||
```
|
||||
|
||||
**Step 3: 添加 AccountService.UpdateAvatar 方法**
|
||||
|
||||
```go
|
||||
func (as *AccountService) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error {
|
||||
return as.accountRepo.UpdateAvatar(ctx, accountID, avatarURL)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: 添加 AccountRepository.UpdateAvatar 方法**
|
||||
|
||||
```go
|
||||
func (ar *AccountRepository) UpdateAvatar(ctx context.Context, accountID uint, avatarURL string) error {
|
||||
return ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", accountID).Update("avatar_url", avatarURL).Error
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: 编译验证**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: 通过
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/account/handler.go backend/internal/account/service.go backend/internal/account/repo.go backend/internal/http/router.go
|
||||
git commit -m "feat: 头像上传接口 /account/uploadAvatar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 更新个人简介接口
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/account/handler.go` — 新增 UpdateProfile
|
||||
- Modify: `backend/internal/http/router.go` — 注册路由
|
||||
|
||||
**Step 1: 新增 request struct + handler**
|
||||
|
||||
在 `entity.go` 加:
|
||||
```go
|
||||
type UpdateProfileRequest struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Bio string `json:"bio"`
|
||||
}
|
||||
```
|
||||
|
||||
Handler:
|
||||
```go
|
||||
func (ah *AccountHandler) UpdateProfile(c *gin.Context) {
|
||||
accountID, err := jwt.GetAccountID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var req UpdateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := ah.accountService.UpdateProfile(c.Request.Context(), accountID, &req); err != nil {
|
||||
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Service + Repo 层**
|
||||
|
||||
```go
|
||||
func (as *AccountService) UpdateProfile(ctx context.Context, accountID uint, req *UpdateProfileRequest) error {
|
||||
updates := map[string]interface{}{}
|
||||
if req.Bio != "" {
|
||||
updates["bio"] = strings.TrimSpace(req.Bio)
|
||||
}
|
||||
if req.AvatarURL != "" {
|
||||
updates["avatar_url"] = strings.TrimSpace(req.AvatarURL)
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return errors.New("nothing to update")
|
||||
}
|
||||
return as.accountRepo.UpdateFields(ctx, accountID, updates)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: 注册路由**
|
||||
|
||||
```go
|
||||
protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile)
|
||||
```
|
||||
|
||||
**Step 4: 编译 + 提交**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: 通过
|
||||
|
||||
```bash
|
||||
git add backend/internal/account/ && git add backend/internal/http/router.go
|
||||
git commit -m "feat: 个人简介更新接口 /account/updateProfile"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Refresh Token 机制
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/auth/jwt.go` — 新增 GenerateRefreshToken + ValidateRefreshToken
|
||||
- Modify: `backend/internal/account/handler.go` — 新增 Refresh handler
|
||||
- Modify: `backend/internal/account/service.go` — Login 返回双 token
|
||||
- Modify: `backend/internal/http/router.go` — 注册 refresh 路由
|
||||
|
||||
**Step 1: auth/jwt.go 增加 Refresh Token**
|
||||
|
||||
```go
|
||||
const (
|
||||
AccessTokenTTL = 15 * time.Minute
|
||||
RefreshTokenTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
func GenerateAccessToken(accountID uint, username string) (string, error) {
|
||||
// 原 GenerateToken 逻辑,TTL 改为 15min
|
||||
}
|
||||
|
||||
func GenerateRefreshToken(accountID uint) (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Login 返回双 token**
|
||||
|
||||
修改 `account/service.go` 的 `Login` 方法,返回值从 `(string, error)` 改为 `(accessToken, refreshToken string, err error)`,并更新 `entity.go` 中的 `LoginResponse`。
|
||||
|
||||
```go
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"` // access token
|
||||
RefreshToken string `json:"refresh_token"` // refresh token
|
||||
AccountID uint `json:"account_id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
```
|
||||
|
||||
Login 时生成两个 token,access token 落库 `account.token`,refresh token 落库 `account.refresh_token`,两者都缓存到 Redis。
|
||||
|
||||
**Step 3: Refresh handler**
|
||||
|
||||
新增 `POST /account/refresh`:
|
||||
|
||||
```go
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
func (ah *AccountHandler) Refresh(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
newAccessToken, err := ah.accountService.RefreshAccessToken(c.Request.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": newAccessToken})
|
||||
}
|
||||
```
|
||||
|
||||
`AccountService.RefreshAccessToken`:查 Redis `account:{id}:refresh` → 匹配 → 生成新 access token → 更新 token 字段。
|
||||
|
||||
**Step 4: 登出/改密时同时清空 refresh_token**
|
||||
|
||||
在 `Logout` 和 `ChangePassword` 中增加 `Del("account:{id}:refresh")`。
|
||||
|
||||
**Step 5: 编译 + 提交**
|
||||
|
||||
```bash
|
||||
git add backend/internal/auth/jwt.go backend/internal/account/
|
||||
git commit -m "feat: Refresh Token 机制 — Access 15min + Refresh 7天"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 前端 auth store + client.ts 适配双 Token
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/stores/auth.ts`
|
||||
- Modify: `frontend/src/api/client.ts`
|
||||
- Modify: `frontend/src/api/account.ts`
|
||||
|
||||
**Step 1: auth store 存储双 token**
|
||||
|
||||
```typescript
|
||||
const ACCESS_KEY = 'access_token'
|
||||
const REFRESH_KEY = 'refresh_token'
|
||||
|
||||
// 新增字段
|
||||
const refreshToken = ref<string | null>(readToken(REFRESH_KEY))
|
||||
|
||||
function setTokens(access: string, refresh: string) {
|
||||
token.value = access; refreshToken.value = refresh
|
||||
writeToken(ACCESS_KEY, access); writeToken(REFRESH_KEY, refresh)
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
token.value = null; refreshToken.value = null
|
||||
removeToken(ACCESS_KEY); removeToken(REFRESH_KEY)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: client.ts 401 自动刷新**
|
||||
|
||||
```typescript
|
||||
async function tryRefresh(): Promise<string | null> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.refreshToken) return null
|
||||
try {
|
||||
const res = await postJson<{ token: string }>('/account/refresh', { refresh_token: auth.refreshToken })
|
||||
auth.setToken(res.token)
|
||||
return res.token
|
||||
} catch {
|
||||
auth.clearTokens()
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 `postJson` 和 `postForm` 的 `!res.ok` 分支中,401 时先尝试刷新,成功则重试原请求。
|
||||
|
||||
**Step 3: 编译验证**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: 通过
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/stores/auth.ts frontend/src/api/client.ts frontend/src/api/account.ts
|
||||
git commit -m "feat: 前端双 Token 适配 — 401 自动刷新 + Refresh Token 存储"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 前端用户 Profile UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/AccountView.vue`
|
||||
- Modify: `frontend/src/components/UserAvatar.vue`
|
||||
- Modify: `frontend/src/views/HomeView.vue` — Feed 卡片中 UserAvatar 传递头像 URL
|
||||
- Modify: `frontend/src/api/account.ts` — 新增 API 调用
|
||||
|
||||
**Step 1: UserAvatar 支持 src**
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
defineProps<{ username: string; id: number; size?: number; src?: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<img v-if="src" :src="src" :width="size" :height="size" class="avatar-img" />
|
||||
<svg v-else ...> <!-- 默认 SVG -->
|
||||
</template>
|
||||
```
|
||||
|
||||
**Step 2: AccountView 加头像上传 + bio 编辑**
|
||||
|
||||
在登录后的 AccountView 中增加:头像上传按钮(调用 `/account/uploadAvatar`)、bio 编辑输入框(调用 `/account/updateProfile`)。
|
||||
|
||||
**Step 3: 编译验证**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: 通过
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/UserAvatar.vue frontend/src/views/AccountView.vue frontend/src/views/HomeView.vue frontend/src/api/account.ts
|
||||
git commit -m "feat: 前端用户 Profile UI — 头像上传 + bio 编辑 + 登录记住我"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
完成所有 Task 后:
|
||||
|
||||
```bash
|
||||
cd backend && go build ./... && go vet ./... && go test ./...
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Expected: 全部通过
|
||||
@@ -1,4 +1,4 @@
|
||||
import { postJson } from './client'
|
||||
import { postForm, postJson } from './client'
|
||||
import type { Account, MessageResponse, TokenResponse } from './types'
|
||||
|
||||
export function register(username: string, password: string) {
|
||||
@@ -32,3 +32,17 @@ export function findById(id: number) {
|
||||
export function findByUsername(username: string) {
|
||||
return postJson<Account>('/account/findByUsername', { username })
|
||||
}
|
||||
|
||||
export function uploadAvatar(file: File) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return postForm<{ avatar_url: string }>('/account/uploadAvatar', fd, { authRequired: true })
|
||||
}
|
||||
|
||||
export function updateProfile(data: { avatar_url?: string; bio?: string }) {
|
||||
return postJson<MessageResponse>('/account/updateProfile', data, { authRequired: true })
|
||||
}
|
||||
|
||||
export function refresh(refreshToken: string) {
|
||||
return postJson<TokenResponse>('/account/refresh', { refresh_token: refreshToken })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { reportError } from '../utils/error-reporter'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
@@ -16,6 +17,35 @@ type ApiErrorBody = { error?: string }
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
||||
|
||||
let isRefreshing = false
|
||||
let refreshPromise: Promise<string | null> | null = null
|
||||
|
||||
async function tryRefresh(): Promise<string | null> {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.refreshToken) return null
|
||||
if (isRefreshing) return refreshPromise
|
||||
isRefreshing = true
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/account/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: auth.refreshToken }),
|
||||
})
|
||||
if (!res.ok) { auth.clearTokens(); return null }
|
||||
const data = await res.json()
|
||||
auth.setToken(data.token)
|
||||
return data.token as string
|
||||
} catch {
|
||||
auth.clearTokens()
|
||||
return null
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
})()
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
export async function postJson<T>(path: string, body: unknown, options?: { authRequired?: boolean }): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const token = auth.token
|
||||
@@ -33,28 +63,18 @@ export async function postJson<T>(path: string, body: unknown, options?: { authR
|
||||
body: JSON.stringify(body ?? {}),
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
let data: unknown = null
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = text
|
||||
if (res.status === 401 && path !== '/account/refresh') {
|
||||
const newToken = await tryRefresh()
|
||||
if (newToken) {
|
||||
headers.Authorization = `Bearer ${newToken}`
|
||||
const retryRes = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST', headers, body: JSON.stringify(body ?? {}),
|
||||
})
|
||||
return handleResponse<T>(retryRes, path)
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
auth.clearToken()
|
||||
}
|
||||
const msg =
|
||||
data && typeof data === 'object' && (data as ApiErrorBody).error
|
||||
? String((data as ApiErrorBody).error)
|
||||
: `请求失败 (${res.status})`
|
||||
throw new ApiError(msg, res.status, data)
|
||||
}
|
||||
|
||||
return data as T
|
||||
return handleResponse<T>(res, path)
|
||||
}
|
||||
|
||||
export async function postForm<T>(path: string, body: FormData, options?: { authRequired?: boolean }): Promise<T> {
|
||||
@@ -74,25 +94,36 @@ export async function postForm<T>(path: string, body: FormData, options?: { auth
|
||||
body,
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
let data: unknown = null
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = text
|
||||
if (res.status === 401 && path !== '/account/refresh') {
|
||||
const newToken = await tryRefresh()
|
||||
if (newToken) {
|
||||
headers.Authorization = `Bearer ${newToken}`
|
||||
const retryRes = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST', headers, body,
|
||||
})
|
||||
return handleResponse<T>(retryRes, path)
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
auth.clearToken()
|
||||
return handleResponse<T>(res, path)
|
||||
}
|
||||
const msg =
|
||||
data && typeof data === 'object' && (data as ApiErrorBody).error
|
||||
|
||||
async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const text = await res.text()
|
||||
let data: unknown = null
|
||||
if (text) {
|
||||
try { data = JSON.parse(text) } catch { data = text }
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) auth.clearTokens()
|
||||
const msg = data && typeof data === 'object' && (data as ApiErrorBody).error
|
||||
? String((data as ApiErrorBody).error)
|
||||
: `请求失败 (${res.status})`
|
||||
throw new ApiError(msg, res.status, data)
|
||||
const apiErr = new ApiError(msg, res.status, data)
|
||||
reportError(apiErr, { path, status: res.status })
|
||||
throw apiErr
|
||||
}
|
||||
|
||||
return data as T
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export type MessageResponse = { message: string }
|
||||
|
||||
export type TokenResponse = { token: string }
|
||||
export type TokenResponse = { token: string; refresh_token?: string; account_id?: number; username?: string }
|
||||
|
||||
export type Account = {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
export type Video = {
|
||||
|
||||
153
frontend/src/components/CommentDrawer.vue
vendored
Normal file
153
frontend/src/components/CommentDrawer.vue
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { ApiError } from '../api/client'
|
||||
import * as commentApi from '../api/comment'
|
||||
import type { Comment, FeedVideoItem } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
const props = defineProps<{ video: FeedVideoItem | null }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const drawer = reactive({
|
||||
loading: false,
|
||||
error: '',
|
||||
comments: [] as Comment[],
|
||||
content: '',
|
||||
})
|
||||
|
||||
function needLogin() {
|
||||
toast.error('请先登录')
|
||||
}
|
||||
|
||||
function close() {
|
||||
drawer.comments = []
|
||||
drawer.content = ''
|
||||
drawer.error = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
if (!props.video) return
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
drawer.comments = await commentApi.listAll(props.video.id)
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publishComment() {
|
||||
if (!props.video) return
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
const content = drawer.content.trim()
|
||||
if (!content) return
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
await commentApi.publish(props.video.id, content)
|
||||
drawer.content = ''
|
||||
await loadComments()
|
||||
toast.success('评论已发布')
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(drawer.error)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function canDeleteComment(c: Comment) {
|
||||
const myId = auth.claims?.account_id
|
||||
return !!myId && myId === c.author_id
|
||||
}
|
||||
|
||||
async function deleteComment(commentId: number) {
|
||||
if (!props.video) return
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
if (!window.confirm('确认删除这条评论?')) return
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
await commentApi.remove(commentId)
|
||||
await loadComments()
|
||||
toast.info('评论已删除')
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(drawer.error)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ loadComments })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="drawer-backdrop" @click.self="close">
|
||||
<div class="drawer">
|
||||
<div class="drawer-head">
|
||||
<div class="drawer-title">{{ video?.title ?? '评论' }}</div>
|
||||
<button class="drawer-x" type="button" @click="close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="drawer-body">
|
||||
<div v-if="drawer.loading" class="drawer-hint">加载中…</div>
|
||||
<div v-else-if="drawer.error" class="drawer-hint bad">{{ drawer.error }}</div>
|
||||
<div v-else-if="drawer.comments.length === 0" class="drawer-hint">暂无评论</div>
|
||||
|
||||
<div class="comment" v-for="c in drawer.comments" :key="c.id">
|
||||
<div class="comment-top">
|
||||
<div class="comment-user">{{ c.username }}</div>
|
||||
<div class="comment-meta mono">#{{ c.id }} · {{ new Date(c.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="comment-content">{{ c.content }}</div>
|
||||
<div class="comment-actions">
|
||||
<button v-if="canDeleteComment(c)" class="chip danger" type="button" :disabled="drawer.loading" @click="deleteComment(c.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-foot">
|
||||
<textarea v-model="drawer.content" placeholder="说点什么…" :disabled="drawer.loading" />
|
||||
<div class="row" style="justify-content: space-between; margin-top: 8px">
|
||||
<button class="chip" type="button" :disabled="drawer.loading" @click="loadComments">刷新</button>
|
||||
<button class="chip primary" type="button" :disabled="drawer.loading || !drawer.content.trim()" @click="publishComment">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.55); backdrop-filter: blur(10px); z-index: 120; display: grid; justify-items: end; }
|
||||
.drawer { width: min(420px, calc(100vw - 18px)); height: 100vh; background: rgba(0,0,0,0.65); border-left: 1px solid rgba(255,255,255,0.12); display: grid; grid-template-rows: auto 1fr auto; }
|
||||
.drawer-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 14px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
||||
.drawer-title { font-weight: 800; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.drawer-x { width: 34px; height: 34px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.9); cursor: pointer; font-size: 20px; line-height: 1; }
|
||||
.drawer-body { overflow: auto; padding: 12px 14px; display: grid; gap: 10px; }
|
||||
.drawer-foot { border-top: 1px solid rgba(255,255,255,0.1); padding: 12px 14px; }
|
||||
.drawer-foot textarea { width: 100%; min-height: 82px; resize: none; border-radius: 14px; border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.9); padding: 10px 12px; outline: none; }
|
||||
.drawer-hint { color: rgba(255,255,255,0.78); padding: 12px 0; }
|
||||
.drawer-hint.bad { color: rgba(254,44,85,0.92); }
|
||||
.comment { border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.05); border-radius: 14px; padding: 10px 10px; }
|
||||
.comment-top { display: grid; gap: 3px; }
|
||||
.comment-user { font-weight: 700; font-size: 13px; }
|
||||
.comment-meta { font-size: 12px; color: rgba(255,255,255,0.55); }
|
||||
.comment-content { margin-top: 8px; font-size: 13px; line-height: 1.35; color: rgba(255,255,255,0.86); white-space: pre-wrap; word-break: break-word; }
|
||||
.comment-actions { margin-top: 10px; display: flex; justify-content: flex-end; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.14); background: rgba(0,0,0,0.28); color: rgba(255,255,255,0.86); font-size: 12px; text-decoration: none; cursor: pointer; }
|
||||
.chip.primary { border-color: rgba(254,44,85,0.45); background: rgba(254,44,85,0.14); }
|
||||
.chip.danger { border-color: rgba(254,44,85,0.55); background: rgba(254,44,85,0.12); }
|
||||
@media (max-width: 900px) {
|
||||
.drawer-backdrop { justify-items: center; align-items: end; }
|
||||
.drawer { width: calc(100vw - 16px); height: min(72vh, 560px); border-left: none; border-top: 1px solid rgba(255,255,255,0.12); border-radius: 18px 18px 0 0; overflow: hidden; }
|
||||
}
|
||||
</style>
|
||||
5
frontend/src/components/UserAvatar.vue
vendored
5
frontend/src/components/UserAvatar.vue
vendored
@@ -5,6 +5,7 @@ const props = defineProps<{
|
||||
username: string
|
||||
id?: number
|
||||
size?: number
|
||||
src?: string
|
||||
}>()
|
||||
|
||||
function hashToHue(input: string) {
|
||||
@@ -33,7 +34,8 @@ const bg = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="avatar" :style="{ width: sizePx, height: sizePx, backgroundImage: bg }" aria-hidden="true">
|
||||
<img v-if="src" :src="src" class="avatar" :style="{ width: sizePx, height: sizePx }" alt="" />
|
||||
<div v-else class="avatar" :style="{ width: sizePx, height: sizePx, backgroundImage: bg }" aria-hidden="true">
|
||||
{{ initial }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,6 +51,7 @@ const bg = computed(() => {
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2px;
|
||||
user-select: none;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
67
frontend/src/composables/useLikeFollow.ts
Normal file
67
frontend/src/composables/useLikeFollow.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { reactive } from 'vue'
|
||||
import { ApiError } from '../api/client'
|
||||
import * as likeApi from '../api/like'
|
||||
import type { FeedVideoItem } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useSocialStore } from '../stores/social'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
export function useLikeFollow(needLogin: () => void) {
|
||||
const auth = useAuthStore()
|
||||
const social = useSocialStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const likeBusy = reactive<Record<string, boolean>>({})
|
||||
const followBusy = reactive<Record<string, boolean>>({})
|
||||
|
||||
async function toggleLike(item: FeedVideoItem) {
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
const key = String(item.id)
|
||||
if (likeBusy[key]) return
|
||||
likeBusy[key] = true
|
||||
try {
|
||||
if (item.is_liked) await likeApi.unlike(item.id)
|
||||
else await likeApi.like(item.id)
|
||||
item.is_liked = !item.is_liked
|
||||
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
likeBusy[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollow(authorId: number) {
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
const key = String(authorId)
|
||||
if (followBusy[key]) return
|
||||
followBusy[key] = true
|
||||
try {
|
||||
if (social.isFollowing(authorId)) {
|
||||
await social.unfollow(authorId)
|
||||
toast.info('已取关')
|
||||
} else {
|
||||
await social.follow(authorId)
|
||||
toast.success('已关注')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
followBusy[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function share(item: FeedVideoItem) {
|
||||
const url = `${location.origin}/video/${item.id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success('链接已复制')
|
||||
} catch {
|
||||
window.prompt('复制链接', url)
|
||||
}
|
||||
}
|
||||
|
||||
return { likeBusy, followBusy, toggleLike, toggleFollow, share }
|
||||
}
|
||||
112
frontend/src/composables/useVideoFeed.ts
Normal file
112
frontend/src/composables/useVideoFeed.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ApiError } from '../api/client'
|
||||
import * as feedApi from '../api/feed'
|
||||
import type { FeedVideoItem } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
export type TabKey = 'recommend' | 'hot' | 'following'
|
||||
|
||||
export function useVideoFeed() {
|
||||
const auth = useAuthStore()
|
||||
const tab = ref<TabKey>('recommend')
|
||||
|
||||
const recommend = reactive({
|
||||
items: [] as FeedVideoItem[],
|
||||
loading: false, error: '',
|
||||
hasMore: false, nextTime: 0,
|
||||
})
|
||||
|
||||
const hot = reactive({
|
||||
items: [] as FeedVideoItem[],
|
||||
loading: false, error: '',
|
||||
hasMore: false,
|
||||
nextLikesCountBefore: undefined as number | undefined,
|
||||
nextIdBefore: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const following = reactive({
|
||||
items: [] as FeedVideoItem[],
|
||||
loading: false, error: '',
|
||||
hasMore: false, nextTime: 0,
|
||||
})
|
||||
|
||||
const currentState = computed(() => {
|
||||
if (tab.value === 'hot') return hot
|
||||
if (tab.value === 'following') return following
|
||||
return recommend
|
||||
})
|
||||
|
||||
async function loadRecommend(reset: boolean) {
|
||||
if (recommend.loading) return
|
||||
recommend.loading = true
|
||||
recommend.error = ''
|
||||
try {
|
||||
const res = await feedApi.listLatest({ limit: 10, latest_time: reset ? 0 : recommend.nextTime })
|
||||
recommend.hasMore = res.has_more
|
||||
recommend.nextTime = res.next_time
|
||||
recommend.items = reset ? res.video_list : recommend.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
recommend.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
recommend.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHot(reset: boolean) {
|
||||
if (hot.loading) return
|
||||
hot.loading = true
|
||||
hot.error = ''
|
||||
try {
|
||||
const res = await feedApi.listLikesCount({
|
||||
limit: 10,
|
||||
likes_count_before: reset ? undefined : hot.nextLikesCountBefore,
|
||||
id_before: reset ? undefined : hot.nextIdBefore,
|
||||
})
|
||||
hot.hasMore = res.has_more
|
||||
hot.nextLikesCountBefore = res.next_likes_count_before
|
||||
hot.nextIdBefore = res.next_id_before
|
||||
hot.items = reset ? res.video_list : hot.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
hot.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
hot.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFollowing(reset: boolean) {
|
||||
if (!auth.isLoggedIn) {
|
||||
following.error = '登录后才能查看关注流'
|
||||
return
|
||||
}
|
||||
if (following.loading) return
|
||||
following.loading = true
|
||||
following.error = ''
|
||||
try {
|
||||
const res = await feedApi.listByFollowing({ limit: 10, latest_time: reset ? 0 : following.nextTime })
|
||||
following.hasMore = res.has_more
|
||||
following.nextTime = res.next_time
|
||||
following.items = reset ? res.video_list : following.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
following.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
following.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTabLoaded() {
|
||||
if (tab.value === 'recommend' && recommend.items.length === 0) await loadRecommend(true)
|
||||
if (tab.value === 'hot' && hot.items.length === 0) await loadHot(true)
|
||||
if (tab.value === 'following' && following.items.length === 0) await loadFollowing(true)
|
||||
}
|
||||
|
||||
async function loadMoreIfNeeded(activeIndex: number) {
|
||||
const items = currentState.value.items
|
||||
if (items.length === 0) return
|
||||
if (activeIndex < items.length - 3) return
|
||||
if (tab.value === 'recommend' && recommend.hasMore) await loadRecommend(false)
|
||||
if (tab.value === 'hot' && hot.hasMore) await loadHot(false)
|
||||
if (tab.value === 'following' && following.hasMore) await loadFollowing(false)
|
||||
}
|
||||
|
||||
return { tab, recommend, hot, following, currentState, loadRecommend, loadHot, loadFollowing, ensureTabLoaded, loadMoreIfNeeded }
|
||||
}
|
||||
78
frontend/src/composables/useVideoPlayer.ts
Normal file
78
frontend/src/composables/useVideoPlayer.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
export function useVideoPlayer(scrollerRef: ReturnType<typeof ref<HTMLDivElement | null>>) {
|
||||
const toast = useToastStore()
|
||||
const muted = ref(true)
|
||||
const activeIndex = ref(0)
|
||||
const videoMap = new Map<number, HTMLVideoElement>()
|
||||
|
||||
function getScrollerHeight() {
|
||||
return scrollerRef.value?.clientHeight ?? 0
|
||||
}
|
||||
|
||||
function setVideoRef(id: number, el: HTMLVideoElement | null) {
|
||||
if (el) {
|
||||
el.muted = muted.value
|
||||
videoMap.set(id, el)
|
||||
} else {
|
||||
videoMap.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToIndex(idx: number, totalItems: number) {
|
||||
const el = scrollerRef.value
|
||||
if (!el) return
|
||||
const h = getScrollerHeight()
|
||||
if (!h) return
|
||||
const next = Math.max(0, Math.min(idx, Math.max(0, totalItems - 1)))
|
||||
el.scrollTo({ top: next * h, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
let scrollRaf = 0
|
||||
function onScroll() {
|
||||
if (!scrollerRef.value) return
|
||||
if (scrollRaf) return
|
||||
scrollRaf = window.requestAnimationFrame(() => {
|
||||
scrollRaf = 0
|
||||
const el = scrollerRef.value
|
||||
if (!el) return
|
||||
const h = el.clientHeight
|
||||
if (!h) return
|
||||
const idx = Math.round(el.scrollTop / h)
|
||||
if (idx !== activeIndex.value) activeIndex.value = idx
|
||||
})
|
||||
}
|
||||
|
||||
async function playActive(activeItemId: number | undefined) {
|
||||
if (!activeItemId) return
|
||||
for (const [id, v] of videoMap.entries()) {
|
||||
if (id === activeItemId) continue
|
||||
v.pause()
|
||||
}
|
||||
const video = videoMap.get(activeItemId)
|
||||
if (!video) return
|
||||
video.muted = muted.value
|
||||
try {
|
||||
await video.play()
|
||||
} catch {
|
||||
/* ignore autoplay errors */
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
muted.value = !muted.value
|
||||
for (const v of videoMap.values()) v.muted = muted.value
|
||||
toast.info(muted.value ? '已静音' : '已取消静音')
|
||||
}
|
||||
|
||||
function togglePlayPause(activeItemId: number | undefined) {
|
||||
if (!activeItemId) return
|
||||
const video = videoMap.get(activeItemId)
|
||||
if (!video) return
|
||||
if (video.paused) void video.play()
|
||||
else video.pause()
|
||||
}
|
||||
|
||||
return { muted, activeIndex, videoMap, setVideoRef, scrollToIndex, onScroll, playActive, toggleMute, togglePlayPause }
|
||||
}
|
||||
@@ -3,8 +3,14 @@ import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { reportError } from './utils/error-reporter'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.config.errorHandler = (err, _instance, info) => {
|
||||
reportError(err instanceof Error ? err : new Error(String(err)), { info })
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -9,6 +9,7 @@ import ChangePasswordView from '../views/ChangePasswordView.vue'
|
||||
import RegisterView from '../views/RegisterView.vue'
|
||||
import SettingsView from '../views/SettingsView.vue'
|
||||
import UserProfileView from '../views/UserProfileView.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -16,14 +17,23 @@ const router = createRouter({
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/feed', redirect: '/' },
|
||||
{ path: '/hot', name: 'hot', component: HotView },
|
||||
{ path: '/video', name: 'video', component: VideoView },
|
||||
{ path: '/video', name: 'video', component: VideoView, meta: { requiresAuth: true } },
|
||||
{ path: '/video/:id', name: 'video-detail', component: VideoDetailView, props: true },
|
||||
{ path: '/account', name: 'account', component: AccountView },
|
||||
{ path: '/account/register', name: 'account-register', component: RegisterView },
|
||||
{ path: '/account/change-password', name: 'account-change-password', component: ChangePasswordView },
|
||||
{ path: '/settings', name: 'settings', component: SettingsView },
|
||||
{ path: '/settings', name: 'settings', component: SettingsView, meta: { requiresAuth: true } },
|
||||
{ path: '/u/:id', name: 'user-profile', component: UserProfileView, props: true },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.requiresAuth && !auth.isLoggedIn) {
|
||||
next({ path: '/account', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -3,43 +3,46 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { decodeJwtPayload, type JwtPayload } from '../utils/jwt'
|
||||
|
||||
const TOKEN_KEY = 'jwt_token'
|
||||
const ACCESS_KEY = 'access_token'
|
||||
const REFRESH_KEY = 'refresh_token'
|
||||
|
||||
function readToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
function readStored(key: string): string | null {
|
||||
try { return localStorage.getItem(key) } catch { return null }
|
||||
}
|
||||
|
||||
function writeToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
function writeStored(key: string, value: string) {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
|
||||
function removeToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
function removeStored(key: string) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(readToken())
|
||||
const token = ref<string | null>(readStored(ACCESS_KEY))
|
||||
const refreshToken = ref<string | null>(readStored(REFRESH_KEY))
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const claims = computed<JwtPayload | null>(() => (token.value ? decodeJwtPayload(token.value) : null))
|
||||
|
||||
function setToken(newToken: string) {
|
||||
token.value = newToken
|
||||
writeToken(newToken)
|
||||
writeStored(ACCESS_KEY, newToken)
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
function setTokens(access: string, refresh: string) {
|
||||
token.value = access
|
||||
refreshToken.value = refresh
|
||||
writeStored(ACCESS_KEY, access)
|
||||
writeStored(REFRESH_KEY, refresh)
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
token.value = null
|
||||
removeToken()
|
||||
refreshToken.value = null
|
||||
removeStored(ACCESS_KEY)
|
||||
removeStored(REFRESH_KEY)
|
||||
}
|
||||
|
||||
function syncFromStorage() {
|
||||
token.value = readToken()
|
||||
}
|
||||
|
||||
return { token, isLoggedIn, claims, setToken, clearToken, syncFromStorage }
|
||||
return { token, refreshToken, isLoggedIn, claims, setToken, setTokens, clearTokens }
|
||||
})
|
||||
|
||||
18
frontend/src/utils/error-reporter.ts
Normal file
18
frontend/src/utils/error-reporter.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function reportError(error: Error, context?: Record<string, unknown>) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[ErrorReporter]', error.message, context)
|
||||
return
|
||||
}
|
||||
fetch('/api/error-report', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
}).catch(() => {
|
||||
/* 静默失败,避免错误上报自身导致循环 */
|
||||
})
|
||||
}
|
||||
2
frontend/src/views/AccountView.vue
vendored
2
frontend/src/views/AccountView.vue
vendored
@@ -125,7 +125,7 @@ async function onLogin() {
|
||||
busy.value = true
|
||||
try {
|
||||
const res = await accountApi.login(username, password)
|
||||
auth.setToken(res.token)
|
||||
auth.setTokens(res.token, res.refresh_token ?? '')
|
||||
toast.success('登录成功')
|
||||
await social.refreshMine()
|
||||
await loadMyVideos()
|
||||
|
||||
877
frontend/src/views/HomeView.vue
vendored
877
frontend/src/views/HomeView.vue
vendored
@@ -1,19 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppShell from '../components/AppShell.vue'
|
||||
import CommentDrawer from '../components/CommentDrawer.vue'
|
||||
import UserAvatar from '../components/UserAvatar.vue'
|
||||
import { ApiError } from '../api/client'
|
||||
import * as commentApi from '../api/comment'
|
||||
import * as feedApi from '../api/feed'
|
||||
import * as likeApi from '../api/like'
|
||||
import type { Comment, FeedVideoItem } from '../api/types'
|
||||
import type { FeedVideoItem } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useSocialStore } from '../stores/social'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
|
||||
type TabKey = 'recommend' | 'hot' | 'following'
|
||||
import { useVideoFeed } from '../composables/useVideoFeed'
|
||||
import { useVideoPlayer } from '../composables/useVideoPlayer'
|
||||
import { useLikeFollow } from '../composables/useLikeFollow'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -21,418 +19,87 @@ const auth = useAuthStore()
|
||||
const social = useSocialStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const tab = ref<TabKey>('recommend')
|
||||
const { tab, following, currentState, loadFollowing, ensureTabLoaded, loadMoreIfNeeded } = useVideoFeed()
|
||||
const scroller = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const q = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
|
||||
const recommend = reactive({
|
||||
items: [] as FeedVideoItem[],
|
||||
loading: false,
|
||||
error: '',
|
||||
hasMore: false,
|
||||
nextTime: 0,
|
||||
})
|
||||
|
||||
const hot = reactive({
|
||||
items: [] as FeedVideoItem[],
|
||||
loading: false,
|
||||
error: '',
|
||||
hasMore: false,
|
||||
nextLikesCountBefore: undefined as number | undefined,
|
||||
nextIdBefore: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const following = reactive({
|
||||
items: [] as FeedVideoItem[],
|
||||
loading: false,
|
||||
error: '',
|
||||
hasMore: false,
|
||||
nextTime: 0,
|
||||
})
|
||||
|
||||
const likeBusy = reactive<Record<string, boolean>>({})
|
||||
const followBusy = reactive<Record<string, boolean>>({})
|
||||
|
||||
const muted = ref(true)
|
||||
const activeIndex = ref(0)
|
||||
const videoMap = new Map<number, HTMLVideoElement>()
|
||||
|
||||
const currentState = computed(() => {
|
||||
if (tab.value === 'hot') return hot
|
||||
if (tab.value === 'following') return following
|
||||
return recommend
|
||||
})
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const items = currentState.value.items
|
||||
if (!q.value) return items
|
||||
return items.filter((v) => v.title.toLowerCase().includes(q.value) || v.author.username.toLowerCase().includes(q.value))
|
||||
})
|
||||
|
||||
const activeItem = computed(() => filteredItems.value[activeIndex.value] ?? null)
|
||||
const myAccountId = computed(() => auth.claims?.account_id ?? 0)
|
||||
|
||||
function setVideoRef(id: number, el: HTMLVideoElement | null) {
|
||||
if (el) {
|
||||
el.muted = muted.value
|
||||
videoMap.set(id, el)
|
||||
} else {
|
||||
videoMap.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function getScrollerHeight() {
|
||||
return scroller.value?.clientHeight ?? 0
|
||||
}
|
||||
|
||||
function scrollToIndex(idx: number) {
|
||||
const el = scroller.value
|
||||
if (!el) return
|
||||
const h = getScrollerHeight()
|
||||
if (!h) return
|
||||
const next = Math.max(0, Math.min(idx, Math.max(0, filteredItems.value.length - 1)))
|
||||
el.scrollTo({ top: next * h, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
let scrollRaf = 0
|
||||
function onScroll() {
|
||||
if (!scroller.value) return
|
||||
if (scrollRaf) return
|
||||
scrollRaf = window.requestAnimationFrame(() => {
|
||||
scrollRaf = 0
|
||||
const el = scroller.value
|
||||
if (!el) return
|
||||
const h = el.clientHeight
|
||||
if (!h) return
|
||||
const idx = Math.round(el.scrollTop / h)
|
||||
if (idx !== activeIndex.value) activeIndex.value = idx
|
||||
})
|
||||
}
|
||||
|
||||
async function playActive() {
|
||||
const item = activeItem.value
|
||||
if (!item) return
|
||||
for (const [id, v] of videoMap.entries()) {
|
||||
if (id === item.id) continue
|
||||
v.pause()
|
||||
}
|
||||
const video = videoMap.get(item.id)
|
||||
if (!video) return
|
||||
video.muted = muted.value
|
||||
try {
|
||||
await video.play()
|
||||
} catch {
|
||||
// ignore autoplay errors
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
muted.value = !muted.value
|
||||
for (const v of videoMap.values()) v.muted = muted.value
|
||||
toast.info(muted.value ? '已静音' : '已取消静音')
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
const item = activeItem.value
|
||||
if (!item) return
|
||||
const video = videoMap.get(item.id)
|
||||
if (!video) return
|
||||
if (video.paused) {
|
||||
void video.play()
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
}
|
||||
const { muted, activeIndex, videoMap, setVideoRef, scrollToIndex, onScroll, playActive, toggleMute, togglePlayPause } = useVideoPlayer(scroller)
|
||||
|
||||
async function needLogin() {
|
||||
toast.error('请先登录')
|
||||
await router.push('/account')
|
||||
}
|
||||
|
||||
async function loadRecommend(reset: boolean) {
|
||||
if (recommend.loading) return
|
||||
recommend.loading = true
|
||||
recommend.error = ''
|
||||
try {
|
||||
const res = await feedApi.listLatest({ limit: 10, latest_time: reset ? 0 : recommend.nextTime })
|
||||
recommend.hasMore = res.has_more
|
||||
recommend.nextTime = res.next_time
|
||||
recommend.items = reset ? res.video_list : recommend.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
recommend.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
recommend.loading = false
|
||||
}
|
||||
}
|
||||
const { likeBusy, followBusy, toggleLike, toggleFollow, share } = useLikeFollow(needLogin)
|
||||
|
||||
async function loadHot(reset: boolean) {
|
||||
if (hot.loading) return
|
||||
hot.loading = true
|
||||
hot.error = ''
|
||||
try {
|
||||
const res = await feedApi.listLikesCount({
|
||||
limit: 10,
|
||||
likes_count_before: reset ? undefined : hot.nextLikesCountBefore,
|
||||
id_before: reset ? undefined : hot.nextIdBefore,
|
||||
const drawerVideo = ref<FeedVideoItem | null>(null)
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
function openComments(item: FeedVideoItem) { drawerVideo.value = item; drawerOpen.value = true }
|
||||
function closeDrawer() { drawerOpen.value = false; drawerVideo.value = null }
|
||||
|
||||
const q = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const filteredItems = computed(() => {
|
||||
const items = currentState.value.items
|
||||
if (!q.value) return items
|
||||
return items.filter((v) => v.title.toLowerCase().includes(q.value) || v.author.username.toLowerCase().includes(q.value))
|
||||
})
|
||||
hot.hasMore = res.has_more
|
||||
hot.nextLikesCountBefore = res.next_likes_count_before
|
||||
hot.nextIdBefore = res.next_id_before
|
||||
hot.items = reset ? res.video_list : hot.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
hot.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
hot.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFollowing(reset: boolean) {
|
||||
if (!auth.isLoggedIn) {
|
||||
following.error = '登录后才能查看关注流'
|
||||
return
|
||||
}
|
||||
if (following.loading) return
|
||||
following.loading = true
|
||||
following.error = ''
|
||||
try {
|
||||
const res = await feedApi.listByFollowing({ limit: 10, latest_time: reset ? 0 : following.nextTime })
|
||||
following.hasMore = res.has_more
|
||||
following.nextTime = res.next_time
|
||||
following.items = reset ? res.video_list : following.items.concat(res.video_list)
|
||||
} catch (e) {
|
||||
following.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
following.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTabLoaded() {
|
||||
if (tab.value === 'recommend' && recommend.items.length === 0) await loadRecommend(true)
|
||||
if (tab.value === 'hot' && hot.items.length === 0) await loadHot(true)
|
||||
if (tab.value === 'following' && following.items.length === 0) await loadFollowing(true)
|
||||
}
|
||||
|
||||
async function loadMoreIfNeeded() {
|
||||
const activeItem = computed(() => filteredItems.value[activeIndex.value] ?? null)
|
||||
const visibleRange = computed(() => {
|
||||
const idx = activeIndex.value
|
||||
const items = filteredItems.value
|
||||
if (items.length === 0) return
|
||||
if (idx < items.length - 3) return
|
||||
|
||||
if (tab.value === 'recommend' && recommend.hasMore) await loadRecommend(false)
|
||||
if (tab.value === 'hot' && hot.hasMore) await loadHot(false)
|
||||
if (tab.value === 'following' && following.hasMore) await loadFollowing(false)
|
||||
}
|
||||
|
||||
async function toggleLike(item: FeedVideoItem) {
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
const key = String(item.id)
|
||||
if (likeBusy[key]) return
|
||||
likeBusy[key] = true
|
||||
try {
|
||||
if (item.is_liked) await likeApi.unlike(item.id)
|
||||
else await likeApi.like(item.id)
|
||||
item.is_liked = !item.is_liked
|
||||
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
likeBusy[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollow(authorId: number) {
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
const key = String(authorId)
|
||||
if (followBusy[key]) return
|
||||
followBusy[key] = true
|
||||
try {
|
||||
if (social.isFollowing(authorId)) {
|
||||
await social.unfollow(authorId)
|
||||
toast.info('已取关')
|
||||
} else {
|
||||
await social.follow(authorId)
|
||||
toast.success('已关注')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
followBusy[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function share(item: FeedVideoItem) {
|
||||
const url = `${location.origin}/video/${item.id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success('链接已复制')
|
||||
} catch {
|
||||
window.prompt('复制链接', url)
|
||||
}
|
||||
}
|
||||
|
||||
const drawer = reactive({
|
||||
open: false,
|
||||
video: null as FeedVideoItem | null,
|
||||
loading: false,
|
||||
error: '',
|
||||
comments: [] as Comment[],
|
||||
content: '',
|
||||
const len = filteredItems.value.length
|
||||
return { start: Math.max(0, idx - 1), end: Math.min(len - 1, idx + 1) }
|
||||
})
|
||||
|
||||
function closeDrawer() {
|
||||
drawer.open = false
|
||||
drawer.video = null
|
||||
drawer.comments = []
|
||||
drawer.content = ''
|
||||
drawer.error = ''
|
||||
}
|
||||
|
||||
async function openComments(item: FeedVideoItem) {
|
||||
drawer.open = true
|
||||
drawer.video = item
|
||||
drawer.content = ''
|
||||
await loadComments()
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
if (!drawer.video) return
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
drawer.comments = await commentApi.listAll(drawer.video.id)
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publishComment() {
|
||||
if (!drawer.video) return
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
const content = drawer.content.trim()
|
||||
if (!content) return
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
await commentApi.publish(drawer.video.id, content)
|
||||
drawer.content = ''
|
||||
await loadComments()
|
||||
toast.success('评论已发布')
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(drawer.error)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function canDeleteComment(c: Comment) {
|
||||
const myId = auth.claims?.account_id
|
||||
return !!myId && myId === c.author_id
|
||||
}
|
||||
|
||||
async function deleteComment(commentId: number) {
|
||||
if (!drawer.video) return
|
||||
if (!auth.isLoggedIn) return needLogin()
|
||||
if (!window.confirm('确认删除这条评论?')) return
|
||||
drawer.loading = true
|
||||
drawer.error = ''
|
||||
try {
|
||||
await commentApi.remove(commentId)
|
||||
await loadComments()
|
||||
toast.info('评论已删除')
|
||||
} catch (e) {
|
||||
drawer.error = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(drawer.error)
|
||||
} finally {
|
||||
drawer.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onKeydown(e: KeyboardEvent) {
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return
|
||||
if (drawer.open) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
scrollToIndex(activeIndex.value + 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
scrollToIndex(activeIndex.value - 1)
|
||||
} else if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
togglePlayPause()
|
||||
} else if (e.key.toLowerCase() === 'm') {
|
||||
e.preventDefault()
|
||||
toggleMute()
|
||||
} else if (e.key.toLowerCase() === 'c') {
|
||||
if (activeItem.value) {
|
||||
e.preventDefault()
|
||||
await openComments(activeItem.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
const myAccountId = computed(() => auth.claims?.account_id ?? 0)
|
||||
|
||||
watch(activeItem, async () => {
|
||||
await nextTick()
|
||||
await playActive()
|
||||
await loadMoreIfNeeded()
|
||||
await playActive(activeItem.value?.id)
|
||||
await loadMoreIfNeeded(activeIndex.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => tab.value,
|
||||
async () => {
|
||||
watch(() => tab.value, async () => {
|
||||
activeIndex.value = 0
|
||||
videoMap.clear()
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
await ensureTabLoaded()
|
||||
await nextTick()
|
||||
await playActive()
|
||||
},
|
||||
)
|
||||
await playActive(activeItem.value?.id)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => q.value,
|
||||
async () => {
|
||||
watch(() => q.value, async () => {
|
||||
activeIndex.value = 0
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
await nextTick()
|
||||
await playActive()
|
||||
},
|
||||
)
|
||||
await playActive(activeItem.value?.id)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => filteredItems.value.length,
|
||||
(len) => {
|
||||
watch(() => filteredItems.value.length, (len) => {
|
||||
if (len === 0) activeIndex.value = 0
|
||||
else if (activeIndex.value > len - 1) activeIndex.value = len - 1
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => auth.isLoggedIn,
|
||||
async (v) => {
|
||||
if (tab.value === 'following' && v && following.items.length === 0) {
|
||||
await loadFollowing(true)
|
||||
watch(() => auth.isLoggedIn, async (v) => {
|
||||
if (tab.value === 'following' && v && following.items.length === 0) await loadFollowing(true)
|
||||
})
|
||||
|
||||
async function onKeydown(e: KeyboardEvent) {
|
||||
const t = e.target as HTMLElement | null
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return
|
||||
if (drawerOpen.value) return
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); scrollToIndex(activeIndex.value + 1, filteredItems.value.length) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); scrollToIndex(activeIndex.value - 1, filteredItems.value.length) }
|
||||
else if (e.key === ' ') { e.preventDefault(); togglePlayPause(activeItem.value?.id) }
|
||||
else if (e.key.toLowerCase() === 'm') { e.preventDefault(); toggleMute() }
|
||||
else if (e.key.toLowerCase() === 'c') { if (activeItem.value) { e.preventDefault(); openComments(activeItem.value) } }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureTabLoaded()
|
||||
await nextTick()
|
||||
await playActive()
|
||||
await playActive(activeItem.value?.id)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -442,7 +109,6 @@ onBeforeUnmount(() => {
|
||||
<button class="tab" :class="{ on: tab === 'recommend' }" type="button" @click="tab = 'recommend'">推荐</button>
|
||||
<button class="tab" :class="{ on: tab === 'following' }" type="button" @click="tab = 'following'">关注</button>
|
||||
<button class="tab" :class="{ on: tab === 'hot' }" type="button" @click="tab = 'hot'">点赞榜</button>
|
||||
|
||||
<div class="tabs-right">
|
||||
<button class="chip" type="button" @click="toggleMute">{{ muted ? '静音' : '有声' }}</button>
|
||||
<RouterLink class="chip" :to="activeItem ? `/video/${activeItem.id}` : '/video'">详情</RouterLink>
|
||||
@@ -451,29 +117,25 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div ref="scroller" class="scroller" @scroll="onScroll">
|
||||
<div v-if="currentState.loading && currentState.items.length === 0" class="center-hint">加载中…</div>
|
||||
<div v-else-if="currentState.error && currentState.items.length === 0" class="center-hint bad">
|
||||
{{ currentState.error }}
|
||||
</div>
|
||||
<div v-else-if="currentState.error && currentState.items.length === 0" class="center-hint bad">{{ currentState.error }}</div>
|
||||
<div v-else-if="filteredItems.length === 0" class="center-hint">没有匹配内容</div>
|
||||
|
||||
<section
|
||||
v-for="(item, idx) in filteredItems"
|
||||
:key="`${tab}-${item.id}`"
|
||||
v-show="idx >= visibleRange.start && idx <= visibleRange.end"
|
||||
class="slide"
|
||||
:class="{ active: idx === activeIndex }"
|
||||
>
|
||||
<div class="stage" @click="togglePlayPause" @dblclick.prevent="toggleLike(item)">
|
||||
<div class="stage" @click="togglePlayPause(activeItem?.id)" @dblclick.prevent="toggleLike(item)">
|
||||
<video
|
||||
class="video"
|
||||
:ref="(el) => setVideoRef(item.id, el as HTMLVideoElement | null)"
|
||||
:src="item.play_url"
|
||||
:poster="item.cover_url"
|
||||
playsinline
|
||||
preload="metadata"
|
||||
loop
|
||||
playsinline preload="metadata" loop
|
||||
/>
|
||||
<div class="grad" />
|
||||
|
||||
<div class="meta">
|
||||
<RouterLink class="author-link" :to="`/u/${item.author.id}`" @click.stop>
|
||||
<UserAvatar :username="item.author.username" :id="item.author.id" :size="34" />
|
||||
@@ -482,35 +144,28 @@ onBeforeUnmount(() => {
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div v-if="item.description" class="desc">{{ item.description }}</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="act" type="button" :disabled="!!likeBusy[String(item.id)]" @click.stop="toggleLike(item)">
|
||||
<span class="icon" :class="{ liked: item.is_liked }">♥</span>
|
||||
<span class="count">{{ item.likes_count }}</span>
|
||||
</button>
|
||||
|
||||
<button class="act" type="button" @click.stop="openComments(item)">
|
||||
<span class="icon">💬</span>
|
||||
<span class="count">评论</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!myAccountId || myAccountId !== item.author.id"
|
||||
class="act"
|
||||
type="button"
|
||||
class="act" type="button"
|
||||
:disabled="!!followBusy[String(item.author.id)]"
|
||||
@click.stop="toggleFollow(item.author.id)"
|
||||
>
|
||||
@click.stop="toggleFollow(item.author.id)">
|
||||
<span class="icon">+</span>
|
||||
<span class="count">{{ social.isFollowing(item.author.id) ? '已关注' : '关注' }}</span>
|
||||
</button>
|
||||
|
||||
<button class="act" type="button" @click.stop="share(item)">
|
||||
<span class="icon">↗</span>
|
||||
<span class="count">分享</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hint">
|
||||
<span class="chip mono">↑ ↓ 切换</span>
|
||||
<span class="chip mono">空格 暂停</span>
|
||||
@@ -521,398 +176,40 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="drawer.open" class="drawer-backdrop" @click.self="closeDrawer">
|
||||
<div class="drawer">
|
||||
<div class="drawer-head">
|
||||
<div class="drawer-title">{{ drawer.video?.title ?? '评论' }}</div>
|
||||
<button class="drawer-x" type="button" @click="closeDrawer">×</button>
|
||||
</div>
|
||||
|
||||
<div class="drawer-body">
|
||||
<div v-if="drawer.loading" class="drawer-hint">加载中…</div>
|
||||
<div v-else-if="drawer.error" class="drawer-hint bad">{{ drawer.error }}</div>
|
||||
<div v-else-if="drawer.comments.length === 0" class="drawer-hint">暂无评论</div>
|
||||
|
||||
<div class="comment" v-for="c in drawer.comments" :key="c.id">
|
||||
<div class="comment-top">
|
||||
<div class="comment-user">{{ c.username }}</div>
|
||||
<div class="comment-meta mono">
|
||||
#{{ c.id }} · {{ new Date(c.created_at).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-content">{{ c.content }}</div>
|
||||
<div class="comment-actions">
|
||||
<button v-if="canDeleteComment(c)" class="chip danger" type="button" :disabled="drawer.loading" @click="deleteComment(c.id)">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-foot">
|
||||
<textarea v-model="drawer.content" placeholder="说点什么…" :disabled="drawer.loading" />
|
||||
<div class="row" style="justify-content: space-between; margin-top: 8px">
|
||||
<button class="chip" type="button" :disabled="drawer.loading" @click="loadComments">刷新</button>
|
||||
<button class="chip primary" type="button" :disabled="drawer.loading || !drawer.content.trim()" @click="publishComment">
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CommentDrawer v-if="drawerOpen" :video="drawerVideo" @close="closeDrawer" />
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-radius: 999px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.on {
|
||||
border-color: rgba(254, 44, 85, 0.5);
|
||||
background: rgba(254, 44, 85, 0.16);
|
||||
}
|
||||
|
||||
.tabs-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scroll-snap-type: y mandatory;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge legacy */
|
||||
}
|
||||
|
||||
.scroller::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.center-hint {
|
||||
height: calc(100% - 60px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.center-hint.bad {
|
||||
color: rgba(254, 44, 85, 0.92);
|
||||
}
|
||||
|
||||
.slide {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
scroll-snap-align: start;
|
||||
padding: 18px 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.stage {
|
||||
width: min(980px, calc(100vw - 28px));
|
||||
height: calc(100vh - 56px - 52px - 36px);
|
||||
position: relative;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.grad {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.68), rgba(0, 0, 0, 0.12) 40%, rgba(0, 0, 0, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.meta {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 18px;
|
||||
max-width: min(620px, calc(100% - 96px));
|
||||
}
|
||||
|
||||
.author-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.2px;
|
||||
margin-bottom: 6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.author-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.author-name {
|
||||
text-shadow: 0 14px 30px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.act {
|
||||
width: 70px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
padding: 10px 10px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.act:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.act:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.icon.liked {
|
||||
color: rgba(254, 44, 85, 1);
|
||||
text-shadow: 0 10px 20px rgba(254, 44, 85, 0.25);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 14px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chip.primary {
|
||||
border-color: rgba(254, 44, 85, 0.45);
|
||||
background: rgba(254, 44, 85, 0.14);
|
||||
}
|
||||
|
||||
.chip.danger {
|
||||
border-color: rgba(254, 44, 85, 0.55);
|
||||
background: rgba(254, 44, 85, 0.12);
|
||||
}
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 120;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: min(420px, calc(100vw - 18px));
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.drawer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-x {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.drawer-body {
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.drawer-foot {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.drawer-foot textarea {
|
||||
width: 100%;
|
||||
min-height: 82px;
|
||||
resize: none;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drawer-hint {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.drawer-hint.bad {
|
||||
color: rgba(254, 44, 85, 0.92);
|
||||
}
|
||||
|
||||
.comment {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 14px;
|
||||
padding: 10px 10px;
|
||||
}
|
||||
|
||||
.comment-top {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.comment-user {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stage {
|
||||
width: calc(100vw - 28px);
|
||||
height: calc(100vh - 56px - 52px - 36px);
|
||||
}
|
||||
.drawer-backdrop {
|
||||
justify-items: center;
|
||||
align-items: end;
|
||||
}
|
||||
.drawer {
|
||||
width: calc(100vw - 16px);
|
||||
height: min(72vh, 560px);
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px 18px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
.page { height: 100%; display: flex; flex-direction: column; }
|
||||
.tabs { height: 52px; display: flex; align-items: center; gap: 10px; padding: 0 14px; border-bottom: 1px solid rgba(255,255,255,0.08); background: rgba(0,0,0,0.25); backdrop-filter: blur(16px); }
|
||||
.tab { border: 1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.88); border-radius: 999px; padding: 8px 14px; cursor: pointer; }
|
||||
.tab.on { border-color: rgba(254,44,85,0.5); background: rgba(254,44,85,0.16); }
|
||||
.tabs-right { margin-left: auto; display: flex; gap: 10px; align-items: center; }
|
||||
.scroller { flex: 1; min-height: 0; overflow-y: auto; scroll-snap-type: y mandatory; scroll-behavior: smooth; scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.scroller::-webkit-scrollbar { width: 0; height: 0; }
|
||||
.center-hint { height: calc(100% - 60px); display: grid; place-items: center; color: rgba(255,255,255,0.78); }
|
||||
.center-hint.bad { color: rgba(254,44,85,0.92); }
|
||||
.slide { height: 100%; box-sizing: border-box; scroll-snap-align: start; padding: 18px 14px; display: grid; place-items: center; }
|
||||
.stage { width: min(980px, calc(100vw - 28px)); height: calc(100vh - 56px - 52px - 36px); position: relative; border-radius: 18px; overflow: hidden; border: 1px solid rgba(255,255,255,0.12); background: rgba(0,0,0,0.35); box-shadow: 0 20px 60px rgba(0,0,0,0.55); }
|
||||
.video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: rgba(0,0,0,0.4); }
|
||||
.grad { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.68), rgba(0,0,0,0.12) 40%, rgba(0,0,0,0) 70%); pointer-events: none; }
|
||||
.meta { position: absolute; left: 16px; bottom: 18px; max-width: min(620px, calc(100% - 96px)); }
|
||||
.author-link { display: inline-flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: 0.2px; margin-bottom: 6px; text-decoration: none; }
|
||||
.author-link:hover { text-decoration: none; }
|
||||
.author-name { text-shadow: 0 14px 30px rgba(0,0,0,0.55); }
|
||||
.title { font-size: 16px; font-weight: 700; margin-bottom: 6px; }
|
||||
.desc { color: rgba(255,255,255,0.74); font-size: 13px; line-height: 1.35; }
|
||||
.actions { position: absolute; right: 12px; bottom: 18px; display: grid; gap: 12px; }
|
||||
.act { width: 70px; border-radius: 16px; border: 1px solid rgba(255,255,255,0.14); background: rgba(0,0,0,0.32); color: rgba(255,255,255,0.92); padding: 10px 10px; cursor: pointer; display: grid; gap: 6px; justify-items: center; }
|
||||
.act:hover { background: rgba(255,255,255,0.1); }
|
||||
.act:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.icon { font-size: 20px; line-height: 1; opacity: 0.92; }
|
||||
.icon.liked { color: rgba(254,44,85,1); text-shadow: 0 10px 20px rgba(254,44,85,0.25); }
|
||||
.count { font-size: 12px; color: rgba(255,255,255,0.8); }
|
||||
.hint { position: absolute; left: 14px; top: 14px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 8px; padding: 7px 10px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.14); background: rgba(0,0,0,0.28); color: rgba(255,255,255,0.86); font-size: 12px; text-decoration: none; }
|
||||
.chip.primary { border-color: rgba(254,44,85,0.45); background: rgba(254,44,85,0.14); }
|
||||
.chip.danger { border-color: rgba(254,44,85,0.55); background: rgba(254,44,85,0.12); }
|
||||
</style>
|
||||
|
||||
2
frontend/src/views/SettingsView.vue
vendored
2
frontend/src/views/SettingsView.vue
vendored
@@ -75,7 +75,7 @@ async function onLogout() {
|
||||
const msg = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(`登出失败:${msg}`)
|
||||
} finally {
|
||||
auth.clearToken()
|
||||
auth.clearTokens()
|
||||
rename.open = false
|
||||
toast.info('已退出登录')
|
||||
busy.value = false
|
||||
|
||||
Reference in New Issue
Block a user