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
|
# 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
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@@ -16,44 +24,100 @@ docker compose up -d --build
|
|||||||
访问:
|
访问:
|
||||||
- 前端:`http://localhost:5173`
|
- 前端:`http://localhost:5173`
|
||||||
- 后端 API:`http://localhost:8080`
|
- 后端 API:`http://localhost:8080`
|
||||||
- RabbitMQ 管理台:`http://localhost:15672`(默认账号 `admin` / `password123`)
|
- RabbitMQ 管理台:`http://localhost:15672`(`admin` / `password123`)
|
||||||
|
|
||||||
说明:
|
默认 `.env` 自动生成 JWT 密钥。生产环境请修改 `JWT_SECRET`。
|
||||||
- Compose 会启动 `mysql`、`redis`、`rabbitmq`、`backend`(API)、`worker`、`frontend`。
|
|
||||||
- 容器内后端配置使用 `backend/configs/config.docker.yaml`(会挂载到 `/app/configs/config.yaml`)。
|
|
||||||
|
|
||||||
## 本地开发启动(不容器化)
|
## 测试数据
|
||||||
|
|
||||||
|
启动后内置 100 个测试用户(`user001` ~ `user100`,密码均为 `123456`),`user001` 已发布视频并拥有粉丝/点赞数据。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
1) 先启动依赖(也可以只用 compose 拉起依赖):
|
|
||||||
```bash
|
```bash
|
||||||
|
# 启动依赖
|
||||||
docker compose up -d mysql redis rabbitmq
|
docker compose up -d mysql redis rabbitmq
|
||||||
```
|
|
||||||
|
|
||||||
2) 用 compose 启 MySQL 时,宿主机端口是 `3307`。本地 Go 进程请使用面向 compose 依赖的配置文件 `backend/configs/config.compose-local.yaml`。
|
# 后端
|
||||||
|
|
||||||
3) 启动后端 API:
|
|
||||||
```bash
|
|
||||||
cd backend
|
cd backend
|
||||||
CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd
|
CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd
|
||||||
```
|
|
||||||
|
|
||||||
4) 启动 Worker(消费 MQ、异步落库/更新 Redis 热榜):
|
# Worker
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd/worker
|
CONFIG_PATH=configs/config.compose-local.yaml go run ./cmd/worker
|
||||||
```
|
|
||||||
|
|
||||||
5) 启动前端(开发模式):
|
# 前端
|
||||||
```bash
|
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install && npm run dev
|
||||||
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`。
|
||||||
|
|||||||
@@ -1,295 +1,316 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"feedsystem_video_go/internal/config"
|
"feedsystem_video_go/internal/config"
|
||||||
"feedsystem_video_go/internal/db"
|
"feedsystem_video_go/internal/db"
|
||||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
"feedsystem_video_go/internal/observability"
|
"feedsystem_video_go/internal/observability"
|
||||||
"feedsystem_video_go/internal/social"
|
"feedsystem_video_go/internal/social"
|
||||||
"feedsystem_video_go/internal/video"
|
"feedsystem_video_go/internal/video"
|
||||||
"feedsystem_video_go/internal/worker"
|
"feedsystem_video_go/internal/worker"
|
||||||
"log"
|
mqrabbit "feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
"os"
|
"log"
|
||||||
"os/signal"
|
"os"
|
||||||
"strconv"
|
"os/signal"
|
||||||
"syscall"
|
"strconv"
|
||||||
"time"
|
"syscall"
|
||||||
|
"time"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
"gorm.io/gorm"
|
||||||
const (
|
)
|
||||||
socialExchange = "social.events"
|
|
||||||
socialQueue = "social.events"
|
const (
|
||||||
socialBindingKey = "social.*"
|
socialExchange = "social.events"
|
||||||
|
socialQueue = "social.events"
|
||||||
likeExchange = "like.events"
|
socialBindingKey = "social.*"
|
||||||
likeQueue = "like.events"
|
|
||||||
likeBindingKey = "like.*"
|
likeExchange = "like.events"
|
||||||
|
likeQueue = "like.events"
|
||||||
commentExchange = "comment.events"
|
likeBindingKey = "like.*"
|
||||||
commentQueue = "comment.events"
|
|
||||||
commentBindingKey = "comment.*"
|
commentExchange = "comment.events"
|
||||||
|
commentQueue = "comment.events"
|
||||||
popularityExchange = "video.popularity.events"
|
commentBindingKey = "comment.*"
|
||||||
popularityQueue = "video.popularity.events"
|
|
||||||
popularityBindingKey = "video.popularity.*"
|
popularityExchange = "video.popularity.events"
|
||||||
)
|
popularityQueue = "video.popularity.events"
|
||||||
|
popularityBindingKey = "video.popularity.*"
|
||||||
func main() {
|
)
|
||||||
// 加载配置
|
|
||||||
configPath := os.Getenv("CONFIG_PATH")
|
func connectWithRetry(name string, maxRetries int, fn func() error) {
|
||||||
if configPath == "" {
|
for i := 0; i < maxRetries; i++ {
|
||||||
configPath = "configs/config.yaml"
|
if err := fn(); err == nil {
|
||||||
}
|
return
|
||||||
log.Printf("Loading config from %s", configPath)
|
}
|
||||||
cfg, usedDefault, err := config.LoadLocalDev(configPath)
|
wait := time.Duration(1<<i) * time.Second
|
||||||
if err != nil {
|
if wait > 30*time.Second {
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
wait = 30 * time.Second
|
||||||
}
|
}
|
||||||
if usedDefault {
|
log.Printf("%s 不可用,%v 后重试 (%d/%d)...", name, wait, i+1, maxRetries)
|
||||||
log.Printf("Config File %s not found, using default local config", configPath)
|
time.Sleep(wait)
|
||||||
} else {
|
}
|
||||||
log.Printf("Config loaded from file: %s", configPath)
|
log.Fatalf("%s: 超过最大重试次数", name)
|
||||||
}
|
}
|
||||||
// 连接数据库
|
|
||||||
sqlDB, err := db.NewDB(cfg.Database)
|
func main() {
|
||||||
if err != nil {
|
// 加载配置
|
||||||
log.Fatalf("Failed to connect database: %v", err)
|
configPath := os.Getenv("CONFIG_PATH")
|
||||||
}
|
if configPath == "" {
|
||||||
defer db.CloseDB(sqlDB)
|
configPath = "configs/config.yaml"
|
||||||
|
}
|
||||||
// 连接 Redis(用于流行度更新)
|
log.Printf("Loading config from %s", configPath)
|
||||||
cache, err := rediscache.NewFromEnv(&cfg.Redis)
|
cfg, usedDefault, err := config.LoadLocalDev(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Redis config error (popularity worker disabled): %v", err)
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
cache = nil
|
}
|
||||||
} else {
|
if usedDefault {
|
||||||
pingCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
log.Printf("Config File %s not found, using default local config", configPath)
|
||||||
defer cancel()
|
} else {
|
||||||
if err := cache.Ping(pingCtx); err != nil {
|
log.Printf("Config loaded from file: %s", configPath)
|
||||||
log.Printf("Redis not available (popularity worker disabled): %v", err)
|
}
|
||||||
_ = cache.Close()
|
// 连接数据库(带重试)
|
||||||
cache = nil
|
var sqlDB *gorm.DB
|
||||||
} else {
|
connectWithRetry("MySQL", 10, func() error {
|
||||||
defer cache.Close()
|
var err error
|
||||||
log.Printf("Redis connected (popularity worker enabled)")
|
sqlDB, err = db.NewDB(cfg.Database)
|
||||||
}
|
return err
|
||||||
}
|
})
|
||||||
// 连接 RabbitMQ
|
defer db.CloseDB(sqlDB)
|
||||||
url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/"
|
|
||||||
conn, err := amqp.Dial(url)
|
// 连接 Redis(用于流行度更新)
|
||||||
if err != nil {
|
cache, err := rediscache.NewFromEnv(&cfg.Redis)
|
||||||
log.Fatalf("Failed to connect rabbitmq: %v", err)
|
if err != nil {
|
||||||
}
|
log.Printf("Redis config error (popularity worker disabled): %v", err)
|
||||||
defer conn.Close()
|
cache = nil
|
||||||
// 创建 RabbitMQ 通道
|
} else {
|
||||||
ch, err := conn.Channel()
|
pingCtx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
if err != nil {
|
defer cancel()
|
||||||
log.Fatalf("Failed to open rabbitmq channel: %v", err)
|
if err := cache.Ping(pingCtx); err != nil {
|
||||||
}
|
log.Printf("Redis not available (popularity worker disabled): %v", err)
|
||||||
defer ch.Close()
|
_ = cache.Close()
|
||||||
// 声明 Social 交换机和队列
|
cache = nil
|
||||||
if err := declareSocialTopology(ch); err != nil {
|
} else {
|
||||||
log.Fatalf("Failed to declare social topology: %v", err)
|
defer cache.Close()
|
||||||
}
|
log.Printf("Redis connected (popularity worker enabled)")
|
||||||
if err := declareLikeTopology(ch); err != nil {
|
}
|
||||||
log.Fatalf("Failed to declare like topology: %v", err)
|
}
|
||||||
}
|
// 连接 RabbitMQ(带重试)
|
||||||
if err := declareCommentTopology(ch); err != nil {
|
url := "amqp://" + cfg.RabbitMQ.Username + ":" + cfg.RabbitMQ.Password + "@" + cfg.RabbitMQ.Host + ":" + strconv.Itoa(cfg.RabbitMQ.Port) + "/"
|
||||||
log.Fatalf("Failed to declare comment topology: %v", err)
|
var conn *amqp.Connection
|
||||||
}
|
connectWithRetry("RabbitMQ", 10, func() error {
|
||||||
if cache != nil {
|
var err error
|
||||||
if err := declarePopularityTopology(ch); err != nil {
|
conn, err = amqp.Dial(url)
|
||||||
log.Fatalf("Failed to declare popularity topology: %v", err)
|
return err
|
||||||
}
|
})
|
||||||
}
|
defer conn.Close()
|
||||||
if err := ch.Qos(50, 0, false); err != nil {
|
// 创建 RabbitMQ 通道
|
||||||
log.Fatalf("Failed to set qos: %v", err)
|
ch, err := conn.Channel()
|
||||||
}
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to open rabbitmq channel: %v", err)
|
||||||
repo := social.NewSocialRepository(sqlDB)
|
}
|
||||||
socialWorker := worker.NewSocialWorker(ch, repo, socialQueue)
|
defer ch.Close()
|
||||||
videoRepo := video.NewVideoRepository(sqlDB)
|
// 声明 Social 交换机和队列
|
||||||
likeRepo := video.NewLikeRepository(sqlDB)
|
if err := declareSocialTopology(ch); err != nil {
|
||||||
commentRepo := video.NewCommentRepository(sqlDB)
|
log.Fatalf("Failed to declare social topology: %v", err)
|
||||||
likeWorker := worker.NewLikeWorker(ch, likeRepo, videoRepo, likeQueue)
|
}
|
||||||
commentWorker := worker.NewCommentWorker(ch, commentRepo, videoRepo, commentQueue)
|
if err := declareLikeTopology(ch); err != nil {
|
||||||
var popularityWorker *worker.PopularityWorker
|
log.Fatalf("Failed to declare like topology: %v", err)
|
||||||
if cache != nil {
|
}
|
||||||
popularityWorker = worker.NewPopularityWorker(ch, cache, popularityQueue)
|
if err := declareCommentTopology(ch); err != nil {
|
||||||
}
|
log.Fatalf("Failed to declare comment topology: %v", err)
|
||||||
|
}
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
if cache != nil {
|
||||||
defer stop()
|
if err := declarePopularityTopology(ch); err != nil {
|
||||||
|
log.Fatalf("Failed to declare popularity topology: %v", err)
|
||||||
pprofServer, err := observability.NewPprofServer(
|
}
|
||||||
"Worker",
|
}
|
||||||
cfg.ObservabilityConfig.Pprof.Enabled,
|
if err := ch.Qos(50, 0, false); err != nil {
|
||||||
cfg.ObservabilityConfig.Pprof.WorkerAddr,
|
log.Fatalf("Failed to set qos: %v", err)
|
||||||
)
|
}
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to start worker pprof server: %v", err)
|
repo := social.NewSocialRepository(sqlDB)
|
||||||
}
|
socialWorker := worker.NewSocialWorker(ch, repo, socialQueue)
|
||||||
if pprofServer != nil {
|
videoRepo := video.NewVideoRepository(sqlDB)
|
||||||
defer pprofServer.Close()
|
likeRepo := video.NewLikeRepository(sqlDB)
|
||||||
}
|
commentRepo := video.NewCommentRepository(sqlDB)
|
||||||
|
likeWorker := worker.NewLikeWorker(ch, likeRepo, videoRepo, likeQueue)
|
||||||
errCh := make(chan error, 4)
|
commentWorker := worker.NewCommentWorker(ch, commentRepo, videoRepo, commentQueue)
|
||||||
log.Printf("Worker started, consuming queue=%s", socialQueue)
|
var popularityWorker *worker.PopularityWorker
|
||||||
go func() { errCh <- socialWorker.Run(ctx) }()
|
if cache != nil {
|
||||||
log.Printf("Worker started, consuming queue=%s", likeQueue)
|
popularityWorker = worker.NewPopularityWorker(ch, cache, popularityQueue)
|
||||||
go func() { errCh <- likeWorker.Run(ctx) }()
|
}
|
||||||
log.Printf("Worker started, consuming queue=%s", commentQueue)
|
|
||||||
go func() { errCh <- commentWorker.Run(ctx) }()
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
if popularityWorker != nil {
|
defer stop()
|
||||||
log.Printf("Worker started, consuming queue=%s", popularityQueue)
|
|
||||||
go func() { errCh <- popularityWorker.Run(ctx) }()
|
pprofServer, err := observability.NewPprofServer(
|
||||||
}
|
"Worker",
|
||||||
|
cfg.ObservabilityConfig.Pprof.Enabled,
|
||||||
err = <-errCh
|
cfg.ObservabilityConfig.Pprof.WorkerAddr,
|
||||||
if err != nil && err != context.Canceled {
|
)
|
||||||
log.Fatalf("Worker stopped: %v", err)
|
if err != nil {
|
||||||
}
|
log.Printf("Failed to start worker pprof server: %v", err)
|
||||||
log.Printf("Worker stopped")
|
}
|
||||||
}
|
if pprofServer != nil {
|
||||||
|
defer pprofServer.Close()
|
||||||
func declareSocialTopology(ch *amqp.Channel) error {
|
}
|
||||||
if err := ch.ExchangeDeclare(
|
|
||||||
socialExchange,
|
errCh := make(chan error, 4)
|
||||||
"topic",
|
log.Printf("Worker started, consuming queue=%s", socialQueue)
|
||||||
true,
|
go func() { errCh <- socialWorker.Run(ctx) }()
|
||||||
false,
|
log.Printf("Worker started, consuming queue=%s", likeQueue)
|
||||||
false,
|
go func() { errCh <- likeWorker.Run(ctx) }()
|
||||||
false,
|
log.Printf("Worker started, consuming queue=%s", commentQueue)
|
||||||
nil,
|
go func() { errCh <- commentWorker.Run(ctx) }()
|
||||||
); err != nil {
|
if popularityWorker != nil {
|
||||||
return err
|
log.Printf("Worker started, consuming queue=%s", popularityQueue)
|
||||||
}
|
go func() { errCh <- popularityWorker.Run(ctx) }()
|
||||||
|
}
|
||||||
q, err := ch.QueueDeclare(
|
|
||||||
socialQueue,
|
err = <-errCh
|
||||||
true,
|
if err != nil && err != context.Canceled {
|
||||||
false,
|
log.Fatalf("Worker stopped: %v", err)
|
||||||
false,
|
}
|
||||||
false,
|
log.Printf("Worker stopped")
|
||||||
nil,
|
}
|
||||||
)
|
|
||||||
if err != nil {
|
func declareSocialTopology(ch *amqp.Channel) error {
|
||||||
return err
|
if err := ch.ExchangeDeclare(
|
||||||
}
|
socialExchange,
|
||||||
|
"topic",
|
||||||
if err := ch.QueueBind(
|
true,
|
||||||
q.Name,
|
false,
|
||||||
socialBindingKey,
|
false,
|
||||||
socialExchange,
|
false,
|
||||||
false,
|
nil,
|
||||||
nil,
|
); err != nil {
|
||||||
); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
|
||||||
return nil
|
q, err := ch.QueueDeclare(
|
||||||
}
|
socialQueue,
|
||||||
|
true,
|
||||||
func declarePopularityTopology(ch *amqp.Channel) error {
|
false,
|
||||||
if err := ch.ExchangeDeclare(
|
false,
|
||||||
popularityExchange,
|
false,
|
||||||
"topic",
|
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
|
||||||
true,
|
)
|
||||||
false,
|
if err != nil {
|
||||||
false,
|
return err
|
||||||
false,
|
}
|
||||||
nil,
|
|
||||||
); err != nil {
|
if err := ch.QueueBind(
|
||||||
return err
|
q.Name,
|
||||||
}
|
socialBindingKey,
|
||||||
|
socialExchange,
|
||||||
q, err := ch.QueueDeclare(
|
false,
|
||||||
popularityQueue,
|
nil,
|
||||||
true,
|
); err != nil {
|
||||||
false,
|
return err
|
||||||
false,
|
}
|
||||||
false,
|
return nil
|
||||||
nil,
|
}
|
||||||
)
|
|
||||||
if err != nil {
|
func declarePopularityTopology(ch *amqp.Channel) error {
|
||||||
return err
|
if err := ch.ExchangeDeclare(
|
||||||
}
|
popularityExchange,
|
||||||
|
"topic",
|
||||||
return ch.QueueBind(
|
true,
|
||||||
q.Name,
|
false,
|
||||||
popularityBindingKey,
|
false,
|
||||||
popularityExchange,
|
false,
|
||||||
false,
|
nil,
|
||||||
nil,
|
); err != nil {
|
||||||
)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func declareLikeTopology(ch *amqp.Channel) error {
|
q, err := ch.QueueDeclare(
|
||||||
if err := ch.ExchangeDeclare(
|
popularityQueue,
|
||||||
likeExchange,
|
true,
|
||||||
"topic",
|
false,
|
||||||
true,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
|
||||||
false,
|
)
|
||||||
nil,
|
if err != nil {
|
||||||
); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
|
||||||
|
return ch.QueueBind(
|
||||||
q, err := ch.QueueDeclare(
|
q.Name,
|
||||||
likeQueue,
|
popularityBindingKey,
|
||||||
true,
|
popularityExchange,
|
||||||
false,
|
false,
|
||||||
false,
|
nil,
|
||||||
false,
|
)
|
||||||
nil,
|
}
|
||||||
)
|
|
||||||
if err != nil {
|
func declareLikeTopology(ch *amqp.Channel) error {
|
||||||
return err
|
if err := ch.ExchangeDeclare(
|
||||||
}
|
likeExchange,
|
||||||
|
"topic",
|
||||||
return ch.QueueBind(
|
true,
|
||||||
q.Name,
|
false,
|
||||||
likeBindingKey,
|
false,
|
||||||
likeExchange,
|
false,
|
||||||
false,
|
nil,
|
||||||
nil,
|
); err != nil {
|
||||||
)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func declareCommentTopology(ch *amqp.Channel) error {
|
q, err := ch.QueueDeclare(
|
||||||
if err := ch.ExchangeDeclare(
|
likeQueue,
|
||||||
commentExchange,
|
true,
|
||||||
"topic",
|
false,
|
||||||
true,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
|
||||||
false,
|
)
|
||||||
nil,
|
if err != nil {
|
||||||
); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
|
||||||
|
return ch.QueueBind(
|
||||||
q, err := ch.QueueDeclare(
|
q.Name,
|
||||||
commentQueue,
|
likeBindingKey,
|
||||||
true,
|
likeExchange,
|
||||||
false,
|
false,
|
||||||
false,
|
nil,
|
||||||
false,
|
)
|
||||||
nil,
|
}
|
||||||
)
|
|
||||||
if err != nil {
|
func declareCommentTopology(ch *amqp.Channel) error {
|
||||||
return err
|
if err := ch.ExchangeDeclare(
|
||||||
}
|
commentExchange,
|
||||||
|
"topic",
|
||||||
return ch.QueueBind(
|
true,
|
||||||
q.Name,
|
false,
|
||||||
commentBindingKey,
|
false,
|
||||||
commentExchange,
|
false,
|
||||||
false,
|
nil,
|
||||||
nil,
|
); err != nil {
|
||||||
)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
q, err := ch.QueueDeclare(
|
||||||
|
commentQueue,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
amqp.Table{"x-dead-letter-exchange": mqrabbit.DLXExchange},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ch.QueueBind(
|
||||||
|
q.Name,
|
||||||
|
commentBindingKey,
|
||||||
|
commentExchange,
|
||||||
|
false,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,46 +1,79 @@
|
|||||||
package account
|
package account
|
||||||
|
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Username string `gorm:"unique" json:"username"`
|
Username string `gorm:"unique" json:"username"`
|
||||||
Password string `json:"-"`
|
Password string `json:"-"`
|
||||||
Token string `json:"-"`
|
Token string `json:"-"`
|
||||||
}
|
RefreshToken string `json:"-"`
|
||||||
|
AvatarURL string `gorm:"type:varchar(512)" json:"avatar_url,omitempty"`
|
||||||
type CreateAccountRequest struct {
|
Bio string `gorm:"type:varchar(255)" json:"bio,omitempty"`
|
||||||
Username string `json:"username"`
|
}
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
type CreateAccountRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
type RenameRequest struct {
|
Password string `json:"password"`
|
||||||
NewUsername string `json:"new_username"`
|
}
|
||||||
}
|
|
||||||
|
type RenameRequest struct {
|
||||||
type FindByIDRequest struct {
|
NewUsername string `json:"new_username"`
|
||||||
ID uint `json:"id"`
|
}
|
||||||
}
|
|
||||||
|
type FindByIDRequest struct {
|
||||||
type FindByIDResponse struct {
|
ID uint `json:"id"`
|
||||||
ID uint `json:"id"`
|
}
|
||||||
Username string `json:"username"`
|
|
||||||
}
|
type FindByIDResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
type FindByUsernameRequest struct {
|
Username string `json:"username"`
|
||||||
Username string `json:"username"`
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
}
|
Bio string `json:"bio,omitempty"`
|
||||||
|
}
|
||||||
type FindByUsernameResponse struct {
|
|
||||||
ID uint `json:"id"`
|
type FindByUsernameRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangePasswordRequest struct {
|
type FindByUsernameResponse struct {
|
||||||
Username string `json:"username"`
|
ID uint `json:"id"`
|
||||||
OldPassword string `json:"old_password"`
|
Username string `json:"username"`
|
||||||
NewPassword string `json:"new_password"`
|
}
|
||||||
}
|
|
||||||
|
type ChangePasswordRequest struct {
|
||||||
type LoginRequest struct {
|
Username string `json:"username"`
|
||||||
Username string `json:"username"`
|
OldPassword string `json:"old_password"`
|
||||||
Password string `json:"password"`
|
NewPassword string `json:"new_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,142 +1,257 @@
|
|||||||
package account
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"github.com/gin-gonic/gin"
|
"errors"
|
||||||
"gorm.io/gorm"
|
"fmt"
|
||||||
)
|
"net/http"
|
||||||
|
"os"
|
||||||
type AccountHandler struct {
|
"path"
|
||||||
accountService *AccountService
|
"path/filepath"
|
||||||
}
|
"strconv"
|
||||||
|
"strings"
|
||||||
func NewAccountHandler(accountService *AccountService) *AccountHandler {
|
|
||||||
return &AccountHandler{accountService: accountService}
|
"feedsystem_video_go/internal/apierror"
|
||||||
}
|
|
||||||
func (h *AccountHandler) CreateAccount(c *gin.Context) {
|
"github.com/gin-gonic/gin"
|
||||||
var req CreateAccountRequest
|
"gorm.io/gorm"
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
|
||||||
return
|
type AccountHandler struct {
|
||||||
}
|
accountService *AccountService
|
||||||
if err := h.accountService.CreateAccount(c.Request.Context(), &Account{
|
}
|
||||||
Username: req.Username,
|
|
||||||
Password: req.Password,
|
func NewAccountHandler(accountService *AccountService) *AccountHandler {
|
||||||
}); err != nil {
|
return &AccountHandler{accountService: accountService}
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
}
|
||||||
return
|
func (h *AccountHandler) CreateAccount(c *gin.Context) {
|
||||||
}
|
var req CreateAccountRequest
|
||||||
c.JSON(200, gin.H{"message": "account created"})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
}
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
func (h *AccountHandler) Rename(c *gin.Context) {
|
}
|
||||||
var req RenameRequest
|
if err := h.accountService.CreateAccount(c.Request.Context(), &Account{
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
Username: req.Username,
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
Password: req.Password,
|
||||||
return
|
}); err != nil {
|
||||||
}
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
accountID, err := getAccountID(c)
|
return
|
||||||
if err != nil {
|
}
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
c.JSON(200, gin.H{"message": "account created"})
|
||||||
return
|
}
|
||||||
}
|
|
||||||
token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername)
|
func (h *AccountHandler) Rename(c *gin.Context) {
|
||||||
if err != nil {
|
var req RenameRequest
|
||||||
if errors.Is(err, ErrNewUsernameRequired) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if errors.Is(err, ErrUsernameTaken) {
|
accountID, err := getAccountID(c)
|
||||||
c.JSON(409, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
}
|
||||||
c.JSON(404, gin.H{"error": "account not found"})
|
token, err := h.accountService.Rename(c.Request.Context(), accountID, req.NewUsername)
|
||||||
return
|
if err != nil {
|
||||||
}
|
if errors.Is(err, ErrNewUsernameRequired) {
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(200, gin.H{"token": token})
|
if errors.Is(err, ErrUsernameTaken) {
|
||||||
}
|
c.JSON(409, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
func (h *AccountHandler) ChangePassword(c *gin.Context) {
|
}
|
||||||
var req ChangePasswordRequest
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
c.JSON(404, gin.H{"error": "account not found"})
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
return
|
||||||
return
|
}
|
||||||
}
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil {
|
return
|
||||||
c.JSON(400, gin.H{"error": "unsuccessfully password changed"})
|
}
|
||||||
return
|
c.JSON(200, gin.H{"token": token})
|
||||||
}
|
}
|
||||||
c.JSON(200, gin.H{"message": "successfully password changed"})
|
|
||||||
}
|
func (h *AccountHandler) ChangePassword(c *gin.Context) {
|
||||||
|
var req ChangePasswordRequest
|
||||||
func (h *AccountHandler) FindByID(c *gin.Context) {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
var req FindByIDRequest
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
return
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
}
|
||||||
return
|
if err := h.accountService.ChangePassword(c.Request.Context(), req.Username, req.OldPassword, req.NewPassword); err != nil {
|
||||||
}
|
c.JSON(400, gin.H{"error": "unsuccessfully password changed"})
|
||||||
if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil {
|
return
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
}
|
||||||
return
|
c.JSON(200, gin.H{"message": "successfully password changed"})
|
||||||
} else {
|
}
|
||||||
c.JSON(200, account)
|
|
||||||
}
|
func (h *AccountHandler) FindByID(c *gin.Context) {
|
||||||
}
|
var req FindByIDRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
func (h *AccountHandler) FindByUsername(c *gin.Context) {
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
var req FindByUsernameRequest
|
return
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
}
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if account, err := h.accountService.FindByID(c.Request.Context(), req.ID); err != nil {
|
||||||
return
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil {
|
} else {
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
c.JSON(200, account)
|
||||||
return
|
}
|
||||||
} else {
|
}
|
||||||
c.JSON(200, account)
|
|
||||||
}
|
func (h *AccountHandler) FindByUsername(c *gin.Context) {
|
||||||
}
|
var req FindByUsernameRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
func (h *AccountHandler) Login(c *gin.Context) {
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
var req LoginRequest
|
return
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
}
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username); err != nil {
|
||||||
return
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if token, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password); err != nil {
|
} else {
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
c.JSON(200, account)
|
||||||
return
|
}
|
||||||
} else {
|
}
|
||||||
c.JSON(200, gin.H{"token": token})
|
|
||||||
}
|
func (h *AccountHandler) Login(c *gin.Context) {
|
||||||
}
|
var req LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
func (h *AccountHandler) Logout(c *gin.Context) {
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
accountID, err := getAccountID(c)
|
return
|
||||||
if err != nil {
|
}
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
account, err := h.accountService.FindByUsername(c.Request.Context(), req.Username)
|
||||||
return
|
if err != nil {
|
||||||
}
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil {
|
return
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
}
|
||||||
return
|
accessToken, refreshToken, err := h.accountService.Login(c.Request.Context(), req.Username, req.Password)
|
||||||
}
|
if err != nil {
|
||||||
c.JSON(200, gin.H{"message": "account logged out"})
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
func getAccountID(c *gin.Context) (uint, error) {
|
c.JSON(200, LoginResponse{Token: accessToken, RefreshToken: refreshToken, AccountID: account.ID, Username: account.Username})
|
||||||
value, exists := c.Get("accountID")
|
}
|
||||||
if !exists {
|
|
||||||
return 0, errors.New("accountID not found")
|
func (h *AccountHandler) Logout(c *gin.Context) {
|
||||||
}
|
accountID, err := getAccountID(c)
|
||||||
id, ok := value.(uint)
|
if err != nil {
|
||||||
if !ok {
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
return 0, errors.New("accountID has invalid type")
|
return
|
||||||
}
|
}
|
||||||
return id, nil
|
if err := h.accountService.Logout(c.Request.Context(), accountID); err != nil {
|
||||||
}
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return 0, errors.New("accountID not found")
|
||||||
|
}
|
||||||
|
id, ok := value.(uint)
|
||||||
|
if !ok {
|
||||||
|
return 0, errors.New("accountID has invalid type")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,86 +1,106 @@
|
|||||||
package account
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AccountRepository struct {
|
type AccountRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccountRepository(db *gorm.DB) *AccountRepository {
|
func NewAccountRepository(db *gorm.DB) *AccountRepository {
|
||||||
return &AccountRepository{db: db}
|
return &AccountRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ar *AccountRepository) CreateAccount(ctx context.Context, account *Account) error {
|
func (ar *AccountRepository) CreateAccount(ctx context.Context, account *Account) error {
|
||||||
if err := ar.db.WithContext(ctx).Create(account).Error; err != nil {
|
if err := ar.db.WithContext(ctx).Create(account).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ar *AccountRepository) Rename(ctx context.Context, id uint, newUsername string) error {
|
func (ar *AccountRepository) Rename(ctx context.Context, id uint, newUsername string) error {
|
||||||
result := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("username", newUsername)
|
result := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("username", newUsername)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
return gorm.ErrRecordNotFound
|
return gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ar *AccountRepository) RenameWithToken(ctx context.Context, id uint, newUsername string, token string) error {
|
func (ar *AccountRepository) RenameWithToken(ctx context.Context, id uint, newUsername string, token string) error {
|
||||||
return ar.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return ar.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
result := tx.Model(&Account{}).Where("id = ?", id).Update("username", newUsername)
|
result := tx.Model(&Account{}).Where("id = ?", id).Update("username", newUsername)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
if result.RowsAffected == 0 {
|
||||||
return gorm.ErrRecordNotFound
|
return gorm.ErrRecordNotFound
|
||||||
}
|
}
|
||||||
if err := tx.Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil {
|
if err := tx.Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ar *AccountRepository) ChangePassword(ctx context.Context, id uint, newPassword string) error {
|
func (ar *AccountRepository) ChangePassword(ctx context.Context, id uint, newPassword string) error {
|
||||||
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("password", newPassword).Error; err != nil {
|
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("password", newPassword).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ar *AccountRepository) FindByID(ctx context.Context, id uint) (*Account, error) {
|
func (ar *AccountRepository) FindByID(ctx context.Context, id uint) (*Account, error) {
|
||||||
var account Account
|
var account Account
|
||||||
if err := ar.db.WithContext(ctx).First(&account, id).Error; err != nil {
|
if err := ar.db.WithContext(ctx).First(&account, id).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ar *AccountRepository) FindByUsername(ctx context.Context, username string) (*Account, error) {
|
func (ar *AccountRepository) FindByUsername(ctx context.Context, username string) (*Account, error) {
|
||||||
var account Account
|
var account Account
|
||||||
if err := ar.db.WithContext(ctx).Where("username = ?", username).First(&account).Error; err != nil {
|
if err := ar.db.WithContext(ctx).Where("username = ?", username).First(&account).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &account, nil
|
return &account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ar *AccountRepository) Login(ctx context.Context, id uint, token string) error {
|
func (ar *AccountRepository) Login(ctx context.Context, id uint, token, refreshToken string) error {
|
||||||
if err := ar.db.WithContext(ctx).Model(&Account{}).Where("id = ?", id).Update("token", token).Error; err != nil {
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ar *AccountRepository) Logout(ctx context.Context, id uint) error {
|
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 err
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,156 +1,236 @@
|
|||||||
package account
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"feedsystem_video_go/internal/auth"
|
"feedsystem_video_go/internal/auth"
|
||||||
"fmt"
|
"log"
|
||||||
"log"
|
"strconv"
|
||||||
"time"
|
"strings"
|
||||||
|
"time"
|
||||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
|
||||||
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
"github.com/go-sql-driver/mysql"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"github.com/go-sql-driver/mysql"
|
||||||
"gorm.io/gorm"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
type AccountService struct {
|
|
||||||
accountRepository *AccountRepository
|
type AccountService struct {
|
||||||
cache *rediscache.Client
|
accountRepository *AccountRepository
|
||||||
}
|
cache *rediscache.Client
|
||||||
|
}
|
||||||
var (
|
|
||||||
ErrUsernameTaken = errors.New("username already exists")
|
var (
|
||||||
ErrNewUsernameRequired = errors.New("new_username is required")
|
ErrUsernameTaken = errors.New("username already exists")
|
||||||
)
|
ErrNewUsernameRequired = errors.New("new_username is required")
|
||||||
|
)
|
||||||
func NewAccountService(accountRepository *AccountRepository, cache *rediscache.Client) *AccountService {
|
|
||||||
return &AccountService{accountRepository: accountRepository, cache: cache}
|
func NewAccountService(accountRepository *AccountRepository, cache *rediscache.Client) *AccountService {
|
||||||
}
|
return &AccountService{accountRepository: accountRepository, cache: cache}
|
||||||
|
}
|
||||||
func (as *AccountService) CreateAccount(ctx context.Context, account *Account) error {
|
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost)
|
func (as *AccountService) CreateAccount(ctx context.Context, account *Account) error {
|
||||||
if err != nil {
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(account.Password), bcrypt.DefaultCost)
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return err
|
||||||
account.Password = string(passwordHash)
|
}
|
||||||
if err := as.accountRepository.CreateAccount(ctx, account); err != nil {
|
account.Password = string(passwordHash)
|
||||||
return err
|
if err := as.accountRepository.CreateAccount(ctx, account); err != nil {
|
||||||
}
|
return err
|
||||||
return nil
|
}
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
func (as *AccountService) Rename(ctx context.Context, accountID uint, newUsername string) (string, error) {
|
|
||||||
if newUsername == "" {
|
func (as *AccountService) Rename(ctx context.Context, accountID uint, newUsername string) (string, error) {
|
||||||
return "", ErrNewUsernameRequired
|
if newUsername == "" {
|
||||||
}
|
return "", ErrNewUsernameRequired
|
||||||
|
}
|
||||||
token, err := auth.GenerateToken(accountID, newUsername)
|
|
||||||
if err != nil {
|
token, err := auth.GenerateToken(accountID, newUsername)
|
||||||
return "", err
|
if err != nil {
|
||||||
}
|
return "", err
|
||||||
|
}
|
||||||
if err := as.accountRepository.RenameWithToken(ctx, accountID, newUsername, token); err != nil {
|
|
||||||
var mysqlErr *mysql.MySQLError
|
if err := as.accountRepository.RenameWithToken(ctx, accountID, newUsername, token); err != nil {
|
||||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
var mysqlErr *mysql.MySQLError
|
||||||
return "", ErrUsernameTaken
|
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||||
}
|
return "", ErrUsernameTaken
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
}
|
||||||
return "", err
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
}
|
return "", err
|
||||||
return "", err
|
}
|
||||||
}
|
return "", err
|
||||||
if as.cache != nil {
|
}
|
||||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
if as.cache != nil {
|
||||||
defer cancel()
|
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 {
|
|
||||||
log.Printf("failed to set cache: %v", err)
|
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)
|
||||||
}
|
}
|
||||||
return token, nil
|
}
|
||||||
}
|
return token, nil
|
||||||
|
}
|
||||||
func (as *AccountService) ChangePassword(ctx context.Context, username, oldPassword, newPassword string) error {
|
|
||||||
account, err := as.FindByUsername(ctx, username)
|
func (as *AccountService) ChangePassword(ctx context.Context, username, oldPassword, newPassword string) error {
|
||||||
if err != nil {
|
account, err := as.FindByUsername(ctx, username)
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return err
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil {
|
}
|
||||||
return err
|
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(oldPassword)); err != nil {
|
||||||
}
|
return err
|
||||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
}
|
||||||
if err != nil {
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return err
|
||||||
if err := as.accountRepository.ChangePassword(ctx, account.ID, string(passwordHash)); err != nil {
|
}
|
||||||
return err
|
if err := as.accountRepository.ChangePassword(ctx, account.ID, string(passwordHash)); err != nil {
|
||||||
}
|
return err
|
||||||
if err := as.Logout(ctx, account.ID); err != nil {
|
}
|
||||||
return err
|
if err := as.Logout(ctx, account.ID); err != nil {
|
||||||
}
|
return err
|
||||||
return nil
|
}
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
func (as *AccountService) FindByID(ctx context.Context, id uint) (*Account, error) {
|
|
||||||
if account, err := as.accountRepository.FindByID(ctx, id); err != nil {
|
func (as *AccountService) FindByID(ctx context.Context, id uint) (*Account, error) {
|
||||||
return nil, err
|
if account, err := as.accountRepository.FindByID(ctx, id); err != nil {
|
||||||
} else {
|
return nil, err
|
||||||
return account, nil
|
} else {
|
||||||
}
|
return account, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
func (as *AccountService) FindByUsername(ctx context.Context, username string) (*Account, error) {
|
|
||||||
if account, err := as.accountRepository.FindByUsername(ctx, username); err != nil {
|
func (as *AccountService) FindByUsername(ctx context.Context, username string) (*Account, error) {
|
||||||
return nil, err
|
if account, err := as.accountRepository.FindByUsername(ctx, username); err != nil {
|
||||||
} else {
|
return nil, err
|
||||||
return account, nil
|
} else {
|
||||||
}
|
return account, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
func (as *AccountService) Login(ctx context.Context, username, password string) (string, error) {
|
|
||||||
account, err := as.FindByUsername(ctx, username)
|
func (as *AccountService) Login(ctx context.Context, username, password string) (string, string, error) {
|
||||||
if err != nil {
|
account, err := as.FindByUsername(ctx, username)
|
||||||
return "", err
|
if err != nil {
|
||||||
}
|
return "", "", err
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)); err != nil {
|
}
|
||||||
return "", err
|
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)); err != nil {
|
||||||
}
|
return "", "", err
|
||||||
// generate token
|
}
|
||||||
token, err := auth.GenerateToken(account.ID, account.Username)
|
accessToken, err := auth.GenerateToken(account.ID, account.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
if err := as.accountRepository.Login(ctx, account.ID, token); err != nil {
|
refreshToken, err := auth.GenerateRefreshToken(account.ID)
|
||||||
return "", err
|
if err != nil {
|
||||||
}
|
return "", "", err
|
||||||
if as.cache != nil {
|
}
|
||||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
if err := as.accountRepository.Login(ctx, account.ID, accessToken, refreshToken); err != nil {
|
||||||
defer cancel()
|
return "", "", err
|
||||||
|
}
|
||||||
if err := as.cache.SetBytes(cacheCtx, fmt.Sprintf("account:%d", account.ID), []byte(token), 24*time.Hour); err != nil {
|
if as.cache != nil {
|
||||||
log.Printf("failed to set cache: %v", err)
|
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
}
|
defer cancel()
|
||||||
}
|
|
||||||
return token, 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)
|
||||||
|
}
|
||||||
func (as *AccountService) Logout(ctx context.Context, accountID uint) error {
|
if err := as.cache.SetBytes(cacheCtx, as.cache.Key("account:%d:refresh", account.ID), []byte(refreshToken), 7*24*time.Hour); err != nil {
|
||||||
account, err := as.FindByID(ctx, accountID)
|
log.Printf("failed to set refresh cache: %v", err)
|
||||||
if err != nil {
|
}
|
||||||
return err
|
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)
|
||||||
if account.Token == "" {
|
}
|
||||||
return nil
|
}
|
||||||
}
|
return accessToken, refreshToken, nil
|
||||||
if as.cache != nil {
|
}
|
||||||
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
|
||||||
defer cancel()
|
func (as *AccountService) Logout(ctx context.Context, accountID uint) error {
|
||||||
|
account, err := as.FindByID(ctx, accountID)
|
||||||
if err := as.cache.Del(cacheCtx, fmt.Sprintf("account:%d", account.ID)); err != nil {
|
if err != nil {
|
||||||
log.Printf("failed to del cache: %v", err)
|
return err
|
||||||
}
|
}
|
||||||
}
|
if account.Token == "" {
|
||||||
return as.accountRepository.Logout(ctx, account.ID)
|
return nil
|
||||||
}
|
}
|
||||||
|
if as.cache != nil {
|
||||||
|
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,65 +1,82 @@
|
|||||||
// internal/auth/jwt.go
|
// internal/auth/jwt.go
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"crypto/rand"
|
||||||
"os"
|
"encoding/hex"
|
||||||
"time"
|
"errors"
|
||||||
|
"log"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"os"
|
||||||
)
|
"time"
|
||||||
|
|
||||||
func jwtSecret() []byte {
|
"github.com/golang-jwt/jwt/v5"
|
||||||
secret := os.Getenv("JWT_SECRET")
|
)
|
||||||
if secret == "" {
|
|
||||||
secret = "change-me-in-env"
|
func jwtSecret() []byte {
|
||||||
}
|
secret := os.Getenv("JWT_SECRET")
|
||||||
return []byte(secret)
|
if secret == "" {
|
||||||
}
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
type Claims struct {
|
log.Printf("FATAL: cannot generate JWT secret: %v", err)
|
||||||
AccountID uint `json:"account_id"`
|
return []byte("fallback-unsafe-key-change-me")
|
||||||
Username string `json:"username"`
|
}
|
||||||
jwt.RegisteredClaims
|
secret = hex.EncodeToString(b)
|
||||||
}
|
log.Printf("WARNING: JWT_SECRET not set, generated random key. All tokens invalid on restart.")
|
||||||
|
}
|
||||||
func GenerateToken(accountID uint, username string) (string, error) {
|
return []byte(secret)
|
||||||
now := time.Now()
|
}
|
||||||
|
|
||||||
claims := Claims{
|
type Claims struct {
|
||||||
AccountID: accountID,
|
AccountID uint `json:"account_id"`
|
||||||
Username: username,
|
Username string `json:"username"`
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
jwt.RegisteredClaims
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)),
|
}
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
|
||||||
NotBefore: jwt.NewNumericDate(now),
|
func GenerateToken(accountID uint, username string) (string, error) {
|
||||||
},
|
now := time.Now()
|
||||||
}
|
|
||||||
|
claims := Claims{
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
AccountID: accountID,
|
||||||
|
Username: username,
|
||||||
return token.SignedString(jwtSecret())
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
}
|
ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
func ParseToken(tokenString string) (*Claims, error) {
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
token, err := jwt.ParseWithClaims(
|
},
|
||||||
tokenString,
|
}
|
||||||
&Claims{},
|
|
||||||
func(token *jwt.Token) (interface{}, error) {
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
|
||||||
return nil, errors.New("unexpected signing method")
|
return token.SignedString(jwtSecret())
|
||||||
}
|
}
|
||||||
return jwtSecret(), nil
|
|
||||||
},
|
func GenerateRefreshToken(accountID uint) (string, error) {
|
||||||
)
|
b := make([]byte, 32)
|
||||||
if err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
claims, ok := token.Claims.(*Claims)
|
}
|
||||||
if !ok || !token.Valid {
|
|
||||||
return nil, jwt.ErrTokenInvalidClaims
|
func ParseToken(tokenString string) (*Claims, error) {
|
||||||
}
|
token, err := jwt.ParseWithClaims(
|
||||||
|
tokenString,
|
||||||
return claims, nil
|
&Claims{},
|
||||||
}
|
func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if token.Method == nil || token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
return jwtSecret(), nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, jwt.ErrTokenInvalidClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,36 +1,42 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"feedsystem_video_go/internal/account"
|
"feedsystem_video_go/internal/account"
|
||||||
"feedsystem_video_go/internal/config"
|
"feedsystem_video_go/internal/config"
|
||||||
"feedsystem_video_go/internal/social"
|
"feedsystem_video_go/internal/message"
|
||||||
"feedsystem_video_go/internal/video"
|
"feedsystem_video_go/internal/social"
|
||||||
"fmt"
|
"feedsystem_video_go/internal/video"
|
||||||
|
"feedsystem_video_go/internal/worker"
|
||||||
"gorm.io/driver/mysql"
|
"fmt"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
func NewDB(dbcfg config.DatabaseConfig) (*gorm.DB, error) {
|
)
|
||||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
|
||||||
dbcfg.User, dbcfg.Password, dbcfg.Host, dbcfg.Port, dbcfg.DBName)
|
func NewDB(dbcfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
dbcfg.User, dbcfg.Password, dbcfg.Host, dbcfg.Port, dbcfg.DBName)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||||
}
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
return db, nil
|
}
|
||||||
}
|
|
||||||
|
return db, nil
|
||||||
func AutoMigrate(db *gorm.DB) error {
|
}
|
||||||
return db.AutoMigrate(&account.Account{}, &video.Video{}, &video.Like{}, &video.Comment{}, &social.Social{}, &video.OutboxMsg{})
|
|
||||||
}
|
func AutoMigrate(db *gorm.DB) error {
|
||||||
|
return db.AutoMigrate(
|
||||||
func CloseDB(db *gorm.DB) error {
|
&account.Account{}, &video.Video{}, &video.Like{}, &video.Comment{},
|
||||||
sqlDB, err := db.DB()
|
&social.Social{}, &video.OutboxMsg{}, &video.Tag{}, &video.VideoTag{},
|
||||||
if err != nil {
|
&message.Message{}, &worker.Notification{},
|
||||||
return err
|
)
|
||||||
}
|
}
|
||||||
return sqlDB.Close()
|
|
||||||
}
|
func CloseDB(db *gorm.DB) error {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,82 +1,82 @@
|
|||||||
package feed
|
package feed
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type FeedAuthor struct {
|
type FeedAuthor struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeedVideoItem struct {
|
type FeedVideoItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Author FeedAuthor `json:"author"`
|
Author FeedAuthor `json:"author"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
PlayURL string `json:"play_url"`
|
PlayURL string `json:"play_url"`
|
||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
CreateTime int64 `json:"create_time"`
|
CreateTime int64 `json:"create_time"`
|
||||||
LikesCount int64 `json:"likes_count"`
|
LikesCount int64 `json:"likes_count"`
|
||||||
IsLiked bool `json:"is_liked"`
|
IsLiked bool `json:"is_liked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListLatestRequest struct {
|
type ListLatestRequest struct {
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
LatestTime int64 `json:"latest_time"`
|
LatestTime int64 `json:"latest_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListLatestResponse struct {
|
type ListLatestResponse struct {
|
||||||
VideoList []FeedVideoItem `json:"video_list"`
|
VideoList []FeedVideoItem `json:"video_list"`
|
||||||
NextTime int64 `json:"next_time"`
|
NextTime int64 `json:"next_time"`
|
||||||
HasMore bool `json:"has_more"`
|
HasMore bool `json:"has_more"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListLikesCountRequest struct {
|
type ListLikesCountRequest struct {
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
LikesCountBefore *int64 `json:"likes_count_before,omitempty"`
|
LikesCountBefore *int64 `json:"likes_count_before,omitempty"`
|
||||||
IDBefore *uint `json:"id_before,omitempty"`
|
IDBefore *uint `json:"id_before,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LikesCountCursor struct {
|
type LikesCountCursor struct {
|
||||||
LikesCount int64
|
LikesCount int64
|
||||||
ID uint
|
ID uint
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListLikesCountResponse struct {
|
type ListLikesCountResponse struct {
|
||||||
VideoList []FeedVideoItem `json:"video_list"`
|
VideoList []FeedVideoItem `json:"video_list"`
|
||||||
NextLikesCountBefore *int64 `json:"next_likes_count_before,omitempty"`
|
NextLikesCountBefore *int64 `json:"next_likes_count_before,omitempty"`
|
||||||
NextIDBefore *uint `json:"next_id_before,omitempty"`
|
NextIDBefore *uint `json:"next_id_before,omitempty"`
|
||||||
HasMore bool `json:"has_more"`
|
HasMore bool `json:"has_more"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListByFollowingRequest struct {
|
type ListByFollowingRequest struct {
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
LatestTime int64 `json:"latest_time"`
|
LatestTime int64 `json:"latest_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListByFollowingResponse struct {
|
type ListByFollowingResponse struct {
|
||||||
VideoList []FeedVideoItem `json:"video_list"`
|
VideoList []FeedVideoItem `json:"video_list"`
|
||||||
NextTime int64 `json:"next_time"`
|
NextTime int64 `json:"next_time"`
|
||||||
HasMore bool `json:"has_more"`
|
HasMore bool `json:"has_more"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListByPopularityRequest struct {
|
type ListByPopularityRequest struct {
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
AsOf int64 `json:"as_of"` // 服务器返回的分钟时间戳;第一页传0
|
AsOf int64 `json:"as_of"` // 服务器返回的分钟时间戳;第一页传0
|
||||||
Offset int `json:"offset"` // 下一页从这里开始;第一页传0
|
Offset int `json:"offset"` // 下一页从这里开始;第一页传0
|
||||||
LatestIDBefore *uint `json:"latest_id_before,omitempty"`
|
LatestIDBefore *uint `json:"latest_id_before,omitempty"`
|
||||||
|
|
||||||
// DB fallback 用(可选)
|
// DB fallback 用(可选)
|
||||||
LatestPopularity int64 `json:"latest_popularity"`
|
LatestPopularity int64 `json:"latest_popularity"`
|
||||||
LatestBefore time.Time `json:"latest_before"`
|
LatestBefore time.Time `json:"latest_before"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListByPopularityResponse struct {
|
type ListByPopularityResponse struct {
|
||||||
VideoList []FeedVideoItem `json:"video_list"`
|
VideoList []FeedVideoItem `json:"video_list"`
|
||||||
AsOf int64 `json:"as_of"`
|
AsOf int64 `json:"as_of"`
|
||||||
NextOffset int `json:"next_offset"`
|
NextOffset int `json:"next_offset"`
|
||||||
HasMore bool `json:"has_more"`
|
HasMore bool `json:"has_more"`
|
||||||
|
|
||||||
NextLatestPopularity *int64 `json:"next_latest_popularity,omitempty"`
|
NextLatestPopularity *int64 `json:"next_latest_popularity,omitempty"`
|
||||||
NextLatestBefore *time.Time `json:"next_latest_before,omitempty"`
|
NextLatestBefore *time.Time `json:"next_latest_before,omitempty"`
|
||||||
NextLatestIDBefore *uint `json:"next_latest_id_before,omitempty"`
|
NextLatestIDBefore *uint `json:"next_latest_id_before,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,175 +1,201 @@
|
|||||||
package feed
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"feedsystem_video_go/internal/middleware/jwt"
|
"feedsystem_video_go/internal/middleware/jwt"
|
||||||
"time"
|
"feedsystem_video_go/internal/apierror"
|
||||||
|
"time"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
type FeedHandler struct {
|
|
||||||
service *FeedService
|
type FeedHandler struct {
|
||||||
}
|
service *FeedService
|
||||||
|
}
|
||||||
func NewFeedHandler(service *FeedService) *FeedHandler {
|
|
||||||
return &FeedHandler{service: service}
|
func NewFeedHandler(service *FeedService) *FeedHandler {
|
||||||
}
|
return &FeedHandler{service: service}
|
||||||
|
}
|
||||||
func (f *FeedHandler) ListLatest(c *gin.Context) {
|
|
||||||
var req ListLatestRequest
|
func (f *FeedHandler) ListLatest(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req ListLatestRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.Limit <= 0 || req.Limit > 50 {
|
}
|
||||||
req.Limit = 10
|
if req.Limit <= 0 || req.Limit > 50 {
|
||||||
}
|
req.Limit = 10
|
||||||
var latestTime time.Time
|
}
|
||||||
if req.LatestTime > 0 {
|
var latestTime time.Time
|
||||||
latestTime = time.UnixMilli(req.LatestTime)
|
if req.LatestTime > 0 {
|
||||||
}
|
latestTime = time.UnixMilli(req.LatestTime)
|
||||||
viewerAccountID, err := jwt.GetAccountID(c)
|
}
|
||||||
if err != nil {
|
viewerAccountID, err := jwt.GetAccountID(c)
|
||||||
viewerAccountID = 0
|
if err != nil {
|
||||||
}
|
viewerAccountID = 0
|
||||||
feedItems, err := f.service.ListLatest(c.Request.Context(), req.Limit, latestTime, viewerAccountID)
|
}
|
||||||
if err != nil {
|
feedItems, err := f.service.ListLatest(c.Request.Context(), req.Limit, latestTime, viewerAccountID)
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList)
|
}
|
||||||
c.JSON(200, feedItems)
|
feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList)
|
||||||
}
|
c.JSON(200, feedItems)
|
||||||
|
}
|
||||||
func (f *FeedHandler) ListLikesCount(c *gin.Context) {
|
|
||||||
var req ListLikesCountRequest
|
func (f *FeedHandler) ListLikesCount(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req ListLikesCountRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.Limit <= 0 || req.Limit > 50 {
|
}
|
||||||
req.Limit = 10
|
if req.Limit <= 0 || req.Limit > 50 {
|
||||||
}
|
req.Limit = 10
|
||||||
|
}
|
||||||
var cursor *LikesCountCursor
|
|
||||||
if req.LikesCountBefore != nil || req.IDBefore != nil {
|
var cursor *LikesCountCursor
|
||||||
if req.LikesCountBefore == nil || req.IDBefore == nil {
|
if req.LikesCountBefore != nil || req.IDBefore != nil {
|
||||||
c.JSON(400, gin.H{"error": "likes_count_before and id_before must be provided together"})
|
if req.LikesCountBefore == nil || req.IDBefore == nil {
|
||||||
return
|
c.JSON(400, gin.H{"error": "likes_count_before and id_before must be provided together"})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
likesCountBefore := *req.LikesCountBefore
|
|
||||||
idBefore := *req.IDBefore
|
likesCountBefore := *req.LikesCountBefore
|
||||||
|
idBefore := *req.IDBefore
|
||||||
if likesCountBefore < 0 {
|
|
||||||
c.JSON(400, gin.H{"error": "invalid cursor: likes_count_before must be >= 0"})
|
if likesCountBefore < 0 {
|
||||||
return
|
c.JSON(400, gin.H{"error": "invalid cursor: likes_count_before must be >= 0"})
|
||||||
}
|
return
|
||||||
if idBefore == 0 {
|
}
|
||||||
if likesCountBefore != 0 {
|
if idBefore == 0 {
|
||||||
c.JSON(400, gin.H{"error": "invalid cursor: id_before must be > 0"})
|
if likesCountBefore != 0 {
|
||||||
return
|
c.JSON(400, gin.H{"error": "invalid cursor: id_before must be > 0"})
|
||||||
}
|
return
|
||||||
} else {
|
}
|
||||||
cursor = &LikesCountCursor{
|
} else {
|
||||||
LikesCount: likesCountBefore,
|
cursor = &LikesCountCursor{
|
||||||
ID: idBefore,
|
LikesCount: likesCountBefore,
|
||||||
}
|
ID: idBefore,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewerAccountID, err := jwt.GetAccountID(c)
|
}
|
||||||
if err != nil {
|
viewerAccountID, err := jwt.GetAccountID(c)
|
||||||
viewerAccountID = 0
|
if err != nil {
|
||||||
}
|
viewerAccountID = 0
|
||||||
feedItems, err := f.service.ListLikesCount(c.Request.Context(), req.Limit, cursor, viewerAccountID)
|
}
|
||||||
if err != nil {
|
feedItems, err := f.service.ListLikesCount(c.Request.Context(), req.Limit, cursor, viewerAccountID)
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList)
|
}
|
||||||
c.JSON(200, feedItems)
|
feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList)
|
||||||
}
|
c.JSON(200, feedItems)
|
||||||
|
}
|
||||||
func (f *FeedHandler) ListByFollowing(c *gin.Context) {
|
|
||||||
var req ListByFollowingRequest
|
func (f *FeedHandler) ListByFollowing(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req ListByFollowingRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.Limit <= 0 || req.Limit > 50 {
|
}
|
||||||
req.Limit = 10
|
if req.Limit <= 0 || req.Limit > 50 {
|
||||||
}
|
req.Limit = 10
|
||||||
viewerAccountID, err := jwt.GetAccountID(c)
|
}
|
||||||
if err != nil {
|
viewerAccountID, err := jwt.GetAccountID(c)
|
||||||
viewerAccountID = 0
|
if err != nil {
|
||||||
}
|
viewerAccountID = 0
|
||||||
var latestTime time.Time
|
}
|
||||||
if req.LatestTime > 0 {
|
var latestTime time.Time
|
||||||
latestTime = time.Unix(req.LatestTime, 0)
|
if req.LatestTime > 0 {
|
||||||
}
|
latestTime = time.Unix(req.LatestTime, 0)
|
||||||
feedItems, err := f.service.ListByFollowing(c.Request.Context(), req.Limit, latestTime, viewerAccountID)
|
}
|
||||||
if err != nil {
|
feedItems, err := f.service.ListByFollowing(c.Request.Context(), req.Limit, latestTime, viewerAccountID)
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList)
|
}
|
||||||
c.JSON(200, feedItems)
|
feedItems.VideoList = nonNilFeedVideoItems(feedItems.VideoList)
|
||||||
}
|
c.JSON(200, feedItems)
|
||||||
|
}
|
||||||
func (f *FeedHandler) ListByPopularity(c *gin.Context) {
|
|
||||||
var req ListByPopularityRequest
|
func (f *FeedHandler) ListByPopularity(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req ListByPopularityRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.Limit <= 0 || req.Limit > 50 {
|
}
|
||||||
req.Limit = 10
|
if req.Limit <= 0 || req.Limit > 50 {
|
||||||
}
|
req.Limit = 10
|
||||||
viewerAccountID, err := jwt.GetAccountID(c)
|
}
|
||||||
if err != nil {
|
viewerAccountID, err := jwt.GetAccountID(c)
|
||||||
viewerAccountID = 0
|
if err != nil {
|
||||||
}
|
viewerAccountID = 0
|
||||||
|
}
|
||||||
var latestPopularity int64
|
|
||||||
var latestBefore time.Time
|
var latestPopularity int64
|
||||||
var latestIDBefore uint
|
var latestBefore time.Time
|
||||||
|
var latestIDBefore uint
|
||||||
if req.LatestPopularity < 0 {
|
|
||||||
c.JSON(400, gin.H{"error": "latest_popularity must be >= 0"})
|
if req.LatestPopularity < 0 {
|
||||||
return
|
c.JSON(400, gin.H{"error": "latest_popularity must be >= 0"})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
anyCursor := !req.LatestBefore.IsZero() || req.LatestIDBefore != nil
|
|
||||||
if anyCursor {
|
anyCursor := !req.LatestBefore.IsZero() || req.LatestIDBefore != nil
|
||||||
if req.LatestBefore.IsZero() || req.LatestIDBefore == nil || *req.LatestIDBefore == 0 {
|
if anyCursor {
|
||||||
c.JSON(400, gin.H{"error": "latest_before and latest_id_before must be provided together"})
|
if req.LatestBefore.IsZero() || req.LatestIDBefore == nil || *req.LatestIDBefore == 0 {
|
||||||
return
|
c.JSON(400, gin.H{"error": "latest_before and latest_id_before must be provided together"})
|
||||||
}
|
return
|
||||||
latestPopularity = req.LatestPopularity
|
}
|
||||||
latestBefore = req.LatestBefore
|
latestPopularity = req.LatestPopularity
|
||||||
latestIDBefore = *req.LatestIDBefore
|
latestBefore = req.LatestBefore
|
||||||
}
|
latestIDBefore = *req.LatestIDBefore
|
||||||
resp, err := f.service.ListByPopularity(
|
}
|
||||||
c.Request.Context(),
|
resp, err := f.service.ListByPopularity(
|
||||||
req.Limit,
|
c.Request.Context(),
|
||||||
req.AsOf,
|
req.Limit,
|
||||||
req.Offset,
|
req.AsOf,
|
||||||
viewerAccountID,
|
req.Offset,
|
||||||
latestPopularity,
|
viewerAccountID,
|
||||||
latestBefore,
|
latestPopularity,
|
||||||
latestIDBefore,
|
latestBefore,
|
||||||
)
|
latestIDBefore,
|
||||||
if err != nil {
|
)
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
resp.VideoList = nonNilFeedVideoItems(resp.VideoList)
|
}
|
||||||
c.JSON(200, resp)
|
resp.VideoList = nonNilFeedVideoItems(resp.VideoList)
|
||||||
}
|
c.JSON(200, resp)
|
||||||
|
}
|
||||||
func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem {
|
|
||||||
if items == nil {
|
func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem {
|
||||||
return []FeedVideoItem{}
|
if items == nil {
|
||||||
}
|
return []FeedVideoItem{}
|
||||||
return items
|
}
|
||||||
}
|
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)})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,103 +1,115 @@
|
|||||||
package feed
|
package feed
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"feedsystem_video_go/internal/social"
|
"feedsystem_video_go/internal/social"
|
||||||
"feedsystem_video_go/internal/video"
|
"feedsystem_video_go/internal/video"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FeedRepository struct {
|
type FeedRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFeedRepository(db *gorm.DB) *FeedRepository {
|
func NewFeedRepository(db *gorm.DB) *FeedRepository {
|
||||||
return &FeedRepository{db: db}
|
return &FeedRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) {
|
func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) {
|
||||||
var videos []*video.Video
|
var videos []*video.Video
|
||||||
query := repo.db.WithContext(ctx).Model(&video.Video{}).
|
query := repo.db.WithContext(ctx).Model(&video.Video{}).
|
||||||
Order("create_time DESC")
|
Order("create_time DESC")
|
||||||
if !latestBefore.IsZero() {
|
if !latestBefore.IsZero() {
|
||||||
query = query.Where("create_time < ?", latestBefore)
|
query = query.Where("create_time < ?", latestBefore)
|
||||||
}
|
}
|
||||||
if err := query.Limit(limit).Find(&videos).Error; err != nil {
|
if err := query.Limit(limit).Find(&videos).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return videos, nil
|
return videos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) {
|
func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) {
|
||||||
var videos []*video.Video
|
var videos []*video.Video
|
||||||
query := repo.db.WithContext(ctx).Model(&video.Video{}).
|
query := repo.db.WithContext(ctx).Model(&video.Video{}).
|
||||||
Order("likes_count DESC, id DESC")
|
Order("likes_count DESC, id DESC")
|
||||||
|
|
||||||
if cursor != nil {
|
if cursor != nil {
|
||||||
query = query.Where(
|
query = query.Where(
|
||||||
"(likes_count < ?) OR (likes_count = ? AND id < ?)",
|
"(likes_count < ?) OR (likes_count = ? AND id < ?)",
|
||||||
cursor.LikesCount,
|
cursor.LikesCount,
|
||||||
cursor.LikesCount, cursor.ID,
|
cursor.LikesCount, cursor.ID,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := query.Limit(limit).Find(&videos).Error; err != nil {
|
if err := query.Limit(limit).Find(&videos).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return videos, nil
|
return videos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) {
|
func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) {
|
||||||
var videos []*video.Video
|
var videos []*video.Video
|
||||||
query := repo.db.WithContext(ctx).Model(&video.Video{}).
|
query := repo.db.WithContext(ctx).Model(&video.Video{}).
|
||||||
Order("create_time DESC")
|
Order("create_time DESC")
|
||||||
if viewerAccountID > 0 {
|
if viewerAccountID > 0 {
|
||||||
followingSubQuery := repo.db.WithContext(ctx).
|
followingSubQuery := repo.db.WithContext(ctx).
|
||||||
Model(&social.Social{}).
|
Model(&social.Social{}).
|
||||||
Select("vlogger_id").
|
Select("vlogger_id").
|
||||||
Where("follower_id = ?", viewerAccountID)
|
Where("follower_id = ?", viewerAccountID)
|
||||||
query = query.Where("author_id IN (?)", followingSubQuery)
|
query = query.Where("author_id IN (?)", followingSubQuery)
|
||||||
}
|
}
|
||||||
if !latestBefore.IsZero() {
|
if !latestBefore.IsZero() {
|
||||||
query = query.Where("create_time < ?", latestBefore)
|
query = query.Where("create_time < ?", latestBefore)
|
||||||
}
|
}
|
||||||
if err := query.Limit(limit).Find(&videos).Error; err != nil {
|
if err := query.Limit(limit).Find(&videos).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return videos, nil
|
return videos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) {
|
func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) {
|
||||||
var videos []*video.Video
|
var videos []*video.Video
|
||||||
query := repo.db.WithContext(ctx).Model(&video.Video{}).
|
query := repo.db.WithContext(ctx).Model(&video.Video{}).
|
||||||
Order("popularity DESC, create_time DESC, id DESC")
|
Order("popularity DESC, create_time DESC, id DESC")
|
||||||
|
|
||||||
// 只有当游标完整提供时才加过滤(popularity 允许为 0)
|
// 只有当游标完整提供时才加过滤(popularity 允许为 0)
|
||||||
if !timeBefore.IsZero() && idBefore > 0 {
|
if !timeBefore.IsZero() && idBefore > 0 {
|
||||||
query = query.Where(
|
query = query.Where(
|
||||||
"(popularity < ?) OR (popularity = ? AND create_time < ?) OR (popularity = ? AND create_time = ? AND id < ?)",
|
"(popularity < ?) OR (popularity = ? AND create_time < ?) OR (popularity = ? AND create_time = ? AND id < ?)",
|
||||||
popularityBefore,
|
popularityBefore,
|
||||||
popularityBefore, timeBefore,
|
popularityBefore, timeBefore,
|
||||||
popularityBefore, timeBefore, idBefore,
|
popularityBefore, timeBefore, idBefore,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := query.Limit(limit).Find(&videos).Error; err != nil {
|
if err := query.Limit(limit).Find(&videos).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return videos, nil
|
return videos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.Video, error) {
|
func (repo *FeedRepository) GetByIDs(ctx context.Context, ids []uint) ([]*video.Video, error) {
|
||||||
var videos []*video.Video
|
var videos []*video.Video
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return videos, nil
|
return videos, nil
|
||||||
}
|
}
|
||||||
if err := repo.db.WithContext(ctx).Model(&video.Video{}).
|
if err := repo.db.WithContext(ctx).Model(&video.Video{}).
|
||||||
Where("id IN ?", ids).Find(&videos).Error; err != nil {
|
Where("id IN ?", ids).Find(&videos).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return videos, nil
|
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:本地缓存
|
//L1:本地缓存
|
||||||
var missedL1 []uint
|
var missedL1 []uint
|
||||||
for _, id := range videoIDs {
|
for _, id := range videoIDs {
|
||||||
cacheKey := fmt.Sprintf("video:entity:%d", id)
|
cacheKey := f.rediscache.Key("video:entity:%d", id)
|
||||||
if f.localcache != nil {
|
if f.localcache != nil {
|
||||||
if v, found := f.localcache.Get(cacheKey); found {
|
if v, found := f.localcache.Get(cacheKey); found {
|
||||||
if data, ok := v.(video.Video); ok {
|
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 {
|
if len(missedL1) > 0 {
|
||||||
cacheKeys := make([]string, len(missedL1))
|
cacheKeys := make([]string, len(missedL1))
|
||||||
for i, id := range 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)
|
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)
|
wg.Add(1)
|
||||||
go func(videoID uint) {
|
go func(videoID uint) {
|
||||||
defer wg.Done()
|
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) {
|
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
|
||||||
videoList, err := f.repo.GetByIDs(ctx, []uint{videoID})
|
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]
|
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 {
|
if b, err := json.Marshal(safeCopy); err == nil {
|
||||||
//异步回写redis
|
//异步回写redis
|
||||||
go func(k string, b []byte) {
|
go func(k string, b []byte) {
|
||||||
@@ -137,7 +137,7 @@ func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*vi
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
videoMap[id] = &safeCopy
|
videoMap[id] = &safeCopy
|
||||||
mu.Unlock()
|
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)
|
}(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) {
|
func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore time.Time, viewerAccountID uint) (ListLatestResponse, error) {
|
||||||
// 获取 ZSET 中最老的一条数据
|
// 获取 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 {
|
if err != nil {
|
||||||
return ListLatestResponse{}, err
|
return ListLatestResponse{}, err
|
||||||
@@ -158,7 +158,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
|
|||||||
|
|
||||||
if isZsetEmpty {
|
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) {
|
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
|
||||||
// 无视游标,直接去 MySQL 捞最新的 1000 条
|
// 无视游标,直接去 MySQL 捞最新的 1000 条
|
||||||
@@ -180,7 +180,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
|
|||||||
Member: fmt.Sprintf("%d", vid.ID),
|
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
|
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) {
|
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
|
||||||
return f.repo.ListLatest(ctx, limit, latestBefore)
|
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) // 防重复
|
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 {
|
if err != nil {
|
||||||
return ListLatestResponse{}, err
|
return ListLatestResponse{}, err
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
|
|||||||
coldCursor = latestBefore
|
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) {
|
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
|
||||||
return f.repo.ListLatest(ctx, remainLimit, coldCursor)
|
return f.repo.ListLatest(ctx, remainLimit, coldCursor)
|
||||||
})
|
})
|
||||||
@@ -343,7 +343,7 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo
|
|||||||
if !latestBefore.IsZero() {
|
if !latestBefore.IsZero() {
|
||||||
before = latestBefore.Unix()
|
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)
|
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -413,10 +413,10 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
|
|||||||
const win = 60
|
const win = 60
|
||||||
keys := make([]string, 0, win)
|
keys := make([]string, 0, win)
|
||||||
for i := 0; i < win; i++ {
|
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)
|
opCtx, cancel := context.WithTimeout(ctx, 80*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -545,3 +545,11 @@ func buildOrderedResult(orderedIDs []uint, dataMap map[uint]*video.Video) []*vid
|
|||||||
}
|
}
|
||||||
return res
|
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,152 +1,237 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"feedsystem_video_go/internal/account"
|
"context"
|
||||||
"feedsystem_video_go/internal/feed"
|
"feedsystem_video_go/internal/account"
|
||||||
"feedsystem_video_go/internal/middleware/jwt"
|
"feedsystem_video_go/internal/feed"
|
||||||
"feedsystem_video_go/internal/middleware/ratelimit"
|
"feedsystem_video_go/internal/message"
|
||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
"feedsystem_video_go/internal/middleware/jwt"
|
||||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
"feedsystem_video_go/internal/middleware/ratelimit"
|
||||||
"feedsystem_video_go/internal/social"
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
"feedsystem_video_go/internal/video"
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
"feedsystem_video_go/internal/worker"
|
"feedsystem_video_go/internal/social"
|
||||||
"log"
|
"feedsystem_video_go/internal/video"
|
||||||
"time"
|
"feedsystem_video_go/internal/worker"
|
||||||
"github.com/gin-gonic/gin"
|
"log"
|
||||||
"gorm.io/gorm"
|
"time"
|
||||||
)
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *gin.Engine {
|
)
|
||||||
r := gin.Default()
|
|
||||||
if err := r.SetTrustedProxies(nil); err != nil {
|
func SetRouter(db *gorm.DB, cache *rediscache.Client, rmq *rabbitmq.RabbitMQ) *gin.Engine {
|
||||||
log.Printf("SetTrustedProxies failed: %v", err)
|
r := gin.Default()
|
||||||
}
|
if err := r.SetTrustedProxies(nil); err != nil {
|
||||||
r.Static("/static", "./.run/uploads")
|
log.Printf("SetTrustedProxies failed: %v", err)
|
||||||
// rate_limit
|
}
|
||||||
loginLimiter := ratelimit.Limit(cache, "account_login", 10, time.Minute, ratelimit.KeyByIP)
|
r.Static("/static", "./.run/uploads")
|
||||||
registerLimiter := ratelimit.Limit(cache, "account_register", 5, time.Hour, ratelimit.KeyByIP)
|
// rate_limit
|
||||||
|
loginLimiter := ratelimit.Limit(cache, "account_login", 10, time.Minute, ratelimit.KeyByIP)
|
||||||
likeLimiter := ratelimit.Limit(cache, "like_write", 30, time.Minute, ratelimit.KeyByAccount)
|
registerLimiter := ratelimit.Limit(cache, "account_register", 5, time.Hour, ratelimit.KeyByIP)
|
||||||
commentLimiter := ratelimit.Limit(cache, "comment_write", 10, time.Minute, ratelimit.KeyByAccount)
|
|
||||||
socialLimiter := ratelimit.Limit(cache, "social_write", 20, time.Minute, ratelimit.KeyByAccount)
|
likeLimiter := ratelimit.Limit(cache, "like_write", 30, time.Minute, ratelimit.KeyByAccount)
|
||||||
|
commentLimiter := ratelimit.Limit(cache, "comment_write", 10, time.Minute, ratelimit.KeyByAccount)
|
||||||
// account
|
socialLimiter := ratelimit.Limit(cache, "social_write", 20, time.Minute, ratelimit.KeyByAccount)
|
||||||
accountRepository := account.NewAccountRepository(db)
|
|
||||||
accountService := account.NewAccountService(accountRepository, cache)
|
// account
|
||||||
accountHandler := account.NewAccountHandler(accountService)
|
accountRepository := account.NewAccountRepository(db)
|
||||||
accountGroup := r.Group("/account")
|
accountService := account.NewAccountService(accountRepository, cache)
|
||||||
{
|
accountHandler := account.NewAccountHandler(accountService)
|
||||||
accountGroup.POST("/register", registerLimiter, accountHandler.CreateAccount)
|
accountGroup := r.Group("/account")
|
||||||
accountGroup.POST("/login", loginLimiter, accountHandler.Login)
|
{
|
||||||
accountGroup.POST("/changePassword", accountHandler.ChangePassword)
|
accountGroup.POST("/register", registerLimiter, accountHandler.CreateAccount)
|
||||||
accountGroup.POST("/findByID", accountHandler.FindByID)
|
accountGroup.POST("/login", loginLimiter, accountHandler.Login)
|
||||||
accountGroup.POST("/findByUsername", accountHandler.FindByUsername)
|
accountGroup.POST("/changePassword", accountHandler.ChangePassword)
|
||||||
}
|
accountGroup.POST("/findByID", accountHandler.FindByID)
|
||||||
protectedAccountGroup := accountGroup.Group("")
|
accountGroup.POST("/findByUsername", accountHandler.FindByUsername)
|
||||||
protectedAccountGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
accountGroup.POST("/refresh", accountHandler.Refresh)
|
||||||
{
|
}
|
||||||
protectedAccountGroup.POST("/logout", accountHandler.Logout)
|
protectedAccountGroup := accountGroup.Group("")
|
||||||
protectedAccountGroup.POST("/rename", accountHandler.Rename)
|
protectedAccountGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
||||||
}
|
{
|
||||||
// video
|
protectedAccountGroup.POST("/logout", accountHandler.Logout)
|
||||||
videoRepository := video.NewVideoRepository(db)
|
protectedAccountGroup.POST("/rename", accountHandler.Rename)
|
||||||
popularityMQ, err := rabbitmq.NewPopularityMQ(rmq)
|
protectedAccountGroup.POST("/uploadAvatar", accountHandler.UploadAvatar)
|
||||||
if err != nil {
|
protectedAccountGroup.POST("/updateProfile", accountHandler.UpdateProfile)
|
||||||
log.Printf("PopularityMQ init failed (mq disabled): %v", err)
|
}
|
||||||
popularityMQ = nil
|
// video
|
||||||
}
|
videoRepository := video.NewVideoRepository(db)
|
||||||
videoService := video.NewVideoService(videoRepository, cache, popularityMQ)
|
popularityMQ, err := rabbitmq.NewPopularityMQ(rmq)
|
||||||
videoHandler := video.NewVideoHandler(videoService, accountService)
|
if err != nil {
|
||||||
videoGroup := r.Group("/video")
|
log.Printf("PopularityMQ init failed (mq disabled): %v", err)
|
||||||
{
|
popularityMQ = nil
|
||||||
videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID)
|
}
|
||||||
videoGroup.POST("/getDetail", videoHandler.GetDetail)
|
videoService := video.NewVideoService(videoRepository, cache, popularityMQ)
|
||||||
}
|
videoHandler := video.NewVideoHandler(videoService, accountService)
|
||||||
protectedVideoGroup := videoGroup.Group("")
|
videoGroup := r.Group("/video")
|
||||||
protectedVideoGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
{
|
||||||
{
|
videoGroup.POST("/listByAuthorID", videoHandler.ListByAuthorID)
|
||||||
protectedVideoGroup.POST("/uploadVideo", videoHandler.UploadVideo)
|
videoGroup.POST("/getDetail", videoHandler.GetDetail)
|
||||||
protectedVideoGroup.POST("/uploadCover", videoHandler.UploadCover)
|
}
|
||||||
protectedVideoGroup.POST("/publish", videoHandler.PublishVideo)
|
protectedVideoGroup := videoGroup.Group("")
|
||||||
}
|
protectedVideoGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
||||||
// like
|
{
|
||||||
likeMQ, err := rabbitmq.NewLikeMQ(rmq)
|
protectedVideoGroup.POST("/uploadVideo", videoHandler.UploadVideo)
|
||||||
if err != nil {
|
protectedVideoGroup.POST("/uploadCover", videoHandler.UploadCover)
|
||||||
log.Printf("LikeMQ init failed (mq disabled): %v", err)
|
protectedVideoGroup.POST("/publish", videoHandler.PublishVideo)
|
||||||
likeMQ = nil
|
}
|
||||||
}
|
// like
|
||||||
likeRepository := video.NewLikeRepository(db)
|
likeMQ, err := rabbitmq.NewLikeMQ(rmq)
|
||||||
likeService := video.NewLikeService(likeRepository, videoRepository, cache, likeMQ, popularityMQ)
|
if err != nil {
|
||||||
likeHandler := video.NewLikeHandler(likeService)
|
log.Printf("LikeMQ init failed (mq disabled): %v", err)
|
||||||
likeGroup := r.Group("/like")
|
likeMQ = nil
|
||||||
protectedLikeGroup := likeGroup.Group("")
|
}
|
||||||
protectedLikeGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
likeRepository := video.NewLikeRepository(db)
|
||||||
{
|
likeService := video.NewLikeService(likeRepository, videoRepository, cache, likeMQ, popularityMQ)
|
||||||
protectedLikeGroup.POST("/like", likeLimiter, likeHandler.Like)
|
likeHandler := video.NewLikeHandler(likeService)
|
||||||
protectedLikeGroup.POST("/unlike", likeLimiter, likeHandler.Unlike)
|
likeGroup := r.Group("/like")
|
||||||
protectedLikeGroup.POST("/isLiked", likeHandler.IsLiked)
|
protectedLikeGroup := likeGroup.Group("")
|
||||||
protectedLikeGroup.POST("/listMyLikedVideos", likeHandler.ListMyLikedVideos)
|
protectedLikeGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
||||||
}
|
{
|
||||||
// comment
|
protectedLikeGroup.POST("/like", likeLimiter, likeHandler.Like)
|
||||||
commentRepository := video.NewCommentRepository(db)
|
protectedLikeGroup.POST("/unlike", likeLimiter, likeHandler.Unlike)
|
||||||
commentMQ, err := rabbitmq.NewCommentMQ(rmq)
|
protectedLikeGroup.POST("/isLiked", likeHandler.IsLiked)
|
||||||
if err != nil {
|
protectedLikeGroup.POST("/listMyLikedVideos", likeHandler.ListMyLikedVideos)
|
||||||
log.Printf("CommentMQ init failed (mq disabled): %v", err)
|
}
|
||||||
commentMQ = nil
|
// comment
|
||||||
}
|
commentRepository := video.NewCommentRepository(db)
|
||||||
commentService := video.NewCommentService(commentRepository, videoRepository, cache, commentMQ, popularityMQ)
|
commentMQ, err := rabbitmq.NewCommentMQ(rmq)
|
||||||
commentHandler := video.NewCommentHandler(commentService, accountService)
|
if err != nil {
|
||||||
commentGroup := r.Group("/comment")
|
log.Printf("CommentMQ init failed (mq disabled): %v", err)
|
||||||
{
|
commentMQ = nil
|
||||||
commentGroup.POST("/listAll", commentHandler.GetAllComments)
|
}
|
||||||
}
|
commentService := video.NewCommentService(commentRepository, videoRepository, cache, commentMQ, popularityMQ)
|
||||||
protectedCommentGroup := commentGroup.Group("")
|
commentHandler := video.NewCommentHandler(commentService, accountService)
|
||||||
protectedCommentGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
commentGroup := r.Group("/comment")
|
||||||
{
|
{
|
||||||
protectedCommentGroup.POST("/publish", commentLimiter, commentHandler.PublishComment)
|
commentGroup.POST("/listAll", commentHandler.GetAllComments)
|
||||||
protectedCommentGroup.POST("/delete", commentLimiter, commentHandler.DeleteComment)
|
}
|
||||||
}
|
protectedCommentGroup := commentGroup.Group("")
|
||||||
// social
|
protectedCommentGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
||||||
socialMQ, err := rabbitmq.NewSocialMQ(rmq)
|
{
|
||||||
if err != nil {
|
protectedCommentGroup.POST("/publish", commentLimiter, commentHandler.PublishComment)
|
||||||
log.Printf("SocialMQ init failed (mq disabled): %v", err)
|
protectedCommentGroup.POST("/delete", commentLimiter, commentHandler.DeleteComment)
|
||||||
socialMQ = nil
|
}
|
||||||
}
|
// social
|
||||||
socialRepository := social.NewSocialRepository(db)
|
socialMQ, err := rabbitmq.NewSocialMQ(rmq)
|
||||||
socialService := social.NewSocialService(socialRepository, accountRepository, socialMQ)
|
if err != nil {
|
||||||
socialHandler := social.NewSocialHandler(socialService)
|
log.Printf("SocialMQ init failed (mq disabled): %v", err)
|
||||||
socialGroup := r.Group("/social")
|
socialMQ = nil
|
||||||
protectedSocialGroup := socialGroup.Group("")
|
}
|
||||||
protectedSocialGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
socialRepository := social.NewSocialRepository(db)
|
||||||
{
|
socialService := social.NewSocialService(socialRepository, accountRepository, socialMQ)
|
||||||
protectedSocialGroup.POST("/follow", socialLimiter, socialHandler.Follow)
|
socialHandler := social.NewSocialHandler(socialService)
|
||||||
protectedSocialGroup.POST("/unfollow", socialLimiter, socialHandler.Unfollow)
|
socialGroup := r.Group("/social")
|
||||||
protectedSocialGroup.POST("/getAllFollowers", socialHandler.GetAllFollowers)
|
protectedSocialGroup := socialGroup.Group("")
|
||||||
protectedSocialGroup.POST("/getAllVloggers", socialHandler.GetAllVloggers)
|
protectedSocialGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
||||||
}
|
{
|
||||||
// feed
|
protectedSocialGroup.POST("/follow", socialLimiter, socialHandler.Follow)
|
||||||
feedRepository := feed.NewFeedRepository(db)
|
protectedSocialGroup.POST("/unfollow", socialLimiter, socialHandler.Unfollow)
|
||||||
feedService := feed.NewFeedService(feedRepository, likeRepository, cache)
|
protectedSocialGroup.POST("/getAllFollowers", socialHandler.GetAllFollowers)
|
||||||
feedHandler := feed.NewFeedHandler(feedService)
|
protectedSocialGroup.POST("/getAllVloggers", socialHandler.GetAllVloggers)
|
||||||
feedGroup := r.Group("/feed")
|
protectedSocialGroup.POST("/getCounts", socialHandler.GetCounts)
|
||||||
feedGroup.Use(jwt.SoftJWTAuth(accountRepository, cache))
|
}
|
||||||
{
|
|
||||||
feedGroup.POST("/listLatest", feedHandler.ListLatest)
|
accountGroup.POST("/getProfile", func(c *gin.Context) {
|
||||||
feedGroup.POST("/listLikesCount", feedHandler.ListLikesCount)
|
var req account.GetProfileRequest
|
||||||
feedGroup.POST("/listByPopularity", feedHandler.ListByPopularity)
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
}
|
c.JSON(400, gin.H{"error": err.Error()})
|
||||||
protectedFeedGroup := feedGroup.Group("")
|
return
|
||||||
protectedFeedGroup.Use(jwt.JWTAuth(accountRepository, cache))
|
}
|
||||||
{
|
if req.AccountID == 0 {
|
||||||
protectedFeedGroup.POST("/listByFollowing", feedHandler.ListByFollowing)
|
c.JSON(400, gin.H{"error": "account_id is required"})
|
||||||
}
|
return
|
||||||
//worker
|
}
|
||||||
timelineMQ, err := rabbitmq.NewTimelineMQ(rmq)
|
acc, err := accountService.FindByID(c.Request.Context(), req.AccountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("timelineMQ init failed (mq disabled): %v", err)
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
socialMQ = nil
|
return
|
||||||
}
|
}
|
||||||
worker.StartOutboxPoller(db, timelineMQ)
|
videoCount, _ := videoRepository.CountByAuthor(c.Request.Context(), req.AccountID)
|
||||||
worker.StartConsumer(timelineMQ, "video.timeline.update.queue", cache)
|
totalLikes, _ := videoRepository.TotalLikesByAuthor(c.Request.Context(), req.AccountID)
|
||||||
return r
|
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)
|
||||||
|
feedHandler := feed.NewFeedHandler(feedService)
|
||||||
|
feedGroup := r.Group("/feed")
|
||||||
|
feedGroup.Use(jwt.SoftJWTAuth(accountRepository, cache))
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
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})
|
||||||
|
}
|
||||||
@@ -1,140 +1,139 @@
|
|||||||
package jwt
|
package jwt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"log"
|
||||||
"log"
|
"net/http"
|
||||||
"net/http"
|
"strings"
|
||||||
"strings"
|
"time"
|
||||||
"time"
|
|
||||||
|
"feedsystem_video_go/internal/account"
|
||||||
"feedsystem_video_go/internal/account"
|
"feedsystem_video_go/internal/auth"
|
||||||
"feedsystem_video_go/internal/auth"
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gin-gonic/gin"
|
)
|
||||||
)
|
|
||||||
|
// JWTAuth check jwt token and ensure it matches the currently stored token.
|
||||||
// JWTAuth check jwt token and ensure it matches the currently stored token.
|
func JWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc {
|
||||||
func JWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc {
|
return func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
authHeader := c.GetHeader("Authorization")
|
||||||
authHeader := c.GetHeader("Authorization")
|
if authHeader == "" {
|
||||||
if authHeader == "" {
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
parts := strings.SplitN(authHeader, " ", 2)
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
tokenString := parts[1]
|
||||||
tokenString := parts[1]
|
|
||||||
|
claims, err := auth.ParseToken(tokenString)
|
||||||
claims, err := auth.ParseToken(tokenString)
|
if err != nil {
|
||||||
if err != nil {
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
return
|
||||||
return
|
}
|
||||||
}
|
check(c, claims, tokenString, accountRepo, cache)
|
||||||
check(c, claims, tokenString, accountRepo, cache)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
func SoftJWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc {
|
||||||
func SoftJWTAuth(accountRepo *account.AccountRepository, cache *rediscache.Client) gin.HandlerFunc {
|
return func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
authHeader := c.GetHeader("Authorization")
|
||||||
authHeader := c.GetHeader("Authorization")
|
if authHeader == "" {
|
||||||
if authHeader == "" {
|
c.Next()
|
||||||
c.Next()
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
parts := strings.SplitN(authHeader, " ", 2)
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
tokenString := parts[1]
|
||||||
tokenString := parts[1]
|
|
||||||
|
claims, err := auth.ParseToken(tokenString)
|
||||||
claims, err := auth.ParseToken(tokenString)
|
if err != nil {
|
||||||
if err != nil {
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
check(c, claims, tokenString, accountRepo, cache)
|
||||||
check(c, claims, tokenString, accountRepo, cache)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
func check(c *gin.Context, claims *auth.Claims, tokenString string, accountRepo *account.AccountRepository, cache *rediscache.Client) {
|
||||||
func check(c *gin.Context, claims *auth.Claims, tokenString string, accountRepo *account.AccountRepository, cache *rediscache.Client) {
|
key := cache.Key("account:%d", claims.AccountID)
|
||||||
key := fmt.Sprintf("account:%d", claims.AccountID)
|
|
||||||
|
// 先查 Redis
|
||||||
// 先查 Redis
|
if cache != nil {
|
||||||
if cache != nil {
|
cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond)
|
||||||
cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond)
|
defer cancel()
|
||||||
defer cancel()
|
|
||||||
|
b, err := cache.GetBytes(cacheCtx, key)
|
||||||
b, err := cache.GetBytes(cacheCtx, key)
|
if err == nil {
|
||||||
if err == nil {
|
if string(b) != tokenString {
|
||||||
if string(b) != tokenString {
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"})
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"})
|
return
|
||||||
return
|
}
|
||||||
}
|
c.Set("accountID", claims.AccountID)
|
||||||
c.Set("accountID", claims.AccountID)
|
c.Set("username", claims.Username)
|
||||||
c.Set("username", claims.Username)
|
c.Next()
|
||||||
c.Next()
|
return
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Redis 故障/未启用:查 DB 兜底
|
||||||
// Redis 故障/未启用:查 DB 兜底
|
accountInfo, err := accountRepo.FindByID(c.Request.Context(), claims.AccountID)
|
||||||
accountInfo, err := accountRepo.FindByID(c.Request.Context(), claims.AccountID)
|
if err != nil || accountInfo.Token == "" || accountInfo.Token != tokenString {
|
||||||
if err != nil || accountInfo.Token == "" || accountInfo.Token != tokenString {
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"})
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"})
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
if cache != nil {
|
||||||
if cache != nil {
|
cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond)
|
||||||
cacheCtx, cancel := context.WithTimeout(c.Request.Context(), 50*time.Millisecond)
|
defer cancel()
|
||||||
defer cancel()
|
|
||||||
|
if err := cache.SetBytes(cacheCtx, key, []byte(tokenString), 24*time.Hour); err != nil {
|
||||||
if err := cache.SetBytes(cacheCtx, key, []byte(tokenString), 24*time.Hour); err != nil {
|
log.Printf("failed to set cache: %v", err)
|
||||||
log.Printf("failed to set cache: %v", err)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
c.Set("accountID", claims.AccountID)
|
||||||
c.Set("accountID", claims.AccountID)
|
c.Set("username", claims.Username)
|
||||||
c.Set("username", claims.Username)
|
c.Next()
|
||||||
c.Next()
|
|
||||||
|
}
|
||||||
}
|
|
||||||
|
func GetAccountID(c *gin.Context) (uint, error) {
|
||||||
func GetAccountID(c *gin.Context) (uint, error) {
|
uidValue, exists := c.Get("accountID")
|
||||||
uidValue, exists := c.Get("accountID")
|
if !exists {
|
||||||
if !exists {
|
return 0, errors.New("accountID not found")
|
||||||
return 0, errors.New("accountID not found")
|
}
|
||||||
}
|
|
||||||
|
accountID, ok := uidValue.(uint)
|
||||||
accountID, ok := uidValue.(uint)
|
if !ok {
|
||||||
if !ok {
|
return 0, errors.New("accountID has invalid type")
|
||||||
return 0, errors.New("accountID has invalid type")
|
}
|
||||||
}
|
|
||||||
|
return accountID, nil
|
||||||
return accountID, nil
|
}
|
||||||
}
|
|
||||||
|
func GetUsername(c *gin.Context) (string, error) {
|
||||||
func GetUsername(c *gin.Context) (string, error) {
|
val, exists := c.Get("username")
|
||||||
val, exists := c.Get("username")
|
if !exists {
|
||||||
if !exists {
|
return "", errors.New("username not found")
|
||||||
return "", errors.New("username not found")
|
}
|
||||||
}
|
|
||||||
|
username, ok := val.(string)
|
||||||
username, ok := val.(string)
|
if !ok {
|
||||||
if !ok {
|
return "", errors.New("username has invalid type")
|
||||||
return "", errors.New("username has invalid type")
|
}
|
||||||
}
|
|
||||||
|
return username, nil
|
||||||
return username, 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)
|
||||||
|
}
|
||||||
@@ -1,116 +1,123 @@
|
|||||||
package rabbitmq
|
package rabbitmq
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"feedsystem_video_go/internal/config"
|
"feedsystem_video_go/internal/config"
|
||||||
"strconv"
|
"log"
|
||||||
"time"
|
"strconv"
|
||||||
|
"time"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
type RabbitMQ struct {
|
|
||||||
Conn *amqp.Connection
|
type RabbitMQ struct {
|
||||||
Ch *amqp.Channel
|
Conn *amqp.Connection
|
||||||
}
|
Ch *amqp.Channel
|
||||||
|
}
|
||||||
func NewRabbitMQ(cfg *config.RabbitMQConfig) (*RabbitMQ, error) {
|
|
||||||
if cfg == nil {
|
func NewRabbitMQ(cfg *config.RabbitMQConfig) (*RabbitMQ, error) {
|
||||||
return nil, errors.New("rabbitmq config is nil")
|
if cfg == nil {
|
||||||
}
|
return nil, errors.New("rabbitmq config is nil")
|
||||||
url := "amqp://" + cfg.Username + ":" + cfg.Password + "@" + cfg.Host + ":" + strconv.Itoa(cfg.Port) + "/"
|
}
|
||||||
conn, err := amqp.Dial(url)
|
url := "amqp://" + cfg.Username + ":" + cfg.Password + "@" + cfg.Host + ":" + strconv.Itoa(cfg.Port) + "/"
|
||||||
if err != nil {
|
conn, err := amqp.Dial(url)
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
ch, err := conn.Channel()
|
}
|
||||||
if err != nil {
|
ch, err := conn.Channel()
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
return &RabbitMQ{Conn: conn, Ch: ch}, nil
|
}
|
||||||
}
|
return &RabbitMQ{Conn: conn, Ch: ch}, nil
|
||||||
|
}
|
||||||
func (r *RabbitMQ) Close() error {
|
|
||||||
if r == nil || r.Ch == nil || r.Conn == nil {
|
func (r *RabbitMQ) Close() error {
|
||||||
return nil
|
if r == nil || r.Ch == nil || r.Conn == nil {
|
||||||
}
|
return nil
|
||||||
if err := r.Ch.Close(); err != nil {
|
}
|
||||||
return err
|
if err := r.Ch.Close(); err != nil {
|
||||||
}
|
return err
|
||||||
if err := r.Conn.Close(); err != nil {
|
}
|
||||||
return err
|
if err := r.Conn.Close(); err != nil {
|
||||||
}
|
return err
|
||||||
return nil
|
}
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string) error {
|
|
||||||
if r == nil || r.Ch == nil {
|
func (r *RabbitMQ) DeclareTopic(exchange string, queue string, bindingKey string) error {
|
||||||
return errors.New("rabbitmq is not initialized")
|
if r == nil || r.Ch == nil {
|
||||||
}
|
return errors.New("rabbitmq is not initialized")
|
||||||
if exchange == "" || queue == "" || bindingKey == "" {
|
}
|
||||||
return errors.New("exchange/queue/bindingKey is required")
|
if exchange == "" || queue == "" || bindingKey == "" {
|
||||||
}
|
return errors.New("exchange/queue/bindingKey is required")
|
||||||
|
}
|
||||||
if err := r.Ch.ExchangeDeclare(
|
|
||||||
exchange,
|
if err := r.Ch.ExchangeDeclare(
|
||||||
"topic",
|
exchange,
|
||||||
true,
|
"topic",
|
||||||
false,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
nil,
|
false,
|
||||||
); err != nil {
|
nil,
|
||||||
return err
|
); err != nil {
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
q, err := r.Ch.QueueDeclare(
|
|
||||||
queue,
|
q, err := r.Ch.QueueDeclare(
|
||||||
true,
|
queue,
|
||||||
false,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
nil,
|
false,
|
||||||
)
|
amqp.Table{"x-dead-letter-exchange": DLXExchange},
|
||||||
if err != nil {
|
)
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
return r.Ch.QueueBind(
|
|
||||||
q.Name,
|
if err := r.Ch.QueueBind(
|
||||||
bindingKey,
|
q.Name,
|
||||||
exchange,
|
bindingKey,
|
||||||
false,
|
exchange,
|
||||||
nil,
|
false,
|
||||||
)
|
nil,
|
||||||
}
|
); err != nil {
|
||||||
|
return err
|
||||||
func (r *RabbitMQ) PublishJSON(ctx context.Context, exchange string, routingKey string, payload any) error {
|
}
|
||||||
if r == nil || r.Ch == nil {
|
if err := DeclareDLX(r.Ch, queue); err != nil {
|
||||||
return errors.New("rabbitmq is not initialized")
|
log.Printf("DLX declare failed for %s: %v", queue, err)
|
||||||
}
|
}
|
||||||
if exchange == "" || routingKey == "" {
|
return nil
|
||||||
return errors.New("exchange and routingKey are required")
|
}
|
||||||
}
|
|
||||||
b, err := json.Marshal(payload)
|
func (r *RabbitMQ) PublishJSON(ctx context.Context, exchange string, routingKey string, payload any) error {
|
||||||
if err != nil {
|
if r == nil || r.Ch == nil {
|
||||||
return err
|
return errors.New("rabbitmq is not initialized")
|
||||||
}
|
}
|
||||||
return r.Ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{
|
if exchange == "" || routingKey == "" {
|
||||||
ContentType: "application/json",
|
return errors.New("exchange and routingKey are required")
|
||||||
DeliveryMode: amqp.Persistent,
|
}
|
||||||
Timestamp: time.Now(),
|
b, err := json.Marshal(payload)
|
||||||
Body: b,
|
if err != nil {
|
||||||
})
|
return err
|
||||||
}
|
}
|
||||||
|
return r.Ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{
|
||||||
func newEventID(n int) (string, error) {
|
ContentType: "application/json",
|
||||||
b := make([]byte, n)
|
DeliveryMode: amqp.Persistent,
|
||||||
if _, err := rand.Read(b); err != nil {
|
Timestamp: time.Now(),
|
||||||
return "", err
|
Body: b,
|
||||||
}
|
})
|
||||||
return hex.EncodeToString(b), nil
|
}
|
||||||
}
|
|
||||||
|
func newEventID(n int) (string, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,99 +1,111 @@
|
|||||||
package redis
|
package redis
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"feedsystem_video_go/internal/config"
|
"feedsystem_video_go/internal/config"
|
||||||
"strconv"
|
"fmt"
|
||||||
"time"
|
"strconv"
|
||||||
|
"time"
|
||||||
redis "github.com/redis/go-redis/v9"
|
|
||||||
)
|
redis "github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
type Client struct {
|
|
||||||
rdb *redis.Client
|
type Client struct {
|
||||||
}
|
rdb *redis.Client
|
||||||
|
keyPrefix string
|
||||||
func NewFromEnv(cfg *config.RedisConfig) (*Client, error) {
|
}
|
||||||
rdb := redis.NewClient(&redis.Options{
|
|
||||||
Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port),
|
const defaultKeyPrefix = "v1:"
|
||||||
Password: cfg.Password,
|
|
||||||
DB: cfg.DB,
|
func NewFromEnv(cfg *config.RedisConfig) (*Client, error) {
|
||||||
})
|
rdb := redis.NewClient(&redis.Options{
|
||||||
return &Client{rdb: rdb}, nil
|
Addr: cfg.Host + ":" + strconv.Itoa(cfg.Port),
|
||||||
}
|
Password: cfg.Password,
|
||||||
|
DB: cfg.DB,
|
||||||
func (c *Client) Close() error {
|
})
|
||||||
if c == nil || c.rdb == nil {
|
return &Client{rdb: rdb, keyPrefix: defaultKeyPrefix}, nil
|
||||||
return nil
|
}
|
||||||
}
|
|
||||||
return c.rdb.Close()
|
func (c *Client) Close() error {
|
||||||
}
|
if c == nil || c.rdb == nil {
|
||||||
|
return nil
|
||||||
func (c *Client) Ping(ctx context.Context) error {
|
}
|
||||||
if c == nil || c.rdb == nil {
|
return c.rdb.Close()
|
||||||
return nil
|
}
|
||||||
}
|
|
||||||
return c.rdb.Ping(ctx).Err()
|
func (c *Client) Ping(ctx context.Context) error {
|
||||||
}
|
if c == nil || c.rdb == nil {
|
||||||
|
return nil
|
||||||
func IsMiss(err error) bool {
|
}
|
||||||
return err == redis.Nil
|
return c.rdb.Ping(ctx).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func randToken(n int) (string, error) {
|
func IsMiss(err error) bool {
|
||||||
b := make([]byte, n)
|
return err == redis.Nil
|
||||||
if _, err := rand.Read(b); err != nil {
|
}
|
||||||
return "", err
|
|
||||||
}
|
func (c *Client) Key(format string, args ...any) string {
|
||||||
return hex.EncodeToString(b), nil
|
prefix := ""
|
||||||
}
|
if c != nil {
|
||||||
|
prefix = c.keyPrefix
|
||||||
func (c *Client) Lock(ctx context.Context, key string, ttl time.Duration) (token string, ok bool, err error) {
|
}
|
||||||
if c == nil || c.rdb == nil {
|
return prefix + fmt.Sprintf(format, args...)
|
||||||
return "", false, nil
|
}
|
||||||
}
|
|
||||||
token, err = randToken(16)
|
func randToken(n int) (string, error) {
|
||||||
if err != nil {
|
b := make([]byte, n)
|
||||||
return "", false, err
|
if _, err := rand.Read(b); err != nil {
|
||||||
}
|
return "", err
|
||||||
ok, err = c.rdb.SetNX(ctx, key, token, ttl).Result()
|
}
|
||||||
return token, ok, err
|
return hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var unlockScript = redis.NewScript(`
|
func (c *Client) Lock(ctx context.Context, key string, ttl time.Duration) (token string, ok bool, err error) {
|
||||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
if c == nil || c.rdb == nil {
|
||||||
return redis.call("DEL", KEYS[1])
|
return "", false, nil
|
||||||
else
|
}
|
||||||
return 0
|
token, err = randToken(16)
|
||||||
end
|
if err != nil {
|
||||||
`)
|
return "", false, err
|
||||||
|
}
|
||||||
var incrementWithExpireScript = redis.NewScript(`
|
ok, err = c.rdb.SetNX(ctx, key, token, ttl).Result()
|
||||||
local count = redis.call("INCR", KEYS[1])
|
return token, ok, err
|
||||||
if count == 1 then
|
}
|
||||||
redis.call("PEXPIRE", KEYS[1], ARGV[1])
|
|
||||||
end
|
var unlockScript = redis.NewScript(`
|
||||||
return count
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||||
`)
|
return redis.call("DEL", KEYS[1])
|
||||||
|
else
|
||||||
func (c *Client) Unlock(ctx context.Context, key string, token string) error {
|
return 0
|
||||||
if c == nil || c.rdb == nil {
|
end
|
||||||
return nil
|
`)
|
||||||
}
|
|
||||||
_, err := unlockScript.Run(ctx, c.rdb, []string{key}, token).Result()
|
var incrementWithExpireScript = redis.NewScript(`
|
||||||
return err
|
local count = redis.call("INCR", KEYS[1])
|
||||||
}
|
if count == 1 then
|
||||||
|
redis.call("PEXPIRE", KEYS[1], ARGV[1])
|
||||||
func (c *Client) IncrementWithExpire(ctx context.Context, key string, expire time.Duration) (int64, error) {
|
end
|
||||||
if c == nil || c.rdb == nil {
|
return count
|
||||||
return 0, nil
|
`)
|
||||||
}
|
|
||||||
return incrementWithExpireScript.Run(
|
func (c *Client) Unlock(ctx context.Context, key string, token string) error {
|
||||||
ctx,
|
if c == nil || c.rdb == nil {
|
||||||
c.rdb,
|
return nil
|
||||||
[]string{key},
|
}
|
||||||
expire.Milliseconds(),
|
_, err := unlockScript.Run(ctx, c.rdb, []string{key}, token).Result()
|
||||||
).Int64()
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) IncrementWithExpire(ctx context.Context, key string, expire time.Duration) (int64, error) {
|
||||||
|
if c == nil || c.rdb == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return incrementWithExpireScript.Run(
|
||||||
|
ctx,
|
||||||
|
c.rdb,
|
||||||
|
[]string{key},
|
||||||
|
expire.Milliseconds(),
|
||||||
|
).Int64()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,40 @@
|
|||||||
package social
|
package social
|
||||||
|
|
||||||
import "feedsystem_video_go/internal/account"
|
import "feedsystem_video_go/internal/account"
|
||||||
|
|
||||||
type Social struct {
|
type Social struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
FollowerID uint `gorm:"not null;index:idx_social_follower;uniqueIndex:idx_social_follower_vlogger"`
|
FollowerID uint `gorm:"not null;index:idx_social_follower;uniqueIndex:idx_social_follower_vlogger"`
|
||||||
VloggerID uint `gorm:"not null;index:idx_social_vlogger;uniqueIndex:idx_social_follower_vlogger"`
|
VloggerID uint `gorm:"not null;index:idx_social_vlogger;uniqueIndex:idx_social_follower_vlogger"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FollowRequest struct {
|
type FollowRequest struct {
|
||||||
VloggerID uint `json:"vlogger_id"`
|
VloggerID uint `json:"vlogger_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnfollowRequest struct {
|
type UnfollowRequest struct {
|
||||||
VloggerID uint `json:"vlogger_id"`
|
VloggerID uint `json:"vlogger_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetAllFollowersRequest struct {
|
type GetAllFollowersRequest struct {
|
||||||
VloggerID uint `json:"vlogger_id"`
|
VloggerID uint `json:"vlogger_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetAllFollowersResponse struct {
|
type GetAllFollowersResponse struct {
|
||||||
Followers []*account.Account `json:"followers"`
|
Followers []*account.Account `json:"followers"`
|
||||||
}
|
FollowerCount int64 `json:"follower_count"`
|
||||||
|
}
|
||||||
type GetAllVloggersRequest struct {
|
|
||||||
FollowerID uint `json:"follower_id"`
|
type GetAllVloggersResponse struct {
|
||||||
}
|
Vloggers []*account.Account `json:"vloggers"`
|
||||||
|
VloggerCount int64 `json:"vlogger_count"`
|
||||||
type GetAllVloggersResponse struct {
|
}
|
||||||
Vloggers []*account.Account `json:"vloggers"`
|
|
||||||
|
type SocialCounts struct {
|
||||||
|
FollowerCount int64 `json:"follower_count"`
|
||||||
|
VloggerCount int64 `json:"vlogger_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetAllVloggersRequest struct {
|
||||||
|
FollowerID uint `json:"follower_id"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,125 +1,139 @@
|
|||||||
package social
|
package social
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"feedsystem_video_go/internal/account"
|
"feedsystem_video_go/internal/account"
|
||||||
"feedsystem_video_go/internal/middleware/jwt"
|
"feedsystem_video_go/internal/apierror"
|
||||||
"net/http"
|
"feedsystem_video_go/internal/middleware/jwt"
|
||||||
|
"net/http"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
type SocialHandler struct {
|
|
||||||
service *SocialService
|
type SocialHandler struct {
|
||||||
}
|
service *SocialService
|
||||||
|
}
|
||||||
func NewSocialHandler(service *SocialService) *SocialHandler {
|
|
||||||
return &SocialHandler{service: service}
|
func NewSocialHandler(service *SocialService) *SocialHandler {
|
||||||
}
|
return &SocialHandler{service: service}
|
||||||
|
}
|
||||||
func (h *SocialHandler) Follow(c *gin.Context) {
|
|
||||||
var req FollowRequest
|
func (h *SocialHandler) Follow(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req FollowRequest
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.VloggerID <= 0 {
|
}
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"})
|
if req.VloggerID <= 0 {
|
||||||
return
|
c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"})
|
||||||
}
|
return
|
||||||
FollowerID, err := jwt.GetAccountID(c)
|
}
|
||||||
if err != nil {
|
FollowerID, err := jwt.GetAccountID(c)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
social := &Social{
|
}
|
||||||
FollowerID: FollowerID,
|
social := &Social{
|
||||||
VloggerID: req.VloggerID,
|
FollowerID: FollowerID,
|
||||||
}
|
VloggerID: req.VloggerID,
|
||||||
if err := h.service.Follow(c.Request.Context(), social); err != nil {
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
if err := h.service.Follow(c.Request.Context(), social); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "followed"})
|
}
|
||||||
}
|
c.JSON(http.StatusOK, gin.H{"message": "followed"})
|
||||||
|
}
|
||||||
func (h *SocialHandler) Unfollow(c *gin.Context) {
|
|
||||||
var req UnfollowRequest
|
func (h *SocialHandler) Unfollow(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req UnfollowRequest
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.VloggerID <= 0 {
|
}
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"})
|
if req.VloggerID <= 0 {
|
||||||
return
|
c.JSON(http.StatusBadRequest, gin.H{"error": "vlogger_id is required"})
|
||||||
}
|
return
|
||||||
FollowerID, err := jwt.GetAccountID(c)
|
}
|
||||||
if err != nil {
|
FollowerID, err := jwt.GetAccountID(c)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
social := &Social{
|
}
|
||||||
FollowerID: FollowerID,
|
social := &Social{
|
||||||
VloggerID: req.VloggerID,
|
FollowerID: FollowerID,
|
||||||
}
|
VloggerID: req.VloggerID,
|
||||||
if err := h.service.Unfollow(c.Request.Context(), social); err != nil {
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
if err := h.service.Unfollow(c.Request.Context(), social); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "unfollowed"})
|
}
|
||||||
}
|
c.JSON(http.StatusOK, gin.H{"message": "unfollowed"})
|
||||||
|
}
|
||||||
func (h *SocialHandler) GetAllFollowers(c *gin.Context) {
|
|
||||||
var req GetAllFollowersRequest
|
func (h *SocialHandler) GetAllFollowers(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req GetAllFollowersRequest
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
vloggerID := req.VloggerID
|
|
||||||
if vloggerID == 0 {
|
vloggerID := req.VloggerID
|
||||||
accountID, err := jwt.GetAccountID(c)
|
if vloggerID == 0 {
|
||||||
if err != nil {
|
accountID, err := jwt.GetAccountID(c)
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
vloggerID = accountID
|
}
|
||||||
}
|
vloggerID = accountID
|
||||||
|
}
|
||||||
followers, err := h.service.GetAllFollowers(c.Request.Context(), vloggerID)
|
|
||||||
if err != nil {
|
followers, err := h.service.GetAllFollowers(c.Request.Context(), vloggerID)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if followers == nil {
|
}
|
||||||
followers = []*account.Account{}
|
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 {
|
func (h *SocialHandler) GetAllVloggers(c *gin.Context) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
var req GetAllVloggersRequest
|
||||||
return
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
}
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
followerID := req.FollowerID
|
}
|
||||||
if followerID == 0 {
|
|
||||||
accountID, err := jwt.GetAccountID(c)
|
followerID := req.FollowerID
|
||||||
if err != nil {
|
if followerID == 0 {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
accountID, err := jwt.GetAccountID(c)
|
||||||
return
|
if err != nil {
|
||||||
}
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
followerID = accountID
|
return
|
||||||
}
|
}
|
||||||
|
followerID = accountID
|
||||||
vloggers, err := h.service.GetAllVloggers(c.Request.Context(), followerID)
|
}
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
vloggers, err := h.service.GetAllVloggers(c.Request.Context(), followerID)
|
||||||
return
|
if err != nil {
|
||||||
}
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
if vloggers == nil {
|
return
|
||||||
vloggers = []*account.Account{}
|
}
|
||||||
}
|
if vloggers == nil {
|
||||||
c.JSON(http.StatusOK, GetAllVloggersResponse{Vloggers: vloggers})
|
vloggers = []*account.Account{}
|
||||||
}
|
}
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,91 +1,109 @@
|
|||||||
package social
|
package social
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"feedsystem_video_go/internal/account"
|
"feedsystem_video_go/internal/account"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SocialRepository struct {
|
type SocialRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSocialRepository(db *gorm.DB) *SocialRepository {
|
func NewSocialRepository(db *gorm.DB) *SocialRepository {
|
||||||
return &SocialRepository{db: db}
|
return &SocialRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *SocialRepository) Follow(ctx context.Context, social *Social) error {
|
func (r *SocialRepository) Follow(ctx context.Context, social *Social) error {
|
||||||
return r.db.WithContext(ctx).Create(social).Error
|
return r.db.WithContext(ctx).Create(social).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *SocialRepository) Unfollow(ctx context.Context, social *Social) error {
|
func (r *SocialRepository) Unfollow(ctx context.Context, social *Social) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID).
|
Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID).
|
||||||
Delete(&Social{}).Error
|
Delete(&Social{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *SocialRepository) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) {
|
func (r *SocialRepository) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) {
|
||||||
var relations []Social
|
var relations []Social
|
||||||
if err := r.db.WithContext(ctx).
|
if err := r.db.WithContext(ctx).
|
||||||
Model(&Social{}).
|
Model(&Social{}).
|
||||||
Where("vlogger_id = ?", VloggerID).
|
Where("vlogger_id = ?", VloggerID).
|
||||||
Find(&relations).Error; err != nil {
|
Limit(200).
|
||||||
return nil, err
|
Find(&relations).Error; err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
followerIDs := make([]uint, 0, len(relations))
|
|
||||||
for _, rel := range relations {
|
followerIDs := make([]uint, 0, len(relations))
|
||||||
followerIDs = append(followerIDs, rel.FollowerID)
|
for _, rel := range relations {
|
||||||
}
|
followerIDs = append(followerIDs, rel.FollowerID)
|
||||||
if len(followerIDs) == 0 {
|
}
|
||||||
return []*account.Account{}, nil
|
if len(followerIDs) == 0 {
|
||||||
}
|
return []*account.Account{}, nil
|
||||||
|
}
|
||||||
var followers []*account.Account
|
|
||||||
if err := r.db.WithContext(ctx).
|
var followers []*account.Account
|
||||||
Model(&account.Account{}).
|
if err := r.db.WithContext(ctx).
|
||||||
Where("id IN ?", followerIDs).
|
Model(&account.Account{}).
|
||||||
Find(&followers).Error; err != nil {
|
Where("id IN ?", followerIDs).
|
||||||
return nil, err
|
Find(&followers).Error; err != nil {
|
||||||
}
|
return nil, err
|
||||||
return followers, nil
|
}
|
||||||
}
|
return followers, nil
|
||||||
|
}
|
||||||
func (r *SocialRepository) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) {
|
|
||||||
var relations []Social
|
func (r *SocialRepository) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) {
|
||||||
if err := r.db.WithContext(ctx).
|
var relations []Social
|
||||||
Model(&Social{}).
|
if err := r.db.WithContext(ctx).
|
||||||
Where("follower_id = ?", FollowerID).
|
Model(&Social{}).
|
||||||
Find(&relations).Error; err != nil {
|
Where("follower_id = ?", FollowerID).
|
||||||
return nil, err
|
Limit(200).
|
||||||
}
|
Find(&relations).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
vloggerIDs := make([]uint, 0, len(relations))
|
}
|
||||||
for _, rel := range relations {
|
|
||||||
vloggerIDs = append(vloggerIDs, rel.VloggerID)
|
vloggerIDs := make([]uint, 0, len(relations))
|
||||||
}
|
for _, rel := range relations {
|
||||||
if len(vloggerIDs) == 0 {
|
vloggerIDs = append(vloggerIDs, rel.VloggerID)
|
||||||
return []*account.Account{}, nil
|
}
|
||||||
}
|
if len(vloggerIDs) == 0 {
|
||||||
|
return []*account.Account{}, nil
|
||||||
var vloggers []*account.Account
|
}
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Model(&account.Account{}).
|
var vloggers []*account.Account
|
||||||
Where("id IN ?", vloggerIDs).
|
if err := r.db.WithContext(ctx).
|
||||||
Find(&vloggers).Error; err != nil {
|
Model(&account.Account{}).
|
||||||
return nil, err
|
Where("id IN ?", vloggerIDs).
|
||||||
}
|
Find(&vloggers).Error; err != nil {
|
||||||
return vloggers, nil
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return vloggers, nil
|
||||||
func (r *SocialRepository) IsFollowed(ctx context.Context, social *Social) (bool, error) {
|
}
|
||||||
var count int64
|
|
||||||
if err := r.db.WithContext(ctx).
|
func (r *SocialRepository) IsFollowed(ctx context.Context, social *Social) (bool, error) {
|
||||||
Model(&Social{}).
|
var count int64
|
||||||
Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID).
|
if err := r.db.WithContext(ctx).
|
||||||
Count(&count).Error; err != nil {
|
Model(&Social{}).
|
||||||
return false, err
|
Where("follower_id = ? AND vlogger_id = ?", social.FollowerID, social.VloggerID).
|
||||||
}
|
Count(&count).Error; err != nil {
|
||||||
return count > 0, nil
|
return false, err
|
||||||
}
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,93 +1,101 @@
|
|||||||
package social
|
package social
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"feedsystem_video_go/internal/account"
|
"feedsystem_video_go/internal/account"
|
||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SocialService struct {
|
type SocialService struct {
|
||||||
repo *SocialRepository
|
repo *SocialRepository
|
||||||
accountrepo *account.AccountRepository
|
accountrepo *account.AccountRepository
|
||||||
socialMQ *rabbitmq.SocialMQ
|
socialMQ *rabbitmq.SocialMQ
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSocialService(repo *SocialRepository, accountrepo *account.AccountRepository, socialMQ *rabbitmq.SocialMQ) *SocialService {
|
func NewSocialService(repo *SocialRepository, accountrepo *account.AccountRepository, socialMQ *rabbitmq.SocialMQ) *SocialService {
|
||||||
return &SocialService{repo: repo, accountrepo: accountrepo, socialMQ: socialMQ}
|
return &SocialService{repo: repo, accountrepo: accountrepo, socialMQ: socialMQ}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialService) Follow(ctx context.Context, social *Social) error {
|
func (s *SocialService) Follow(ctx context.Context, social *Social) error {
|
||||||
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
|
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
|
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if social.FollowerID == social.VloggerID {
|
if social.FollowerID == social.VloggerID {
|
||||||
return errors.New("can not follow self")
|
return errors.New("can not follow self")
|
||||||
}
|
}
|
||||||
isFollowed, err := s.repo.IsFollowed(ctx, social)
|
isFollowed, err := s.repo.IsFollowed(ctx, social)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isFollowed {
|
if isFollowed {
|
||||||
return errors.New("already followed")
|
return errors.New("already followed")
|
||||||
}
|
}
|
||||||
if s.socialMQ != nil {
|
if s.socialMQ != nil {
|
||||||
s.socialMQ.Follow(ctx, social.FollowerID, social.VloggerID)
|
s.socialMQ.Follow(ctx, social.FollowerID, social.VloggerID)
|
||||||
}
|
}
|
||||||
return s.repo.Follow(ctx, social)
|
return s.repo.Follow(ctx, social)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialService) Unfollow(ctx context.Context, social *Social) error {
|
func (s *SocialService) Unfollow(ctx context.Context, social *Social) error {
|
||||||
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
|
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
|
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
isFollowed, err := s.repo.IsFollowed(ctx, social)
|
isFollowed, err := s.repo.IsFollowed(ctx, social)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !isFollowed {
|
if !isFollowed {
|
||||||
return errors.New("not followed")
|
return errors.New("not followed")
|
||||||
}
|
}
|
||||||
if s.socialMQ != nil {
|
if s.socialMQ != nil {
|
||||||
s.socialMQ.UnFollow(ctx, social.FollowerID, social.VloggerID)
|
s.socialMQ.UnFollow(ctx, social.FollowerID, social.VloggerID)
|
||||||
}
|
}
|
||||||
return s.repo.Unfollow(ctx, social)
|
return s.repo.Unfollow(ctx, social)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialService) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) {
|
func (s *SocialService) GetAllFollowers(ctx context.Context, VloggerID uint) ([]*account.Account, error) {
|
||||||
_, err := s.accountrepo.FindByID(ctx, VloggerID)
|
_, err := s.accountrepo.FindByID(ctx, VloggerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.repo.GetAllFollowers(ctx, VloggerID)
|
return s.repo.GetAllFollowers(ctx, VloggerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialService) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) {
|
func (s *SocialService) GetAllVloggers(ctx context.Context, FollowerID uint) ([]*account.Account, error) {
|
||||||
_, err := s.accountrepo.FindByID(ctx, FollowerID)
|
_, err := s.accountrepo.FindByID(ctx, FollowerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.repo.GetAllVloggers(ctx, FollowerID)
|
return s.repo.GetAllVloggers(ctx, FollowerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SocialService) IsFollowed(ctx context.Context, social *Social) (bool, error) {
|
func (s *SocialService) CountFollowers(ctx context.Context, vloggerID uint) (int64, error) {
|
||||||
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
|
return s.repo.CountFollowers(ctx, vloggerID)
|
||||||
if err != nil {
|
}
|
||||||
return false, err
|
|
||||||
}
|
func (s *SocialService) CountVloggers(ctx context.Context, followerID uint) (int64, error) {
|
||||||
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
|
return s.repo.CountVloggers(ctx, followerID)
|
||||||
if err != nil {
|
}
|
||||||
return false, err
|
|
||||||
}
|
func (s *SocialService) IsFollowed(ctx context.Context, social *Social) (bool, error) {
|
||||||
return s.repo.IsFollowed(ctx, social)
|
_, err := s.accountrepo.FindByID(ctx, social.FollowerID)
|
||||||
}
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_, err = s.accountrepo.FindByID(ctx, social.VloggerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return s.repo.IsFollowed(ctx, social)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,97 +1,98 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"feedsystem_video_go/internal/account"
|
"feedsystem_video_go/internal/account"
|
||||||
"feedsystem_video_go/internal/middleware/jwt"
|
"feedsystem_video_go/internal/apierror"
|
||||||
|
"feedsystem_video_go/internal/middleware/jwt"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
type CommentHandler struct {
|
|
||||||
service *CommentService
|
type CommentHandler struct {
|
||||||
accountService *account.AccountService
|
service *CommentService
|
||||||
}
|
accountService *account.AccountService
|
||||||
|
}
|
||||||
func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler {
|
|
||||||
return &CommentHandler{service: service, accountService: accountService}
|
func NewCommentHandler(service *CommentService, accountService *account.AccountService) *CommentHandler {
|
||||||
}
|
return &CommentHandler{service: service, accountService: accountService}
|
||||||
func (h *CommentHandler) PublishComment(c *gin.Context) {
|
}
|
||||||
var req PublishCommentRequest
|
func (h *CommentHandler) PublishComment(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req PublishCommentRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.Content == "" {
|
}
|
||||||
c.JSON(400, gin.H{"error": "content is required"})
|
if req.Content == "" {
|
||||||
return
|
c.JSON(400, gin.H{"error": "content is required"})
|
||||||
}
|
return
|
||||||
if req.VideoID <= 0 {
|
}
|
||||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
if req.VideoID <= 0 {
|
||||||
return
|
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||||
}
|
return
|
||||||
authorId, err := jwt.GetAccountID(c)
|
}
|
||||||
if err != nil {
|
authorId, err := jwt.GetAccountID(c)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
user, err := h.accountService.FindByID(c.Request.Context(), authorId)
|
}
|
||||||
if err != nil {
|
user, err := h.accountService.FindByID(c.Request.Context(), authorId)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
comment := &Comment{
|
}
|
||||||
Username: user.Username,
|
comment := &Comment{
|
||||||
VideoID: req.VideoID,
|
Username: user.Username,
|
||||||
AuthorID: authorId,
|
VideoID: req.VideoID,
|
||||||
Content: req.Content,
|
AuthorID: authorId,
|
||||||
}
|
Content: req.Content,
|
||||||
if err := h.service.Publish(c.Request.Context(), comment); err != nil {
|
}
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := h.service.Publish(c.Request.Context(), comment); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
c.JSON(200, gin.H{"message": "comment published successfully"})
|
}
|
||||||
}
|
c.JSON(200, gin.H{"message": "comment published successfully"})
|
||||||
|
}
|
||||||
func (h *CommentHandler) DeleteComment(c *gin.Context) {
|
|
||||||
var req DeleteCommentRequest
|
func (h *CommentHandler) DeleteComment(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req DeleteCommentRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
accountID, err := jwt.GetAccountID(c)
|
}
|
||||||
if err != nil {
|
accountID, err := jwt.GetAccountID(c)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.CommentID <= 0 {
|
}
|
||||||
c.JSON(400, gin.H{"error": "comment_id is required"})
|
if req.CommentID <= 0 {
|
||||||
return
|
c.JSON(400, gin.H{"error": "comment_id is required"})
|
||||||
}
|
return
|
||||||
if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil {
|
}
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := h.service.Delete(c.Request.Context(), req.CommentID, accountID); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
c.JSON(200, gin.H{"message": "comment deleted successfully"})
|
|
||||||
}
|
c.JSON(200, gin.H{"message": "comment deleted successfully"})
|
||||||
|
}
|
||||||
func (h *CommentHandler) GetAllComments(c *gin.Context) {
|
|
||||||
var req GetAllCommentsRequest
|
func (h *CommentHandler) GetAllComments(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req GetAllCommentsRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.VideoID == 0 {
|
}
|
||||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
if req.VideoID == 0 {
|
||||||
return
|
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||||
}
|
return
|
||||||
comments, err := h.service.GetAll(c.Request.Context(), req.VideoID)
|
}
|
||||||
if err != nil {
|
comments, err := h.service.GetAll(c.Request.Context(), req.VideoID)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if comments == nil {
|
}
|
||||||
comments = []Comment{}
|
if comments == nil {
|
||||||
}
|
comments = []Comment{}
|
||||||
c.JSON(200, comments)
|
}
|
||||||
}
|
c.JSON(200, comments)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,51 +1,55 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommentRepository struct {
|
type CommentRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCommentRepository(db *gorm.DB) *CommentRepository {
|
func NewCommentRepository(db *gorm.DB) *CommentRepository {
|
||||||
return &CommentRepository{db: db}
|
return &CommentRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *CommentRepository) CreateComment(ctx context.Context, comment *Comment) error {
|
func (r *CommentRepository) CreateComment(ctx context.Context, comment *Comment) error {
|
||||||
return r.db.WithContext(ctx).Create(comment).Error
|
return r.db.WithContext(ctx).Create(comment).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment) error {
|
func (r *CommentRepository) DeleteComment(ctx context.Context, comment *Comment) error {
|
||||||
return r.db.WithContext(ctx).Delete(comment).Error
|
return r.db.WithContext(ctx).Delete(comment).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) {
|
func (r *CommentRepository) GetAllComments(ctx context.Context, videoID uint) ([]Comment, error) {
|
||||||
var comments []Comment
|
var comments []Comment
|
||||||
err := r.db.WithContext(ctx).Where("video_id = ?", videoID).Find(&comments).Error
|
err := r.db.WithContext(ctx).
|
||||||
return comments, err
|
Where("video_id = ?", videoID).
|
||||||
}
|
Order("created_at asc").
|
||||||
|
Limit(200).
|
||||||
func (r *CommentRepository) IsExist(ctx context.Context, id uint) (bool, error) {
|
Find(&comments).Error
|
||||||
var comment Comment
|
return comments, err
|
||||||
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
|
}
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return false, nil
|
func (r *CommentRepository) IsExist(ctx context.Context, id uint) (bool, error) {
|
||||||
}
|
var comment Comment
|
||||||
return false, err
|
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
|
||||||
}
|
if err == gorm.ErrRecordNotFound {
|
||||||
return true, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
return false, err
|
||||||
func (r *CommentRepository) GetByID(ctx context.Context, id uint) (*Comment, error) {
|
}
|
||||||
var comment Comment
|
return true, nil
|
||||||
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
|
}
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return nil, nil
|
func (r *CommentRepository) GetByID(ctx context.Context, id uint) (*Comment, error) {
|
||||||
}
|
var comment Comment
|
||||||
return nil, err
|
if err := r.db.WithContext(ctx).First(&comment, id).Error; err != nil {
|
||||||
}
|
if err == gorm.ErrRecordNotFound {
|
||||||
return &comment, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &comment, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,116 +1,155 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
"strings"
|
"feedsystem_video_go/internal/apierror"
|
||||||
|
"regexp"
|
||||||
"gorm.io/gorm"
|
"strings"
|
||||||
)
|
|
||||||
|
"gorm.io/gorm"
|
||||||
type CommentService struct {
|
)
|
||||||
repo *CommentRepository
|
|
||||||
VideoRepository *VideoRepository
|
type CommentService struct {
|
||||||
cache *rediscache.Client
|
repo *CommentRepository
|
||||||
commentMQ *rabbitmq.CommentMQ
|
VideoRepository *VideoRepository
|
||||||
popularityMQ *rabbitmq.PopularityMQ
|
cache *rediscache.Client
|
||||||
}
|
commentMQ *rabbitmq.CommentMQ
|
||||||
|
popularityMQ *rabbitmq.PopularityMQ
|
||||||
func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository, cache *rediscache.Client, commentMQ *rabbitmq.CommentMQ, popularityMQ *rabbitmq.PopularityMQ) *CommentService {
|
}
|
||||||
return &CommentService{repo: repo, VideoRepository: videoRepo, cache: cache, commentMQ: commentMQ, popularityMQ: popularityMQ}
|
|
||||||
}
|
func NewCommentService(repo *CommentRepository, videoRepo *VideoRepository, cache *rediscache.Client, commentMQ *rabbitmq.CommentMQ, popularityMQ *rabbitmq.PopularityMQ) *CommentService {
|
||||||
|
return &CommentService{repo: repo, VideoRepository: videoRepo, cache: cache, commentMQ: commentMQ, popularityMQ: popularityMQ}
|
||||||
func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
|
}
|
||||||
if comment == nil {
|
|
||||||
return errors.New("comment is nil")
|
func (s *CommentService) Publish(ctx context.Context, comment *Comment) error {
|
||||||
}
|
if comment == nil {
|
||||||
comment.Username = strings.TrimSpace(comment.Username)
|
return errors.New("comment is nil")
|
||||||
comment.Content = strings.TrimSpace(comment.Content)
|
}
|
||||||
if comment.VideoID == 0 || comment.AuthorID == 0 {
|
comment.Username = strings.TrimSpace(comment.Username)
|
||||||
return errors.New("video_id and author_id are required")
|
comment.Content = strings.TrimSpace(comment.Content)
|
||||||
}
|
if comment.VideoID == 0 || comment.AuthorID == 0 {
|
||||||
if comment.Content == "" {
|
return errors.New("video_id and author_id are required")
|
||||||
return errors.New("content is required")
|
}
|
||||||
}
|
if comment.Content == "" {
|
||||||
|
return errors.New("content is required")
|
||||||
exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID)
|
}
|
||||||
if err != nil {
|
|
||||||
return err
|
exists, err := s.VideoRepository.IsExist(ctx, comment.VideoID)
|
||||||
}
|
if err != nil {
|
||||||
if !exists {
|
return err
|
||||||
return errors.New("video not found")
|
}
|
||||||
}
|
if !exists {
|
||||||
|
return errors.New("video not found")
|
||||||
mysqlEnqueued := false
|
}
|
||||||
redisEnqueued := false
|
|
||||||
if s.commentMQ != nil {
|
mysqlEnqueued := false
|
||||||
if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil {
|
redisEnqueued := false
|
||||||
mysqlEnqueued = true
|
if s.commentMQ != nil {
|
||||||
}
|
if err := s.commentMQ.Publish(ctx, comment.Username, comment.VideoID, comment.AuthorID, comment.Content); err == nil {
|
||||||
}
|
mysqlEnqueued = true
|
||||||
if s.popularityMQ != nil {
|
}
|
||||||
if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil {
|
}
|
||||||
redisEnqueued = true
|
if s.popularityMQ != nil {
|
||||||
}
|
if err := s.popularityMQ.Update(ctx, comment.VideoID, 1); err == nil {
|
||||||
}
|
redisEnqueued = true
|
||||||
if mysqlEnqueued && redisEnqueued {
|
}
|
||||||
return nil
|
}
|
||||||
}
|
if mysqlEnqueued && redisEnqueued {
|
||||||
|
s.notifyMentions(ctx, comment)
|
||||||
// Fallback: direct MySQL write when comment MQ publish fails.
|
return nil
|
||||||
if !mysqlEnqueued {
|
}
|
||||||
if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil {
|
// Fallback: direct MySQL write when comment MQ publish fails.
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if !mysqlEnqueued {
|
||||||
return errors.New("video not found")
|
if err := s.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
}
|
if err := tx.Select("id").First(&Video{}, comment.VideoID).Error; err != nil {
|
||||||
return err
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
}
|
return errors.New("video not found")
|
||||||
if err := tx.Create(comment).Error; err != nil {
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&Video{}).Where("id = ?", comment.VideoID).
|
if err := tx.Create(comment).Error; err != nil {
|
||||||
UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error
|
return err
|
||||||
}); err != nil {
|
}
|
||||||
return err
|
return tx.Model(&Video{}).Where("id = ?", comment.VideoID).
|
||||||
}
|
UpdateColumn("popularity", gorm.Expr("popularity + 1")).Error
|
||||||
}
|
}); err != nil {
|
||||||
|
return err
|
||||||
// Fallback: direct Redis update when popularity MQ publish fails.
|
}
|
||||||
if !redisEnqueued {
|
}
|
||||||
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
|
|
||||||
}
|
// Fallback: direct Redis update when popularity MQ publish fails.
|
||||||
return nil
|
if !redisEnqueued {
|
||||||
}
|
UpdatePopularityCache(ctx, s.cache, comment.VideoID, 1)
|
||||||
|
}
|
||||||
func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error {
|
s.notifyMentions(ctx, comment)
|
||||||
comment, err := s.repo.GetByID(ctx, commentID)
|
return nil
|
||||||
if err != nil {
|
}
|
||||||
return err
|
|
||||||
}
|
func (s *CommentService) Delete(ctx context.Context, commentID uint, accountID uint) error {
|
||||||
if comment == nil {
|
comment, err := s.repo.GetByID(ctx, commentID)
|
||||||
return errors.New("comment not found")
|
if err != nil {
|
||||||
}
|
return err
|
||||||
if comment.AuthorID != accountID {
|
}
|
||||||
return errors.New("permission denied")
|
if comment == nil {
|
||||||
}
|
return errors.New("comment not found")
|
||||||
if s.commentMQ != nil {
|
}
|
||||||
if err := s.commentMQ.Delete(ctx, commentID); err == nil {
|
if comment.AuthorID != accountID {
|
||||||
return nil
|
return apierror.ErrUnauthorized
|
||||||
}
|
}
|
||||||
}
|
if s.commentMQ != nil {
|
||||||
return s.repo.DeleteComment(ctx, comment)
|
if err := s.commentMQ.Delete(ctx, commentID); err == nil {
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) {
|
}
|
||||||
exists, err := s.VideoRepository.IsExist(ctx, videoID)
|
return s.repo.DeleteComment(ctx, comment)
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
|
||||||
}
|
func (s *CommentService) GetAll(ctx context.Context, videoID uint) ([]Comment, error) {
|
||||||
if !exists {
|
exists, err := s.VideoRepository.IsExist(ctx, videoID)
|
||||||
return nil, errors.New("video not found")
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
return s.repo.GetAllComments(ctx, videoID)
|
}
|
||||||
}
|
if !exists {
|
||||||
|
return nil, errors.New("video not found")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,113 +1,114 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"feedsystem_video_go/internal/middleware/jwt"
|
"feedsystem_video_go/internal/middleware/jwt"
|
||||||
|
"feedsystem_video_go/internal/apierror"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
type LikeHandler struct {
|
|
||||||
service *LikeService
|
type LikeHandler struct {
|
||||||
}
|
service *LikeService
|
||||||
|
}
|
||||||
func NewLikeHandler(service *LikeService) *LikeHandler {
|
|
||||||
return &LikeHandler{service: service}
|
func NewLikeHandler(service *LikeService) *LikeHandler {
|
||||||
}
|
return &LikeHandler{service: service}
|
||||||
|
}
|
||||||
func (lh *LikeHandler) Like(c *gin.Context) {
|
|
||||||
var req LikeRequest
|
func (lh *LikeHandler) Like(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req LikeRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.VideoID <= 0 {
|
}
|
||||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
if req.VideoID <= 0 {
|
||||||
return
|
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
accountID, err := jwt.GetAccountID(c)
|
|
||||||
if err != nil {
|
accountID, err := jwt.GetAccountID(c)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
like := &Like{
|
|
||||||
VideoID: req.VideoID,
|
like := &Like{
|
||||||
AccountID: accountID,
|
VideoID: req.VideoID,
|
||||||
}
|
AccountID: accountID,
|
||||||
if err := lh.service.Like(c.Request.Context(), like); err != nil {
|
}
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
if err := lh.service.Like(c.Request.Context(), like); err != nil {
|
||||||
return
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
c.JSON(200, gin.H{"message": "like success"})
|
}
|
||||||
}
|
c.JSON(200, gin.H{"message": "like success"})
|
||||||
|
}
|
||||||
func (lh *LikeHandler) Unlike(c *gin.Context) {
|
|
||||||
var req LikeRequest
|
func (lh *LikeHandler) Unlike(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req LikeRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.VideoID <= 0 {
|
}
|
||||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
if req.VideoID <= 0 {
|
||||||
return
|
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
accountID, err := jwt.GetAccountID(c)
|
|
||||||
if err != nil {
|
accountID, err := jwt.GetAccountID(c)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
like := &Like{
|
|
||||||
VideoID: req.VideoID,
|
like := &Like{
|
||||||
AccountID: accountID,
|
VideoID: req.VideoID,
|
||||||
}
|
AccountID: accountID,
|
||||||
if err := lh.service.Unlike(c.Request.Context(), like); err != nil {
|
}
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
if err := lh.service.Unlike(c.Request.Context(), like); err != nil {
|
||||||
return
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
c.JSON(200, gin.H{"message": "unlike success"})
|
}
|
||||||
}
|
c.JSON(200, gin.H{"message": "unlike success"})
|
||||||
|
}
|
||||||
func (lh *LikeHandler) IsLiked(c *gin.Context) {
|
|
||||||
var req LikeRequest
|
func (lh *LikeHandler) IsLiked(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req LikeRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if req.VideoID <= 0 {
|
}
|
||||||
c.JSON(400, gin.H{"error": "video_id is required"})
|
if req.VideoID <= 0 {
|
||||||
return
|
c.JSON(400, gin.H{"error": "video_id is required"})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
accountID, err := jwt.GetAccountID(c)
|
|
||||||
if err != nil {
|
accountID, err := jwt.GetAccountID(c)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID)
|
}
|
||||||
if err != nil {
|
isLiked, err := lh.service.IsLiked(c.Request.Context(), req.VideoID, accountID)
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
c.JSON(200, gin.H{"is_liked": isLiked})
|
}
|
||||||
}
|
c.JSON(200, gin.H{"is_liked": isLiked})
|
||||||
|
}
|
||||||
func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) {
|
|
||||||
accountID, err := jwt.GetAccountID(c)
|
func (lh *LikeHandler) ListMyLikedVideos(c *gin.Context) {
|
||||||
if err != nil {
|
accountID, err := jwt.GetAccountID(c)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID)
|
|
||||||
if err != nil {
|
videos, err := lh.service.ListLikedVideos(c.Request.Context(), accountID)
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
if videos == nil {
|
}
|
||||||
videos = []Video{}
|
if videos == nil {
|
||||||
}
|
videos = []Video{}
|
||||||
c.JSON(200, videos)
|
}
|
||||||
}
|
c.JSON(200, videos)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,101 +1,102 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LikeRepository struct {
|
type LikeRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLikeRepository(db *gorm.DB) *LikeRepository {
|
func NewLikeRepository(db *gorm.DB) *LikeRepository {
|
||||||
return &LikeRepository{db: db}
|
return &LikeRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LikeRepository) Like(ctx context.Context, like *Like) error {
|
func (r *LikeRepository) Like(ctx context.Context, like *Like) error {
|
||||||
return r.db.WithContext(ctx).Create(like).Error
|
return r.db.WithContext(ctx).Create(like).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LikeRepository) Unlike(ctx context.Context, like *Like) error {
|
func (r *LikeRepository) Unlike(ctx context.Context, like *Like) error {
|
||||||
return r.db.WithContext(ctx).
|
return r.db.WithContext(ctx).
|
||||||
Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID).
|
Where("video_id = ? AND account_id = ?", like.VideoID, like.AccountID).
|
||||||
Delete(&Like{}).Error
|
Delete(&Like{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LikeRepository) LikeIgnoreDuplicate(ctx context.Context, like *Like) (created bool, err error) {
|
func (r *LikeRepository) LikeIgnoreDuplicate(ctx context.Context, like *Like) (created bool, err error) {
|
||||||
if like == nil || like.VideoID == 0 || like.AccountID == 0 {
|
if like == nil || like.VideoID == 0 || like.AccountID == 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
err = r.db.WithContext(ctx).Create(like).Error
|
err = r.db.WithContext(ctx).Create(like).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
var mysqlErr *mysql.MySQLError
|
var mysqlErr *mysql.MySQLError
|
||||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LikeRepository) DeleteByVideoAndAccount(ctx context.Context, videoID, accountID uint) (deleted bool, err error) {
|
func (r *LikeRepository) DeleteByVideoAndAccount(ctx context.Context, videoID, accountID uint) (deleted bool, err error) {
|
||||||
if videoID == 0 || accountID == 0 {
|
if videoID == 0 || accountID == 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
res := r.db.WithContext(ctx).
|
res := r.db.WithContext(ctx).
|
||||||
Where("video_id = ? AND account_id = ?", videoID, accountID).
|
Where("video_id = ? AND account_id = ?", videoID, accountID).
|
||||||
Delete(&Like{})
|
Delete(&Like{})
|
||||||
return res.RowsAffected > 0, res.Error
|
return res.RowsAffected > 0, res.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) {
|
func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (bool, error) {
|
||||||
var count int64
|
var count int64
|
||||||
err := r.db.WithContext(ctx).Model(&Like{}).
|
err := r.db.WithContext(ctx).Model(&Like{}).
|
||||||
Where("video_id = ? AND account_id = ?", videoID, accountID).
|
Where("video_id = ? AND account_id = ?", videoID, accountID).
|
||||||
Count(&count).Error
|
Count(&count).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) {
|
func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) {
|
||||||
likeMap := make(map[uint]bool)
|
likeMap := make(map[uint]bool)
|
||||||
if len(videoIDs) == 0 {
|
if len(videoIDs) == 0 {
|
||||||
return likeMap, nil
|
return likeMap, nil
|
||||||
}
|
}
|
||||||
if accountID == 0 {
|
if accountID == 0 {
|
||||||
return likeMap, nil
|
return likeMap, nil
|
||||||
}
|
}
|
||||||
var likes []Like
|
var likes []Like
|
||||||
err := r.db.WithContext(ctx).Model(&Like{}).
|
err := r.db.WithContext(ctx).Model(&Like{}).
|
||||||
Where("video_id IN ? AND account_id = ?", videoIDs, accountID).
|
Where("video_id IN ? AND account_id = ?", videoIDs, accountID).
|
||||||
Find(&likes).Error
|
Find(&likes).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, like := range likes {
|
for _, like := range likes {
|
||||||
likeMap[like.VideoID] = true
|
likeMap[like.VideoID] = true
|
||||||
}
|
}
|
||||||
return likeMap, nil
|
return likeMap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *LikeRepository) ListLikedVideos(ctx context.Context, accountID uint) ([]Video, error) {
|
func (r *LikeRepository) ListLikedVideos(ctx context.Context, accountID uint) ([]Video, error) {
|
||||||
var videos []Video
|
var videos []Video
|
||||||
if accountID == 0 {
|
if accountID == 0 {
|
||||||
return videos, nil
|
return videos, nil
|
||||||
}
|
}
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Model(&Video{}).
|
Model(&Video{}).
|
||||||
Joins("JOIN likes ON likes.video_id = videos.id").
|
Joins("JOIN likes ON likes.video_id = videos.id").
|
||||||
Where("likes.account_id = ?", accountID).
|
Where("likes.account_id = ?", accountID).
|
||||||
Order("likes.created_at desc").
|
Order("likes.created_at desc").
|
||||||
Find(&videos).Error
|
Limit(200).
|
||||||
if err != nil {
|
Find(&videos).Error
|
||||||
return nil, err
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
return videos, nil
|
}
|
||||||
}
|
return videos, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,29 +1,28 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"strconv"
|
||||||
"strconv"
|
"time"
|
||||||
"time"
|
|
||||||
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
)
|
||||||
)
|
|
||||||
|
// 更新视频流行度缓存
|
||||||
// 更新视频流行度缓存
|
func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) {
|
||||||
func UpdatePopularityCache(ctx context.Context, cache *rediscache.Client, id uint, change int64) {
|
if cache == nil || id == 0 || change == 0 {
|
||||||
if cache == nil || id == 0 || change == 0 {
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
_ = cache.Del(context.Background(), cache.Key("video:detail:id=%d", id))
|
||||||
_ = cache.Del(context.Background(), fmt.Sprintf("video:detail:id=%d", id))
|
|
||||||
|
now := time.Now().UTC().Truncate(time.Minute)
|
||||||
now := time.Now().UTC().Truncate(time.Minute)
|
windowKey := cache.Key("hot:video:1m:%s", now.Format("200601021504"))
|
||||||
windowKey := "hot:video:1m:" + now.Format("200601021504")
|
member := strconv.FormatUint(uint64(id), 10)
|
||||||
member := strconv.FormatUint(uint64(id), 10)
|
|
||||||
|
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
defer cancel()
|
||||||
defer cancel()
|
|
||||||
|
_ = cache.ZincrBy(opCtx, windowKey, member, float64(change))
|
||||||
_ = cache.ZincrBy(opCtx, windowKey, member, float64(change))
|
_ = cache.Expire(opCtx, windowKey, 2*time.Hour)
|
||||||
_ = cache.Expire(opCtx, windowKey, 2*time.Hour)
|
}
|
||||||
}
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -1,48 +1,48 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Video struct {
|
type Video struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
AuthorID uint `gorm:"index;not null" json:"author_id"`
|
AuthorID uint `gorm:"index;not null" json:"author_id"`
|
||||||
Username string `gorm:"type:varchar(255);not null" json:"username"`
|
Username string `gorm:"type:varchar(255);not null" json:"username"`
|
||||||
Title string `gorm:"type:varchar(255);not null" json:"title"`
|
Title string `gorm:"type:varchar(255);not null" json:"title"`
|
||||||
Description string `gorm:"type:varchar(255);" json:"description,omitempty"`
|
Description string `gorm:"type:varchar(255);" json:"description,omitempty"`
|
||||||
PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"`
|
PlayURL string `gorm:"type:varchar(255);not null" json:"play_url"`
|
||||||
CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"`
|
CoverURL string `gorm:"type:varchar(255);not null" json:"cover_url"`
|
||||||
CreateTime time.Time `gorm:"autoCreateTime" json:"create_time"`
|
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" json:"likes_count"`
|
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" json:"popularity"`
|
Popularity int64 `gorm:"column:popularity;not null;default:0;index:idx_videos_popularity_time_id,priority:1,sort:desc" json:"popularity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublishVideoRequest struct {
|
type PublishVideoRequest struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
PlayURL string `json:"play_url"`
|
PlayURL string `json:"play_url"`
|
||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeleteVideoRequest struct {
|
type DeleteVideoRequest struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListByAuthorIDRequest struct {
|
type ListByAuthorIDRequest struct {
|
||||||
AuthorID uint `json:"author_id"`
|
AuthorID uint `json:"author_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetDetailRequest struct {
|
type GetDetailRequest struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateLikesCountRequest struct {
|
type UpdateLikesCountRequest struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
LikesCount int64 `json:"likes_count"`
|
LikesCount int64 `json:"likes_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OutboxMsg struct {
|
type OutboxMsg struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
VideoID uint `gorm:"index"`
|
VideoID uint `gorm:"index"`
|
||||||
EventType string `gorm:"type:varchar(50)"`
|
EventType string `gorm:"type:varchar(50)"`
|
||||||
CreateTime time.Time `gorm:"autoCreateTime"`
|
CreateTime time.Time `gorm:"autoCreateTime"`
|
||||||
Status string `gorm:"type:varchar(50);index"`
|
Status string `gorm:"type:varchar(50);index"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,241 +1,254 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"feedsystem_video_go/internal/account"
|
"feedsystem_video_go/internal/account"
|
||||||
"feedsystem_video_go/internal/middleware/jwt"
|
"feedsystem_video_go/internal/apierror"
|
||||||
|
"feedsystem_video_go/internal/middleware/jwt"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
type VideoHandler struct {
|
|
||||||
service *VideoService
|
type VideoHandler struct {
|
||||||
accountService *account.AccountService
|
service *VideoService
|
||||||
}
|
accountService *account.AccountService
|
||||||
|
}
|
||||||
func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler {
|
|
||||||
return &VideoHandler{service: service, accountService: accountService}
|
func NewVideoHandler(service *VideoService, accountService *account.AccountService) *VideoHandler {
|
||||||
}
|
return &VideoHandler{service: service, accountService: accountService}
|
||||||
|
}
|
||||||
func (vh *VideoHandler) PublishVideo(c *gin.Context) {
|
|
||||||
var req PublishVideoRequest
|
func (vh *VideoHandler) PublishVideo(c *gin.Context) {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
var req PublishVideoRequest
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
authorId, err := jwt.GetAccountID(c)
|
|
||||||
if err != nil {
|
authorId, err := jwt.GetAccountID(c)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
username, err := jwt.GetUsername(c)
|
}
|
||||||
if err != nil {
|
username, err := jwt.GetUsername(c)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
video := &Video{
|
}
|
||||||
AuthorID: authorId,
|
video := &Video{
|
||||||
Username: username,
|
AuthorID: authorId,
|
||||||
Title: req.Title,
|
Username: username,
|
||||||
Description: req.Description,
|
Title: req.Title,
|
||||||
PlayURL: req.PlayURL,
|
Description: req.Description,
|
||||||
CoverURL: req.CoverURL,
|
PlayURL: req.PlayURL,
|
||||||
CreateTime: time.Now(),
|
CoverURL: req.CoverURL,
|
||||||
}
|
CreateTime: time.Now(),
|
||||||
if err := vh.service.Publish(c.Request.Context(), video); err != nil {
|
}
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err := vh.service.Publish(c.Request.Context(), video); err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
c.JSON(200, video)
|
}
|
||||||
}
|
c.JSON(200, video)
|
||||||
|
}
|
||||||
func (vh *VideoHandler) UploadVideo(c *gin.Context) {
|
|
||||||
authorId, err := jwt.GetAccountID(c)
|
func (vh *VideoHandler) UploadVideo(c *gin.Context) {
|
||||||
if err != nil {
|
authorId, err := jwt.GetAccountID(c)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
f, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
f, err := c.FormFile("file")
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
if err != nil {
|
||||||
return
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
const maxSize = 200 << 20
|
|
||||||
if f.Size <= 0 || f.Size > maxSize {
|
const maxSize = 200 << 20
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
|
if f.Size <= 0 || f.Size > maxSize {
|
||||||
return
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
ext := strings.ToLower(filepath.Ext(f.Filename))
|
|
||||||
if ext != ".mp4" {
|
ext := strings.ToLower(filepath.Ext(f.Filename))
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only .mp4 is allowed"})
|
if ext != ".mp4" {
|
||||||
return
|
c.JSON(http.StatusBadRequest, gin.H{"error": "only .mp4 is allowed"})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
date := time.Now().Format("20060102")
|
|
||||||
relDir := filepath.Join("videos", fmt.Sprintf("%d", authorId), date)
|
date := time.Now().Format("20060102")
|
||||||
root := filepath.Join(".run", "uploads")
|
relDir := filepath.Join("videos", fmt.Sprintf("%d", authorId), date)
|
||||||
absDir := filepath.Join(root, relDir)
|
root := filepath.Join(".run", "uploads")
|
||||||
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
absDir := filepath.Join(root, relDir)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||||
return
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
filename := randHex(16) + ext
|
|
||||||
absPath := filepath.Join(absDir, filename)
|
filename, err := randHex(16)
|
||||||
|
if err != nil {
|
||||||
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
return
|
||||||
return
|
}
|
||||||
}
|
filename = filename + ext
|
||||||
|
absPath := filepath.Join(absDir, filename)
|
||||||
urlPath := path.Join("/static", "videos", fmt.Sprintf("%d", authorId), date, filename)
|
|
||||||
|
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
"url": buildAbsoluteURL(c, urlPath),
|
return
|
||||||
"play_url": buildAbsoluteURL(c, urlPath),
|
}
|
||||||
})
|
|
||||||
}
|
urlPath := path.Join("/static", "videos", fmt.Sprintf("%d", authorId), date, filename)
|
||||||
|
|
||||||
func (vh *VideoHandler) UploadCover(c *gin.Context) {
|
c.JSON(http.StatusOK, gin.H{
|
||||||
authorId, err := jwt.GetAccountID(c)
|
"url": buildAbsoluteURL(c, urlPath),
|
||||||
if err != nil {
|
"play_url": buildAbsoluteURL(c, urlPath),
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
})
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
func (vh *VideoHandler) UploadCover(c *gin.Context) {
|
||||||
f, err := c.FormFile("file")
|
authorId, err := jwt.GetAccountID(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxSize = 10 << 20
|
f, err := c.FormFile("file")
|
||||||
if f.Size <= 0 || f.Size > maxSize {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(f.Filename))
|
const maxSize = 10 << 20
|
||||||
switch ext {
|
if f.Size <= 0 || f.Size > maxSize {
|
||||||
case ".jpg", ".jpeg", ".png", ".webp":
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid file size"})
|
||||||
default:
|
return
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp is allowed"})
|
}
|
||||||
return
|
|
||||||
}
|
ext := strings.ToLower(filepath.Ext(f.Filename))
|
||||||
|
switch ext {
|
||||||
date := time.Now().Format("20060102")
|
case ".jpg", ".jpeg", ".png", ".webp":
|
||||||
relDir := filepath.Join("covers", fmt.Sprintf("%d", authorId), date)
|
default:
|
||||||
root := filepath.Join(".run", "uploads")
|
c.JSON(http.StatusBadRequest, gin.H{"error": "only .jpg/.jpeg/.png/.webp is allowed"})
|
||||||
absDir := filepath.Join(root, relDir)
|
return
|
||||||
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
date := time.Now().Format("20060102")
|
||||||
}
|
relDir := filepath.Join("covers", fmt.Sprintf("%d", authorId), date)
|
||||||
|
root := filepath.Join(".run", "uploads")
|
||||||
filename := randHex(16) + ext
|
absDir := filepath.Join(root, relDir)
|
||||||
absPath := filepath.Join(absDir, filename)
|
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
return
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
}
|
||||||
return
|
|
||||||
}
|
filename, err := randHex(16)
|
||||||
|
if err != nil {
|
||||||
urlPath := path.Join("/static", "covers", fmt.Sprintf("%d", authorId), date, filename)
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||||
|
return
|
||||||
c.JSON(http.StatusOK, gin.H{
|
}
|
||||||
"url": buildAbsoluteURL(c, urlPath),
|
filename = filename + ext
|
||||||
"cover_url": buildAbsoluteURL(c, urlPath),
|
absPath := filepath.Join(absDir, filename)
|
||||||
})
|
|
||||||
}
|
if err := c.SaveUploadedFile(f, absPath); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
func randHex(n int) string {
|
return
|
||||||
b := make([]byte, n)
|
}
|
||||||
_, _ = rand.Read(b)
|
|
||||||
return hex.EncodeToString(b)
|
urlPath := path.Join("/static", "covers", fmt.Sprintf("%d", authorId), date, filename)
|
||||||
}
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
func buildAbsoluteURL(c *gin.Context, p string) string {
|
"url": buildAbsoluteURL(c, urlPath),
|
||||||
scheme := "http"
|
"cover_url": buildAbsoluteURL(c, urlPath),
|
||||||
if c.Request.TLS != nil {
|
})
|
||||||
scheme = "https"
|
}
|
||||||
}
|
|
||||||
if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" {
|
func randHex(n int) (string, error) {
|
||||||
scheme = xf
|
b := make([]byte, n)
|
||||||
}
|
if _, err := rand.Read(b); err != nil {
|
||||||
return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p)
|
return "", fmt.Errorf("rand.Read: %w", err)
|
||||||
}
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
func (vh *VideoHandler) DeleteVideo(c *gin.Context) {
|
}
|
||||||
var req DeleteVideoRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
func buildAbsoluteURL(c *gin.Context, p string) string {
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
scheme := "http"
|
||||||
return
|
if c.Request.TLS != nil {
|
||||||
}
|
scheme = "https"
|
||||||
authorId, err := jwt.GetAccountID(c)
|
}
|
||||||
if err != nil {
|
if xf := c.GetHeader("X-Forwarded-Proto"); xf != "" {
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
scheme = xf
|
||||||
return
|
}
|
||||||
}
|
return fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, p)
|
||||||
if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil {
|
}
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
|
||||||
return
|
func (vh *VideoHandler) DeleteVideo(c *gin.Context) {
|
||||||
}
|
var req DeleteVideoRequest
|
||||||
c.JSON(200, gin.H{"message": "video deleted"})
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
}
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
|
}
|
||||||
var req ListByAuthorIDRequest
|
authorId, err := jwt.GetAccountID(c)
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err != nil {
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID)
|
if err := vh.service.Delete(c.Request.Context(), req.ID, authorId); err != nil {
|
||||||
if err != nil {
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
return
|
||||||
return
|
}
|
||||||
}
|
c.JSON(200, gin.H{"message": "video deleted"})
|
||||||
if videos == nil {
|
}
|
||||||
videos = []Video{}
|
|
||||||
}
|
func (vh *VideoHandler) ListByAuthorID(c *gin.Context) {
|
||||||
c.JSON(200, videos)
|
var req ListByAuthorIDRequest
|
||||||
}
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
func (vh *VideoHandler) GetDetail(c *gin.Context) {
|
return
|
||||||
var req GetDetailRequest
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
videos, err := vh.service.ListByAuthorID(c.Request.Context(), req.AuthorID)
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
if err != nil {
|
||||||
return
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
}
|
return
|
||||||
video, err := vh.service.GetDetail(c.Request.Context(), req.ID)
|
}
|
||||||
if err != nil {
|
if videos == nil {
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
videos = []Video{}
|
||||||
return
|
}
|
||||||
}
|
c.JSON(200, videos)
|
||||||
c.JSON(200, video)
|
}
|
||||||
}
|
|
||||||
|
func (vh *VideoHandler) GetDetail(c *gin.Context) {
|
||||||
func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) {
|
var req GetDetailRequest
|
||||||
var req UpdateLikesCountRequest
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
return
|
||||||
return
|
}
|
||||||
}
|
video, err := vh.service.GetDetail(c.Request.Context(), req.ID)
|
||||||
if err := vh.service.UpdateLikesCount(c.Request.Context(), req.ID, req.LikesCount); err != nil {
|
if err != nil {
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
c.JSON(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(200, gin.H{"message": "likes count updated"})
|
c.JSON(200, video)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (vh *VideoHandler) UpdateLikesCount(c *gin.Context) {
|
||||||
|
var req UpdateLikesCountRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
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(apierror.ClassifyHTTPStatus(err), gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(200, gin.H{"message": "likes count updated"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,104 +1,120 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type VideoRepository struct {
|
type VideoRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
||||||
return &VideoRepository{db: db}
|
return &VideoRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error {
|
func (vr *VideoRepository) CreateVideo(ctx context.Context, video *Video) error {
|
||||||
if err := vr.db.WithContext(ctx).Create(video).Error; err != nil {
|
if err := vr.db.WithContext(ctx).Create(video).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *VideoRepository) CreateMsg(ctx context.Context, Msg *OutboxMsg) error {
|
func (vr *VideoRepository) CreateMsg(ctx context.Context, Msg *OutboxMsg) error {
|
||||||
if err := vr.db.WithContext(ctx).Create(Msg).Error; err != nil {
|
if err := vr.db.WithContext(ctx).Create(Msg).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error {
|
func (vr *VideoRepository) DeleteVideo(ctx context.Context, id uint) error {
|
||||||
if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil {
|
if err := vr.db.WithContext(ctx).Delete(&Video{}, id).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) {
|
func (vr *VideoRepository) ListByAuthorID(ctx context.Context, authorID int64) ([]Video, error) {
|
||||||
var videos []Video
|
var videos []Video
|
||||||
if err := vr.db.WithContext(ctx).
|
if err := vr.db.WithContext(ctx).
|
||||||
Where("author_id = ?", authorID).
|
Where("author_id = ?", authorID).
|
||||||
Order("create_time desc").
|
Order("create_time desc").
|
||||||
Offset(0).
|
Limit(200).
|
||||||
Find(&videos).Error; err != nil {
|
Find(&videos).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return videos, nil
|
return videos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) {
|
func (vr *VideoRepository) GetByID(ctx context.Context, id uint) (*Video, error) {
|
||||||
var video Video
|
var video Video
|
||||||
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
|
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
|
||||||
return (*Video)(nil), err
|
return (*Video)(nil), err
|
||||||
}
|
}
|
||||||
return &video, nil
|
return &video, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
|
func (vr *VideoRepository) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
|
||||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Update("likes_count", likesCount).Error; err != nil {
|
Update("likes_count", likesCount).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) {
|
func (vr *VideoRepository) IsExist(ctx context.Context, id uint) (bool, error) {
|
||||||
var video Video
|
var video Video
|
||||||
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
|
if err := vr.db.WithContext(ctx).First(&video, id).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error {
|
func (vr *VideoRepository) UpdatePopularity(ctx context.Context, id uint, change int64) error {
|
||||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil {
|
Update("popularity", gorm.Expr("popularity + ?", change)).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *VideoRepository) ChangeLikesCount(ctx context.Context, id uint, change int64) error {
|
func (vr *VideoRepository) ChangeLikesCount(ctx context.Context, id uint, change int64) error {
|
||||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count + ?, 0)", change)).Error; err != nil {
|
UpdateColumn("likes_count", gorm.Expr("GREATEST(likes_count + ?, 0)", change)).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change int64) error {
|
func (vr *VideoRepository) ChangePopularity(ctx context.Context, id uint, change int64) error {
|
||||||
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
if err := vr.db.WithContext(ctx).Model(&Video{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
UpdateColumn("popularity", gorm.Expr("GREATEST(popularity + ?, 0)", change)).Error; err != nil {
|
UpdateColumn("popularity", gorm.Expr("GREATEST(popularity + ?, 0)", change)).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,220 +1,226 @@
|
|||||||
package video
|
package video
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"strconv"
|
||||||
"strconv"
|
"strings"
|
||||||
"strings"
|
"time"
|
||||||
"time"
|
|
||||||
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
"feedsystem_video_go/internal/apierror"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type VideoService struct {
|
type VideoService struct {
|
||||||
repo *VideoRepository
|
repo *VideoRepository
|
||||||
cache *rediscache.Client
|
cache *rediscache.Client
|
||||||
cacheTTL time.Duration
|
cacheTTL time.Duration
|
||||||
popularityMQ *rabbitmq.PopularityMQ
|
popularityMQ *rabbitmq.PopularityMQ
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewVideoService(repo *VideoRepository, cache *rediscache.Client, popularityMQ *rabbitmq.PopularityMQ) *VideoService {
|
func NewVideoService(repo *VideoRepository, cache *rediscache.Client, popularityMQ *rabbitmq.PopularityMQ) *VideoService {
|
||||||
return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute, popularityMQ: popularityMQ}
|
return &VideoService{repo: repo, cache: cache, cacheTTL: 5 * time.Minute, popularityMQ: popularityMQ}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
|
func (vs *VideoService) Publish(ctx context.Context, video *Video) error {
|
||||||
if video == nil {
|
if video == nil {
|
||||||
return errors.New("video is nil")
|
return errors.New("video is nil")
|
||||||
}
|
}
|
||||||
video.Title = strings.TrimSpace(video.Title)
|
video.Title = strings.TrimSpace(video.Title)
|
||||||
video.PlayURL = strings.TrimSpace(video.PlayURL)
|
video.PlayURL = strings.TrimSpace(video.PlayURL)
|
||||||
video.CoverURL = strings.TrimSpace(video.CoverURL)
|
video.CoverURL = strings.TrimSpace(video.CoverURL)
|
||||||
|
|
||||||
if video.Title == "" {
|
if video.Title == "" {
|
||||||
return errors.New("title is required")
|
return errors.New("title is required")
|
||||||
}
|
}
|
||||||
if video.PlayURL == "" {
|
if video.PlayURL == "" {
|
||||||
return errors.New("play url is required")
|
return errors.New("play url is required")
|
||||||
}
|
}
|
||||||
if video.CoverURL == "" {
|
if video.CoverURL == "" {
|
||||||
return errors.New("cover url is required")
|
return errors.New("cover url is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
//事务保证视频写入库和消息写入本地消息表的一致性
|
//事务保证视频写入库和消息写入本地消息表的一致性
|
||||||
err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := vs.repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Create(video).Error; err != nil {
|
if err := tx.Create(video).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := OutboxMsg{
|
msg := OutboxMsg{
|
||||||
VideoID: video.ID,
|
VideoID: video.ID,
|
||||||
EventType: "video_published",
|
EventType: "video_published",
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreateTime: video.CreateTime,
|
CreateTime: video.CreateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Create(&msg).Error; err != nil {
|
if err := tx.Create(&msg).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
tags := ExtractTags(video.Title + " " + video.Description)
|
||||||
})
|
for _, tagName := range tags {
|
||||||
return err
|
var tag Tag
|
||||||
|
tx.Where("name = ?", tagName).FirstOrCreate(&tag, Tag{Name: tagName})
|
||||||
}
|
tx.Create(&VideoTag{VideoID: video.ID, TagID: tag.ID})
|
||||||
|
}
|
||||||
func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error {
|
return nil
|
||||||
video, err := vs.repo.GetByID(ctx, id)
|
})
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if video == nil {
|
|
||||||
return errors.New("video not found")
|
func (vs *VideoService) Delete(ctx context.Context, id uint, authorID uint) error {
|
||||||
}
|
video, err := vs.repo.GetByID(ctx, id)
|
||||||
if video.AuthorID != authorID {
|
if err != nil {
|
||||||
return errors.New("unauthorized")
|
return err
|
||||||
}
|
}
|
||||||
if err := vs.repo.DeleteVideo(ctx, id); err != nil {
|
if video == nil {
|
||||||
return err
|
return errors.New("video not found")
|
||||||
}
|
}
|
||||||
if vs.cache != nil {
|
if video.AuthorID != authorID {
|
||||||
cacheKey := fmt.Sprintf("video:detail:id=%d", id)
|
return apierror.ErrUnauthorized
|
||||||
_ = vs.cache.Del(context.Background(), cacheKey)
|
}
|
||||||
}
|
if err := vs.repo.DeleteVideo(ctx, id); err != nil {
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
if vs.cache != nil {
|
||||||
func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) {
|
cacheKey := vs.cache.Key("video:detail:id=%d", id)
|
||||||
videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID))
|
_ = vs.cache.Del(context.Background(), cacheKey)
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
return nil
|
||||||
}
|
}
|
||||||
return videos, nil
|
|
||||||
}
|
func (vs *VideoService) ListByAuthorID(ctx context.Context, authorID uint) ([]Video, error) {
|
||||||
|
videos, err := vs.repo.ListByAuthorID(ctx, int64(authorID))
|
||||||
func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) {
|
if err != nil {
|
||||||
cacheKey := fmt.Sprintf("video:detail:id=%d", id)
|
return nil, err
|
||||||
|
}
|
||||||
getCached := func() (*Video, bool) {
|
return videos, nil
|
||||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
}
|
||||||
defer cancel()
|
|
||||||
|
func (vs *VideoService) GetDetail(ctx context.Context, id uint) (*Video, error) {
|
||||||
b, err := vs.cache.GetBytes(opCtx, cacheKey)
|
cacheKey := vs.cache.Key("video:detail:id=%d", id)
|
||||||
if err != nil {
|
|
||||||
return nil, false
|
getCached := func() (*Video, bool) {
|
||||||
}
|
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
var cached Video
|
defer cancel()
|
||||||
if err := json.Unmarshal(b, &cached); err != nil {
|
|
||||||
return nil, false
|
b, err := vs.cache.GetBytes(opCtx, cacheKey)
|
||||||
}
|
if err != nil {
|
||||||
return &cached, true
|
return nil, false
|
||||||
}
|
}
|
||||||
|
var cached Video
|
||||||
setCached := func(video *Video) {
|
if err := json.Unmarshal(b, &cached); err != nil {
|
||||||
b, err := json.Marshal(video)
|
return nil, false
|
||||||
if err != nil {
|
}
|
||||||
return
|
return &cached, true
|
||||||
}
|
}
|
||||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
|
||||||
defer cancel()
|
setCached := func(video *Video) {
|
||||||
_ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL)
|
b, err := json.Marshal(video)
|
||||||
}
|
if err != nil {
|
||||||
|
return
|
||||||
if vs.cache != nil {
|
}
|
||||||
if v, ok := getCached(); ok {
|
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
return v, nil
|
defer cancel()
|
||||||
}
|
_ = vs.cache.SetBytes(opCtx, cacheKey, b, vs.cacheTTL)
|
||||||
|
}
|
||||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
|
||||||
b, err := vs.cache.GetBytes(opCtx, cacheKey)
|
if vs.cache != nil {
|
||||||
cancel()
|
if v, ok := getCached(); ok {
|
||||||
if err == nil {
|
return v, nil
|
||||||
var cached Video
|
}
|
||||||
if err := json.Unmarshal(b, &cached); err == nil {
|
|
||||||
return &cached, nil
|
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
}
|
b, err := vs.cache.GetBytes(opCtx, cacheKey)
|
||||||
} else if rediscache.IsMiss(err) {
|
cancel()
|
||||||
lockKey := "lock:" + cacheKey
|
if err == nil {
|
||||||
|
var cached Video
|
||||||
lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
if err := json.Unmarshal(b, &cached); err == nil {
|
||||||
token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second)
|
return &cached, nil
|
||||||
lockCancel()
|
}
|
||||||
|
} else if rediscache.IsMiss(err) {
|
||||||
if lockErr == nil && locked {
|
lockKey := "lock:" + cacheKey
|
||||||
defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }()
|
|
||||||
|
lockCtx, lockCancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
if v, ok := getCached(); ok {
|
token, locked, lockErr := vs.cache.Lock(lockCtx, lockKey, 2*time.Second)
|
||||||
return v, nil
|
lockCancel()
|
||||||
}
|
|
||||||
|
if lockErr == nil && locked {
|
||||||
video, err := vs.repo.GetByID(ctx, id)
|
defer func() { _ = vs.cache.Unlock(context.Background(), lockKey, token) }()
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
if v, ok := getCached(); ok {
|
||||||
}
|
return v, nil
|
||||||
setCached(video)
|
}
|
||||||
return video, nil
|
|
||||||
}
|
video, err := vs.repo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
// 没拿到锁:等待别人回填缓存
|
return nil, err
|
||||||
for i := 0; i < 5; i++ {
|
}
|
||||||
select {
|
setCached(video)
|
||||||
case <-ctx.Done():
|
return video, nil
|
||||||
return nil, ctx.Err()
|
}
|
||||||
case <-time.After(20 * time.Millisecond):
|
|
||||||
}
|
// 没拿到锁:等待别人回填缓存
|
||||||
if v, ok := getCached(); ok {
|
for i := 0; i < 5; i++ {
|
||||||
return v, nil
|
select {
|
||||||
}
|
case <-ctx.Done():
|
||||||
}
|
return nil, ctx.Err()
|
||||||
}
|
case <-time.After(20 * time.Millisecond):
|
||||||
}
|
}
|
||||||
|
if v, ok := getCached(); ok {
|
||||||
video, err := vs.repo.GetByID(ctx, id)
|
return v, nil
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
}
|
||||||
}
|
}
|
||||||
if vs.cache != nil {
|
}
|
||||||
setCached(video)
|
|
||||||
}
|
video, err := vs.repo.GetByID(ctx, id)
|
||||||
return video, nil
|
if err != nil {
|
||||||
}
|
return nil, err
|
||||||
|
}
|
||||||
func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
|
if vs.cache != nil {
|
||||||
if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil {
|
setCached(video)
|
||||||
return err
|
}
|
||||||
}
|
return video, nil
|
||||||
return nil
|
}
|
||||||
}
|
|
||||||
|
func (vs *VideoService) UpdateLikesCount(ctx context.Context, id uint, likesCount int64) error {
|
||||||
func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error {
|
if err := vs.repo.UpdateLikesCount(ctx, id, likesCount); err != nil {
|
||||||
if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
if vs.popularityMQ != nil {
|
|
||||||
if err := vs.popularityMQ.Update(ctx, id, change); err == nil {
|
func (vs *VideoService) UpdatePopularity(ctx context.Context, id uint, change int64) error {
|
||||||
return nil
|
if err := vs.repo.UpdatePopularity(ctx, id, change); err != nil {
|
||||||
}
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if vs.cache != nil {
|
if vs.popularityMQ != nil {
|
||||||
// 1) 详情缓存:直接失效(最简单靠谱)
|
if err := vs.popularityMQ.Update(ctx, id, change); err == nil {
|
||||||
_ = vs.cache.Del(context.Background(), fmt.Sprintf("video:detail:id=%d", id))
|
return nil
|
||||||
|
}
|
||||||
// 2) 热榜:写到“时间窗ZSET”,不要用 detail key
|
}
|
||||||
now := time.Now().UTC().Truncate(time.Minute)
|
|
||||||
windowKey := "hot:video:1m:" + now.Format("200601021504")
|
if vs.cache != nil {
|
||||||
member := strconv.FormatUint(uint64(id), 10)
|
// 1) 详情缓存:直接失效(最简单靠谱)
|
||||||
|
_ = vs.cache.Del(context.Background(), vs.cache.Key("video:detail:id=%d", id))
|
||||||
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
|
||||||
defer cancel()
|
// 2) 热榜:写到“时间窗ZSET”,不要用 detail key
|
||||||
|
now := time.Now().UTC().Truncate(time.Minute)
|
||||||
_ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change))
|
windowKey := vs.cache.Key("hot:video:1m:%s", now.Format("200601021504"))
|
||||||
_ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour)
|
member := strconv.FormatUint(uint64(id), 10)
|
||||||
}
|
|
||||||
return nil
|
opCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
}
|
defer cancel()
|
||||||
|
|
||||||
|
_ = vs.cache.ZincrBy(opCtx, windowKey, member, float64(change))
|
||||||
|
_ = vs.cache.Expire(opCtx, windowKey, 2*time.Hour)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,122 +1,128 @@
|
|||||||
package worker
|
package worker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
"feedsystem_video_go/internal/video"
|
"feedsystem_video_go/internal/video"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommentWorker struct {
|
type CommentWorker struct {
|
||||||
ch *amqp.Channel
|
ch *amqp.Channel
|
||||||
comments *video.CommentRepository
|
comments *video.CommentRepository
|
||||||
videos *video.VideoRepository
|
videos *video.VideoRepository
|
||||||
queue string
|
queue string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCommentWorker(ch *amqp.Channel, comments *video.CommentRepository, videos *video.VideoRepository, queue string) *CommentWorker {
|
func NewCommentWorker(ch *amqp.Channel, comments *video.CommentRepository, videos *video.VideoRepository, queue string) *CommentWorker {
|
||||||
return &CommentWorker{ch: ch, comments: comments, videos: videos, queue: queue}
|
return &CommentWorker{ch: ch, comments: comments, videos: videos, queue: queue}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *CommentWorker) Run(ctx context.Context) error {
|
func (w *CommentWorker) Run(ctx context.Context) error {
|
||||||
if w == nil || w.ch == nil || w.comments == nil || w.videos == nil {
|
if w == nil || w.ch == nil || w.comments == nil || w.videos == nil {
|
||||||
return errors.New("comment worker is not initialized")
|
return errors.New("comment worker is not initialized")
|
||||||
}
|
}
|
||||||
if w.queue == "" {
|
if w.queue == "" {
|
||||||
return errors.New("queue is required")
|
return errors.New("queue is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
deliveries, err := w.ch.Consume(
|
deliveries, err := w.ch.Consume(
|
||||||
w.queue,
|
w.queue,
|
||||||
"",
|
"",
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case d, ok := <-deliveries:
|
case d, ok := <-deliveries:
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("deliveries channel closed")
|
return errors.New("deliveries channel closed")
|
||||||
}
|
}
|
||||||
w.handleDelivery(ctx, d)
|
w.handleDelivery(ctx, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *CommentWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
func (w *CommentWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
||||||
if err := w.process(ctx, d.Body); err != nil {
|
if err := w.process(ctx, d.Body); err != nil {
|
||||||
log.Printf("comment worker: failed to process message: %v", err)
|
retryCount := rabbitmq.GetRetryCount(d)
|
||||||
_ = d.Nack(false, true)
|
if retryCount >= rabbitmq.MaxRetryCount {
|
||||||
return
|
log.Printf("comment worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
|
||||||
}
|
_ = d.Ack(false)
|
||||||
_ = d.Ack(false)
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("comment worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
|
||||||
func (w *CommentWorker) process(ctx context.Context, body []byte) error {
|
_ = d.Nack(false, true)
|
||||||
var evt rabbitmq.CommentEvent
|
return
|
||||||
if err := json.Unmarshal(body, &evt); err != nil {
|
}
|
||||||
return nil
|
_ = d.Ack(false)
|
||||||
}
|
}
|
||||||
switch evt.Action {
|
|
||||||
case "publish":
|
func (w *CommentWorker) process(ctx context.Context, body []byte) error {
|
||||||
return w.applyPublish(ctx, &evt)
|
var evt rabbitmq.CommentEvent
|
||||||
case "delete":
|
if err := json.Unmarshal(body, &evt); err != nil {
|
||||||
return w.applyDelete(ctx, &evt)
|
return nil
|
||||||
default:
|
}
|
||||||
return nil
|
switch evt.Action {
|
||||||
}
|
case "publish":
|
||||||
}
|
return w.applyPublish(ctx, &evt)
|
||||||
|
case "delete":
|
||||||
func (w *CommentWorker) applyPublish(ctx context.Context, evt *rabbitmq.CommentEvent) error {
|
return w.applyDelete(ctx, &evt)
|
||||||
if evt == nil || evt.VideoID == 0 || evt.AuthorID == 0 || strings.TrimSpace(evt.Content) == "" {
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
ok, err := w.videos.IsExist(ctx, evt.VideoID)
|
|
||||||
if err != nil {
|
func (w *CommentWorker) applyPublish(ctx context.Context, evt *rabbitmq.CommentEvent) error {
|
||||||
return err
|
if evt == nil || evt.VideoID == 0 || evt.AuthorID == 0 || strings.TrimSpace(evt.Content) == "" {
|
||||||
}
|
return nil
|
||||||
if !ok {
|
}
|
||||||
return nil
|
|
||||||
}
|
ok, err := w.videos.IsExist(ctx, evt.VideoID)
|
||||||
|
if err != nil {
|
||||||
c := &video.Comment{
|
return err
|
||||||
Username: strings.TrimSpace(evt.Username),
|
}
|
||||||
VideoID: evt.VideoID,
|
if !ok {
|
||||||
AuthorID: evt.AuthorID,
|
return nil
|
||||||
Content: strings.TrimSpace(evt.Content),
|
}
|
||||||
}
|
|
||||||
if err := w.comments.CreateComment(ctx, c); err != nil {
|
c := &video.Comment{
|
||||||
return err
|
Username: strings.TrimSpace(evt.Username),
|
||||||
}
|
VideoID: evt.VideoID,
|
||||||
return w.videos.ChangePopularity(ctx, evt.VideoID, 1)
|
AuthorID: evt.AuthorID,
|
||||||
}
|
Content: strings.TrimSpace(evt.Content),
|
||||||
|
}
|
||||||
func (w *CommentWorker) applyDelete(ctx context.Context, evt *rabbitmq.CommentEvent) error {
|
if err := w.comments.CreateComment(ctx, c); err != nil {
|
||||||
if evt == nil || evt.CommentID == 0 {
|
return err
|
||||||
return nil
|
}
|
||||||
}
|
return w.videos.ChangePopularity(ctx, evt.VideoID, 1)
|
||||||
c, err := w.comments.GetByID(ctx, evt.CommentID)
|
}
|
||||||
if err != nil {
|
|
||||||
return err
|
func (w *CommentWorker) applyDelete(ctx context.Context, evt *rabbitmq.CommentEvent) error {
|
||||||
}
|
if evt == nil || evt.CommentID == 0 {
|
||||||
if c == nil {
|
return nil
|
||||||
return nil
|
}
|
||||||
}
|
c, err := w.comments.GetByID(ctx, evt.CommentID)
|
||||||
return w.comments.DeleteComment(ctx, c)
|
if err != nil {
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return w.comments.DeleteComment(ctx, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,136 +1,142 @@
|
|||||||
package worker
|
package worker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
"feedsystem_video_go/internal/video"
|
"feedsystem_video_go/internal/video"
|
||||||
"log"
|
"log"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LikeWorker struct {
|
type LikeWorker struct {
|
||||||
ch *amqp.Channel
|
ch *amqp.Channel
|
||||||
likes *video.LikeRepository
|
likes *video.LikeRepository
|
||||||
videos *video.VideoRepository
|
videos *video.VideoRepository
|
||||||
queue string
|
queue string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLikeWorker(ch *amqp.Channel, likes *video.LikeRepository, videos *video.VideoRepository, queue string) *LikeWorker {
|
func NewLikeWorker(ch *amqp.Channel, likes *video.LikeRepository, videos *video.VideoRepository, queue string) *LikeWorker {
|
||||||
return &LikeWorker{ch: ch, likes: likes, videos: videos, queue: queue}
|
return &LikeWorker{ch: ch, likes: likes, videos: videos, queue: queue}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *LikeWorker) Run(ctx context.Context) error {
|
func (w *LikeWorker) Run(ctx context.Context) error {
|
||||||
if w == nil || w.ch == nil || w.likes == nil || w.videos == nil {
|
if w == nil || w.ch == nil || w.likes == nil || w.videos == nil {
|
||||||
return errors.New("like worker is not initialized")
|
return errors.New("like worker is not initialized")
|
||||||
}
|
}
|
||||||
if w.queue == "" {
|
if w.queue == "" {
|
||||||
return errors.New("queue is required")
|
return errors.New("queue is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
deliveries, err := w.ch.Consume(
|
deliveries, err := w.ch.Consume(
|
||||||
w.queue,
|
w.queue,
|
||||||
"",
|
"",
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case d, ok := <-deliveries:
|
case d, ok := <-deliveries:
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("deliveries channel closed")
|
return errors.New("deliveries channel closed")
|
||||||
}
|
}
|
||||||
w.handleDelivery(ctx, d)
|
w.handleDelivery(ctx, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
func (w *LikeWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
||||||
if err := w.process(ctx, d.Body); err != nil {
|
if err := w.process(ctx, d.Body); err != nil {
|
||||||
log.Printf("like worker: failed to process message: %v", err)
|
retryCount := rabbitmq.GetRetryCount(d)
|
||||||
_ = d.Nack(false, true)
|
if retryCount >= rabbitmq.MaxRetryCount {
|
||||||
return
|
log.Printf("like worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
|
||||||
}
|
_ = d.Ack(false)
|
||||||
_ = d.Ack(false)
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("like worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
|
||||||
func (w *LikeWorker) process(ctx context.Context, body []byte) error {
|
_ = d.Nack(false, true)
|
||||||
var evt rabbitmq.LikeEvent
|
return
|
||||||
if err := json.Unmarshal(body, &evt); err != nil {
|
}
|
||||||
// 解析事件失败,直接丢弃
|
_ = d.Ack(false)
|
||||||
return nil
|
}
|
||||||
}
|
|
||||||
if evt.UserID == 0 || evt.VideoID == 0 {
|
func (w *LikeWorker) process(ctx context.Context, body []byte) error {
|
||||||
return nil
|
var evt rabbitmq.LikeEvent
|
||||||
}
|
if err := json.Unmarshal(body, &evt); err != nil {
|
||||||
|
// 解析事件失败,直接丢弃
|
||||||
switch evt.Action {
|
return nil
|
||||||
case "like":
|
}
|
||||||
return w.applyLike(ctx, evt.UserID, evt.VideoID)
|
if evt.UserID == 0 || evt.VideoID == 0 {
|
||||||
case "unlike":
|
return nil
|
||||||
return w.applyUnlike(ctx, evt.UserID, evt.VideoID)
|
}
|
||||||
default:
|
|
||||||
return nil
|
switch evt.Action {
|
||||||
}
|
case "like":
|
||||||
}
|
return w.applyLike(ctx, evt.UserID, evt.VideoID)
|
||||||
|
case "unlike":
|
||||||
func (w *LikeWorker) applyLike(ctx context.Context, userID, videoID uint) error {
|
return w.applyUnlike(ctx, evt.UserID, evt.VideoID)
|
||||||
ok, err := w.videos.IsExist(ctx, videoID)
|
default:
|
||||||
if err != nil {
|
return nil
|
||||||
return err
|
}
|
||||||
}
|
}
|
||||||
if !ok {
|
|
||||||
return nil
|
func (w *LikeWorker) applyLike(ctx context.Context, userID, videoID uint) error {
|
||||||
}
|
ok, err := w.videos.IsExist(ctx, videoID)
|
||||||
|
if err != nil {
|
||||||
created, err := w.likes.LikeIgnoreDuplicate(ctx, &video.Like{
|
return err
|
||||||
VideoID: videoID,
|
}
|
||||||
AccountID: userID,
|
if !ok {
|
||||||
CreatedAt: time.Now(),
|
return nil
|
||||||
})
|
}
|
||||||
if err != nil {
|
|
||||||
return err
|
created, err := w.likes.LikeIgnoreDuplicate(ctx, &video.Like{
|
||||||
}
|
VideoID: videoID,
|
||||||
if !created {
|
AccountID: userID,
|
||||||
return nil
|
CreatedAt: time.Now(),
|
||||||
}
|
})
|
||||||
|
if err != nil {
|
||||||
if err := w.videos.ChangeLikesCount(ctx, videoID, 1); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
if !created {
|
||||||
return w.videos.ChangePopularity(ctx, videoID, 1)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *LikeWorker) applyUnlike(ctx context.Context, userID, videoID uint) error {
|
if err := w.videos.ChangeLikesCount(ctx, videoID, 1); err != nil {
|
||||||
ok, err := w.videos.IsExist(ctx, videoID)
|
return err
|
||||||
if err != nil {
|
}
|
||||||
return err
|
return w.videos.ChangePopularity(ctx, videoID, 1)
|
||||||
}
|
}
|
||||||
if !ok {
|
|
||||||
return nil
|
func (w *LikeWorker) applyUnlike(ctx context.Context, userID, videoID uint) error {
|
||||||
}
|
ok, err := w.videos.IsExist(ctx, videoID)
|
||||||
|
if err != nil {
|
||||||
deleted, err := w.likes.DeleteByVideoAndAccount(ctx, videoID, userID)
|
return err
|
||||||
if err != nil {
|
}
|
||||||
return err
|
if !ok {
|
||||||
}
|
return nil
|
||||||
if !deleted {
|
}
|
||||||
return nil
|
|
||||||
}
|
deleted, err := w.likes.DeleteByVideoAndAccount(ctx, videoID, userID)
|
||||||
|
if err != nil {
|
||||||
if err := w.videos.ChangeLikesCount(ctx, videoID, -1); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
if !deleted {
|
||||||
return w.videos.ChangePopularity(ctx, videoID, -1)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := w.videos.ChangeLikesCount(ctx, videoID, -1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.videos.ChangePopularity(ctx, videoID, -1)
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -1,93 +1,93 @@
|
|||||||
package worker
|
package worker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
"feedsystem_video_go/internal/middleware/redis"
|
"feedsystem_video_go/internal/middleware/redis"
|
||||||
"feedsystem_video_go/internal/video"
|
"feedsystem_video_go/internal/video"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
oredis "github.com/redis/go-redis/v9"
|
oredis "github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartOutboxPoller(db *gorm.DB, tmq *rabbitmq.TimelineMQ) {
|
func StartOutboxPoller(db *gorm.DB, tmq *rabbitmq.TimelineMQ) {
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
var messages []video.OutboxMsg
|
var messages []video.OutboxMsg
|
||||||
|
|
||||||
err := db.Where("status = ?", "pending").Order("create_time ASC").Limit(100).Find(&messages).Error
|
err := db.Where("status = ?", "pending").Order("create_time ASC").Limit(100).Find(&messages).Error
|
||||||
|
|
||||||
if err != nil || len(messages) == 0 {
|
if err != nil || len(messages) == 0 {
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
err := tmq.PublishVideo(context.Background(), msg.VideoID, msg.CreateTime)
|
err := tmq.PublishVideo(context.Background(), msg.VideoID, msg.CreateTime)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
db.Delete(&msg)
|
db.Delete(&msg)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("投递MQ失败: VideoID: %d, err: %v", msg.VideoID, err)
|
log.Printf("投递MQ失败: VideoID: %d, err: %v", msg.VideoID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartConsumer(tmq *rabbitmq.TimelineMQ, queueName string, redisClient *redis.Client) {
|
func StartConsumer(tmq *rabbitmq.TimelineMQ, queueName string, redisClient *redis.Client) {
|
||||||
msgs, err := tmq.Ch.Consume(
|
msgs, err := tmq.Ch.Consume(
|
||||||
queueName,
|
queueName,
|
||||||
"",
|
"",
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("注册消费失败")
|
log.Printf("注册消费失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for msg := range msgs {
|
for msg := range msgs {
|
||||||
var event rabbitmq.TimelineEvent
|
var event rabbitmq.TimelineEvent
|
||||||
err := json.Unmarshal(msg.Body, &event)
|
err := json.Unmarshal(msg.Body, &event)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("反序列化失败")
|
log.Printf("反序列化失败")
|
||||||
msg.Ack(false)
|
msg.Ack(false)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
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{
|
err = redisClient.ZAdd(ctx, timelineKey, oredis.Z{
|
||||||
Score: float64(event.CreateTime),
|
Score: float64(event.CreateTime),
|
||||||
Member: fmt.Sprintf("%d", event.VideoID),
|
Member: fmt.Sprintf("%d", event.VideoID),
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("写入Zset失败")
|
log.Printf("写入Zset失败")
|
||||||
msg.Nack(false, true)
|
msg.Nack(false, true)
|
||||||
cancel()
|
cancel()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = redisClient.ZRemRangeByRank(ctx, timelineKey, 0, -1001)
|
err = redisClient.ZRemRangeByRank(ctx, timelineKey, 0, -1001)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ZRem失败")
|
log.Printf("ZRem失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
msg.Ack(false)
|
msg.Ack(false)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,85 @@
|
|||||||
package worker
|
package worker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
rediscache "feedsystem_video_go/internal/middleware/redis"
|
rediscache "feedsystem_video_go/internal/middleware/redis"
|
||||||
"feedsystem_video_go/internal/video"
|
"feedsystem_video_go/internal/video"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PopularityWorker struct {
|
type PopularityWorker struct {
|
||||||
ch *amqp.Channel
|
ch *amqp.Channel
|
||||||
cache *rediscache.Client
|
cache *rediscache.Client
|
||||||
queue string
|
queue string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPopularityWorker(ch *amqp.Channel, cache *rediscache.Client, queue string) *PopularityWorker {
|
func NewPopularityWorker(ch *amqp.Channel, cache *rediscache.Client, queue string) *PopularityWorker {
|
||||||
return &PopularityWorker{ch: ch, cache: cache, queue: queue}
|
return &PopularityWorker{ch: ch, cache: cache, queue: queue}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *PopularityWorker) Run(ctx context.Context) error {
|
func (w *PopularityWorker) Run(ctx context.Context) error {
|
||||||
if w == nil || w.ch == nil || w.cache == nil {
|
if w == nil || w.ch == nil || w.cache == nil {
|
||||||
return errors.New("popularity worker is not initialized")
|
return errors.New("popularity worker is not initialized")
|
||||||
}
|
}
|
||||||
if w.queue == "" {
|
if w.queue == "" {
|
||||||
return errors.New("queue is required")
|
return errors.New("queue is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
deliveries, err := w.ch.Consume(
|
deliveries, err := w.ch.Consume(
|
||||||
w.queue,
|
w.queue,
|
||||||
"",
|
"",
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case d, ok := <-deliveries:
|
case d, ok := <-deliveries:
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("deliveries channel closed")
|
return errors.New("deliveries channel closed")
|
||||||
}
|
}
|
||||||
w.handleDelivery(ctx, d)
|
w.handleDelivery(ctx, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *PopularityWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
func (w *PopularityWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
||||||
if err := w.process(ctx, d.Body); err != nil {
|
if err := w.process(ctx, d.Body); err != nil {
|
||||||
log.Printf("popularity worker: failed to process message: %v", err)
|
retryCount := rabbitmq.GetRetryCount(d)
|
||||||
_ = d.Nack(false, true)
|
if retryCount >= rabbitmq.MaxRetryCount {
|
||||||
return
|
log.Printf("popularity worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
|
||||||
}
|
_ = d.Ack(false)
|
||||||
_ = d.Ack(false)
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("popularity worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
|
||||||
func (w *PopularityWorker) process(ctx context.Context, body []byte) error {
|
_ = d.Nack(false, true)
|
||||||
var evt rabbitmq.PopularityEvent
|
return
|
||||||
if err := json.Unmarshal(body, &evt); err != nil {
|
}
|
||||||
return nil
|
_ = d.Ack(false)
|
||||||
}
|
}
|
||||||
if evt.VideoID == 0 || evt.Change == 0 {
|
|
||||||
return nil
|
func (w *PopularityWorker) process(ctx context.Context, body []byte) error {
|
||||||
}
|
var evt rabbitmq.PopularityEvent
|
||||||
video.UpdatePopularityCache(ctx, w.cache, evt.VideoID, evt.Change)
|
if err := json.Unmarshal(body, &evt); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if evt.VideoID == 0 || evt.Change == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
video.UpdatePopularityCache(ctx, w.cache, evt.VideoID, evt.Change)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +1,106 @@
|
|||||||
package worker
|
package worker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"feedsystem_video_go/internal/middleware/rabbitmq"
|
"feedsystem_video_go/internal/middleware/rabbitmq"
|
||||||
"feedsystem_video_go/internal/social"
|
"feedsystem_video_go/internal/social"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SocialWorker struct {
|
type SocialWorker struct {
|
||||||
ch *amqp.Channel
|
ch *amqp.Channel
|
||||||
repo *social.SocialRepository
|
repo *social.SocialRepository
|
||||||
queue string
|
queue string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSocialWorker(ch *amqp.Channel, repo *social.SocialRepository, queue string) *SocialWorker {
|
func NewSocialWorker(ch *amqp.Channel, repo *social.SocialRepository, queue string) *SocialWorker {
|
||||||
return &SocialWorker{ch: ch, repo: repo, queue: queue}
|
return &SocialWorker{ch: ch, repo: repo, queue: queue}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *SocialWorker) Run(ctx context.Context) error {
|
func (w *SocialWorker) Run(ctx context.Context) error {
|
||||||
if w == nil || w.ch == nil || w.repo == nil {
|
if w == nil || w.ch == nil || w.repo == nil {
|
||||||
return errors.New("social worker is not initialized")
|
return errors.New("social worker is not initialized")
|
||||||
}
|
}
|
||||||
if w.queue == "" {
|
if w.queue == "" {
|
||||||
return errors.New("queue is required")
|
return errors.New("queue is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
deliveries, err := w.ch.Consume(
|
deliveries, err := w.ch.Consume(
|
||||||
w.queue,
|
w.queue,
|
||||||
"",
|
"",
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case d, ok := <-deliveries:
|
case d, ok := <-deliveries:
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("deliveries channel closed")
|
return errors.New("deliveries channel closed")
|
||||||
}
|
}
|
||||||
w.handleDelivery(ctx, d)
|
w.handleDelivery(ctx, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *SocialWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
func (w *SocialWorker) handleDelivery(ctx context.Context, d amqp.Delivery) {
|
||||||
if err := w.process(ctx, d.Body); err != nil {
|
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 {
|
||||||
_ = d.Nack(false, true)
|
log.Printf("social worker: max retries exceeded (%d), moving to DLX: %v", retryCount, err)
|
||||||
return
|
_ = d.Ack(false)
|
||||||
}
|
return
|
||||||
_ = d.Ack(false)
|
}
|
||||||
}
|
log.Printf("social worker: failed (retry %d/%d): %v", retryCount+1, rabbitmq.MaxRetryCount, err)
|
||||||
|
_ = d.Nack(false, true)
|
||||||
func (w *SocialWorker) process(ctx context.Context, body []byte) error {
|
return
|
||||||
var evt rabbitmq.SocialEvent
|
}
|
||||||
if err := json.Unmarshal(body, &evt); err != nil {
|
_ = d.Ack(false)
|
||||||
// 解析事件失败,直接丢弃
|
}
|
||||||
return nil
|
|
||||||
}
|
func (w *SocialWorker) process(ctx context.Context, body []byte) error {
|
||||||
if evt.FollowerID == 0 || evt.VloggerID == 0 {
|
var evt rabbitmq.SocialEvent
|
||||||
return nil
|
if err := json.Unmarshal(body, &evt); err != nil {
|
||||||
}
|
// 解析事件失败,直接丢弃
|
||||||
|
return nil
|
||||||
switch evt.Action {
|
}
|
||||||
case "follow":
|
if evt.FollowerID == 0 || evt.VloggerID == 0 {
|
||||||
err := w.repo.Follow(ctx, &social.Social{
|
return nil
|
||||||
FollowerID: evt.FollowerID,
|
}
|
||||||
VloggerID: evt.VloggerID,
|
|
||||||
})
|
switch evt.Action {
|
||||||
if err == nil {
|
case "follow":
|
||||||
return nil
|
err := w.repo.Follow(ctx, &social.Social{
|
||||||
}
|
FollowerID: evt.FollowerID,
|
||||||
var mysqlErr *mysql.MySQLError
|
VloggerID: evt.VloggerID,
|
||||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
})
|
||||||
return nil
|
if err == nil {
|
||||||
}
|
return nil
|
||||||
return err
|
}
|
||||||
case "unfollow":
|
var mysqlErr *mysql.MySQLError
|
||||||
return w.repo.Unfollow(ctx, &social.Social{
|
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||||
FollowerID: evt.FollowerID,
|
return nil
|
||||||
VloggerID: evt.VloggerID,
|
}
|
||||||
})
|
return err
|
||||||
default:
|
case "unfollow":
|
||||||
return nil
|
return w.repo.Unfollow(ctx, &social.Social{
|
||||||
}
|
FollowerID: evt.FollowerID,
|
||||||
}
|
VloggerID: evt.VloggerID,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
@@ -1,106 +1,125 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: "123456"
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456}
|
||||||
MYSQL_DATABASE: "feedsystem"
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-feedsystem}
|
||||||
TZ: "Asia/Shanghai"
|
TZ: "Asia/Shanghai"
|
||||||
ports:
|
ports:
|
||||||
- "3307:3306"
|
- "3307:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
command:
|
command:
|
||||||
- --default-authentication-plugin=mysql_native_password
|
- --default-authentication-plugin=mysql_native_password
|
||||||
- --character-set-server=utf8mb4
|
- --character-set-server=utf8mb4
|
||||||
- --collation-server=utf8mb4_0900_ai_ci
|
- --collation-server=utf8mb4_0900_ai_ci
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p123456 --silent"]
|
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p123456 --silent"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: always
|
restart: always
|
||||||
command: ["redis-server", "--appendonly", "yes", "--requirepass", "123456"]
|
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-123456}"]
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "-a", "123456", "ping"]
|
test: ["CMD", "redis-cli", "-a", "123456", "ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3-management
|
image: rabbitmq:3-management
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "5672:5672"
|
- "5672:5672"
|
||||||
- "15672:15672"
|
- "15672:15672"
|
||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_USER: admin
|
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin}
|
||||||
RABBITMQ_DEFAULT_PASS: password123
|
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-password123}
|
||||||
volumes:
|
volumes:
|
||||||
- rabbitmq_data:/var/lib/rabbitmq
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]
|
test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
target: api
|
target: api
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
environment:
|
||||||
- "8080:8080"
|
JWT_SECRET: ${JWT_SECRET:-feedsystem-dev-secret-key}
|
||||||
volumes:
|
ports:
|
||||||
- ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro
|
- "8080:8080"
|
||||||
- backend_uploads:/app/.run/uploads
|
volumes:
|
||||||
depends_on:
|
- ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro
|
||||||
mysql:
|
- backend_uploads:/app/.run/uploads
|
||||||
condition: service_healthy
|
depends_on:
|
||||||
redis:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
rabbitmq:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
rabbitmq:
|
||||||
worker:
|
condition: service_healthy
|
||||||
build:
|
healthcheck:
|
||||||
context: .
|
test: ["CMD-SHELL", "pgrep api || exit 1"]
|
||||||
dockerfile: backend/Dockerfile
|
interval: 10s
|
||||||
target: worker
|
timeout: 5s
|
||||||
restart: always
|
retries: 3
|
||||||
volumes:
|
|
||||||
- ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro
|
worker:
|
||||||
depends_on:
|
build:
|
||||||
mysql:
|
context: .
|
||||||
condition: service_healthy
|
dockerfile: backend/Dockerfile
|
||||||
redis:
|
target: worker
|
||||||
condition: service_healthy
|
restart: always
|
||||||
rabbitmq:
|
environment:
|
||||||
condition: service_healthy
|
JWT_SECRET: ${JWT_SECRET:-feedsystem-dev-secret-key}
|
||||||
|
volumes:
|
||||||
frontend:
|
- ./backend/configs/config.docker.yaml:/app/configs/config.yaml:ro
|
||||||
build:
|
depends_on:
|
||||||
context: .
|
mysql:
|
||||||
dockerfile: frontend/Dockerfile
|
condition: service_healthy
|
||||||
restart: always
|
redis:
|
||||||
ports:
|
condition: service_healthy
|
||||||
- "5173:80"
|
rabbitmq:
|
||||||
depends_on:
|
condition: service_healthy
|
||||||
- backend
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pgrep worker || exit 1"]
|
||||||
volumes:
|
interval: 15s
|
||||||
mysql_data:
|
timeout: 5s
|
||||||
redis_data:
|
retries: 3
|
||||||
rabbitmq_data:
|
|
||||||
backend_uploads:
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "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:
|
||||||
|
redis_data:
|
||||||
|
rabbitmq_data:
|
||||||
|
backend_uploads:
|
||||||
|
|
||||||
|
|||||||
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: 全部通过
|
||||||
6
frontend/src/App.vue
vendored
6
frontend/src/App.vue
vendored
@@ -1,3 +1,3 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,34 +1,48 @@
|
|||||||
import { postJson } from './client'
|
import { postForm, postJson } from './client'
|
||||||
import type { Account, MessageResponse, TokenResponse } from './types'
|
import type { Account, MessageResponse, TokenResponse } from './types'
|
||||||
|
|
||||||
export function register(username: string, password: string) {
|
export function register(username: string, password: string) {
|
||||||
return postJson<MessageResponse>('/account/register', { username, password })
|
return postJson<MessageResponse>('/account/register', { username, password })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(username: string, password: string) {
|
export function login(username: string, password: string) {
|
||||||
return postJson<TokenResponse>('/account/login', { username, password })
|
return postJson<TokenResponse>('/account/login', { username, password })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
return postJson<MessageResponse>('/account/logout', {}, { authRequired: true })
|
return postJson<MessageResponse>('/account/logout', {}, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rename(newUsername: string) {
|
export function rename(newUsername: string) {
|
||||||
return postJson<TokenResponse>('/account/rename', { new_username: newUsername }, { authRequired: true })
|
return postJson<TokenResponse>('/account/rename', { new_username: newUsername }, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changePassword(username: string, oldPassword: string, newPassword: string) {
|
export function changePassword(username: string, oldPassword: string, newPassword: string) {
|
||||||
return postJson<MessageResponse>('/account/changePassword', {
|
return postJson<MessageResponse>('/account/changePassword', {
|
||||||
username,
|
username,
|
||||||
old_password: oldPassword,
|
old_password: oldPassword,
|
||||||
new_password: newPassword,
|
new_password: newPassword,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findById(id: number) {
|
export function findById(id: number) {
|
||||||
return postJson<Account>('/account/findByID', { id })
|
return postJson<Account>('/account/findByID', { id })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findByUsername(username: string) {
|
export function findByUsername(username: string) {
|
||||||
return postJson<Account>('/account/findByUsername', { username })
|
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 { useAuthStore } from '../stores/auth'
|
||||||
|
import { reportError } from '../utils/error-reporter'
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number
|
status: number
|
||||||
@@ -16,6 +17,35 @@ type ApiErrorBody = { error?: string }
|
|||||||
|
|
||||||
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? '/api'
|
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> {
|
export async function postJson<T>(path: string, body: unknown, options?: { authRequired?: boolean }): Promise<T> {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const token = auth.token
|
const token = auth.token
|
||||||
@@ -33,28 +63,18 @@ export async function postJson<T>(path: string, body: unknown, options?: { authR
|
|||||||
body: JSON.stringify(body ?? {}),
|
body: JSON.stringify(body ?? {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const text = await res.text()
|
if (res.status === 401 && path !== '/account/refresh') {
|
||||||
let data: unknown = null
|
const newToken = await tryRefresh()
|
||||||
if (text) {
|
if (newToken) {
|
||||||
try {
|
headers.Authorization = `Bearer ${newToken}`
|
||||||
data = JSON.parse(text)
|
const retryRes = await fetch(`${API_BASE}${path}`, {
|
||||||
} catch {
|
method: 'POST', headers, body: JSON.stringify(body ?? {}),
|
||||||
data = text
|
})
|
||||||
|
return handleResponse<T>(retryRes, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
return handleResponse<T>(res, path)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postForm<T>(path: string, body: FormData, options?: { authRequired?: boolean }): Promise<T> {
|
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,
|
body,
|
||||||
})
|
})
|
||||||
|
|
||||||
const text = await res.text()
|
if (res.status === 401 && path !== '/account/refresh') {
|
||||||
let data: unknown = null
|
const newToken = await tryRefresh()
|
||||||
if (text) {
|
if (newToken) {
|
||||||
try {
|
headers.Authorization = `Bearer ${newToken}`
|
||||||
data = JSON.parse(text)
|
const retryRes = await fetch(`${API_BASE}${path}`, {
|
||||||
} catch {
|
method: 'POST', headers, body,
|
||||||
data = text
|
})
|
||||||
|
return handleResponse<T>(retryRes, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return handleResponse<T>(res, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) auth.clearTokens()
|
||||||
auth.clearToken()
|
const msg = data && typeof data === 'object' && (data as ApiErrorBody).error
|
||||||
}
|
? String((data as ApiErrorBody).error)
|
||||||
const msg =
|
: `请求失败 (${res.status})`
|
||||||
data && typeof data === 'object' && (data as ApiErrorBody).error
|
const apiErr = new ApiError(msg, res.status, data)
|
||||||
? String((data as ApiErrorBody).error)
|
reportError(apiErr, { path, status: res.status })
|
||||||
: `请求失败 (${res.status})`
|
throw apiErr
|
||||||
throw new ApiError(msg, res.status, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as T
|
return data as T
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { postJson } from './client'
|
import { postJson } from './client'
|
||||||
import { normalizeCommentList } from './normalize'
|
import { normalizeCommentList } from './normalize'
|
||||||
import type { Comment, MessageResponse } from './types'
|
import type { Comment, MessageResponse } from './types'
|
||||||
|
|
||||||
export async function listAll(videoId: number) {
|
export async function listAll(videoId: number) {
|
||||||
const comments = await postJson<Comment[] | null>('/comment/listAll', { video_id: videoId })
|
const comments = await postJson<Comment[] | null>('/comment/listAll', { video_id: videoId })
|
||||||
return normalizeCommentList(comments)
|
return normalizeCommentList(comments)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publish(videoId: number, content: string) {
|
export function publish(videoId: number, content: string) {
|
||||||
return postJson<MessageResponse>('/comment/publish', { video_id: videoId, content }, { authRequired: true })
|
return postJson<MessageResponse>('/comment/publish', { video_id: videoId, content }, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remove(commentId: number) {
|
export function remove(commentId: number) {
|
||||||
return postJson<MessageResponse>('/comment/delete', { comment_id: commentId }, { authRequired: true })
|
return postJson<MessageResponse>('/comment/delete', { comment_id: commentId }, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { postJson } from './client'
|
import { postJson } from './client'
|
||||||
import { normalizeFeedVideoList } from './normalize'
|
import { normalizeFeedVideoList } from './normalize'
|
||||||
import type { ListByFollowingResponse, ListByPopularityResponse, ListLatestResponse, ListLikesCountResponse } from './types'
|
import type { ListByFollowingResponse, ListByPopularityResponse, ListLatestResponse, ListLikesCountResponse } from './types'
|
||||||
|
|
||||||
export async function listLatest(input: { limit: number; latest_time: number }) {
|
export async function listLatest(input: { limit: number; latest_time: number }) {
|
||||||
const res = await postJson<ListLatestResponse>('/feed/listLatest', input)
|
const res = await postJson<ListLatestResponse>('/feed/listLatest', input)
|
||||||
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
|
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listLikesCount(input: { limit: number; likes_count_before?: number; id_before?: number }) {
|
export async function listLikesCount(input: { limit: number; likes_count_before?: number; id_before?: number }) {
|
||||||
const body: Record<string, unknown> = { limit: input.limit }
|
const body: Record<string, unknown> = { limit: input.limit }
|
||||||
if (typeof input.likes_count_before === 'number' || typeof input.id_before === 'number') {
|
if (typeof input.likes_count_before === 'number' || typeof input.id_before === 'number') {
|
||||||
body.likes_count_before = input.likes_count_before ?? 0
|
body.likes_count_before = input.likes_count_before ?? 0
|
||||||
body.id_before = input.id_before ?? 0
|
body.id_before = input.id_before ?? 0
|
||||||
}
|
}
|
||||||
const res = await postJson<ListLikesCountResponse>('/feed/listLikesCount', body)
|
const res = await postJson<ListLikesCountResponse>('/feed/listLikesCount', body)
|
||||||
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
|
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listByPopularity(input: { limit: number; as_of: number; offset: number }) {
|
export async function listByPopularity(input: { limit: number; as_of: number; offset: number }) {
|
||||||
const res = await postJson<ListByPopularityResponse>('/feed/listByPopularity', input)
|
const res = await postJson<ListByPopularityResponse>('/feed/listByPopularity', input)
|
||||||
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
|
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listByFollowing(input: { limit: number; latest_time: number }) {
|
export async function listByFollowing(input: { limit: number; latest_time: number }) {
|
||||||
const res = await postJson<ListByFollowingResponse>('/feed/listByFollowing', input, { authRequired: true })
|
const res = await postJson<ListByFollowingResponse>('/feed/listByFollowing', input, { authRequired: true })
|
||||||
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
|
return { ...res, video_list: normalizeFeedVideoList(res.video_list) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { postJson } from './client'
|
import { postJson } from './client'
|
||||||
import type { IsLikedResponse, MessageResponse, Video } from './types'
|
import type { IsLikedResponse, MessageResponse, Video } from './types'
|
||||||
|
|
||||||
export function like(videoId: number) {
|
export function like(videoId: number) {
|
||||||
return postJson<MessageResponse>('/like/like', { video_id: videoId }, { authRequired: true })
|
return postJson<MessageResponse>('/like/like', { video_id: videoId }, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unlike(videoId: number) {
|
export function unlike(videoId: number) {
|
||||||
return postJson<MessageResponse>('/like/unlike', { video_id: videoId }, { authRequired: true })
|
return postJson<MessageResponse>('/like/unlike', { video_id: videoId }, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLiked(videoId: number) {
|
export function isLiked(videoId: number) {
|
||||||
return postJson<IsLikedResponse>('/like/isLiked', { video_id: videoId }, { authRequired: true })
|
return postJson<IsLikedResponse>('/like/isLiked', { video_id: videoId }, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMyLikedVideos() {
|
export function listMyLikedVideos() {
|
||||||
return postJson<Video[]>('/like/listMyLikedVideos', {}, { authRequired: true })
|
return postJson<Video[]>('/like/listMyLikedVideos', {}, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,57 @@
|
|||||||
import type { Account, Comment, FeedAuthor, FeedVideoItem, Video } from './types'
|
import type { Account, Comment, FeedAuthor, FeedVideoItem, Video } from './types'
|
||||||
|
|
||||||
export function listOrEmpty<T>(value: T[] | null | undefined): T[] {
|
export function listOrEmpty<T>(value: T[] | null | undefined): T[] {
|
||||||
return Array.isArray(value) ? value : []
|
return Array.isArray(value) ? value : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAccount(value: Account | null | undefined): Account {
|
export function normalizeAccount(value: Account | null | undefined): Account {
|
||||||
return {
|
return {
|
||||||
id: Number(value?.id ?? 0),
|
id: Number(value?.id ?? 0),
|
||||||
username: value?.username || '匿名用户',
|
username: value?.username || '匿名用户',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAuthor(value: FeedAuthor | null | undefined): FeedAuthor {
|
function normalizeAuthor(value: FeedAuthor | null | undefined): FeedAuthor {
|
||||||
return {
|
return {
|
||||||
id: Number(value?.id ?? 0),
|
id: Number(value?.id ?? 0),
|
||||||
username: value?.username || '匿名用户',
|
username: value?.username || '匿名用户',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeFeedVideoItem(value: FeedVideoItem): FeedVideoItem {
|
export function normalizeFeedVideoItem(value: FeedVideoItem): FeedVideoItem {
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
author: normalizeAuthor(value.author),
|
author: normalizeAuthor(value.author),
|
||||||
title: value.title || '未命名视频',
|
title: value.title || '未命名视频',
|
||||||
description: value.description || '',
|
description: value.description || '',
|
||||||
play_url: value.play_url || '',
|
play_url: value.play_url || '',
|
||||||
cover_url: value.cover_url || '',
|
cover_url: value.cover_url || '',
|
||||||
create_time: Number(value.create_time ?? 0),
|
create_time: Number(value.create_time ?? 0),
|
||||||
likes_count: Number(value.likes_count ?? 0),
|
likes_count: Number(value.likes_count ?? 0),
|
||||||
is_liked: Boolean(value.is_liked),
|
is_liked: Boolean(value.is_liked),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeFeedVideoList(value: FeedVideoItem[] | null | undefined): FeedVideoItem[] {
|
export function normalizeFeedVideoList(value: FeedVideoItem[] | null | undefined): FeedVideoItem[] {
|
||||||
return listOrEmpty(value).map(normalizeFeedVideoItem)
|
return listOrEmpty(value).map(normalizeFeedVideoItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeVideoList(value: Video[] | null | undefined): Video[] {
|
export function normalizeVideoList(value: Video[] | null | undefined): Video[] {
|
||||||
return listOrEmpty(value).map((video) => ({
|
return listOrEmpty(value).map((video) => ({
|
||||||
...video,
|
...video,
|
||||||
username: video.username || '匿名用户',
|
username: video.username || '匿名用户',
|
||||||
title: video.title || '未命名视频',
|
title: video.title || '未命名视频',
|
||||||
description: video.description || '',
|
description: video.description || '',
|
||||||
play_url: video.play_url || '',
|
play_url: video.play_url || '',
|
||||||
cover_url: video.cover_url || '',
|
cover_url: video.cover_url || '',
|
||||||
likes_count: Number(video.likes_count ?? 0),
|
likes_count: Number(video.likes_count ?? 0),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCommentList(value: Comment[] | null | undefined): Comment[] {
|
export function normalizeCommentList(value: Comment[] | null | undefined): Comment[] {
|
||||||
return listOrEmpty(value).map((comment) => ({
|
return listOrEmpty(value).map((comment) => ({
|
||||||
...comment,
|
...comment,
|
||||||
username: comment.username || '匿名用户',
|
username: comment.username || '匿名用户',
|
||||||
content: comment.content || '',
|
content: comment.content || '',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import { postJson } from './client'
|
import { postJson } from './client'
|
||||||
import { listOrEmpty, normalizeAccount } from './normalize'
|
import { listOrEmpty, normalizeAccount } from './normalize'
|
||||||
import type { GetAllFollowersResponse, GetAllVloggersResponse, MessageResponse } from './types'
|
import type { GetAllFollowersResponse, GetAllVloggersResponse, MessageResponse } from './types'
|
||||||
|
|
||||||
export function follow(vloggerId: number) {
|
export function follow(vloggerId: number) {
|
||||||
return postJson<MessageResponse>('/social/follow', { vlogger_id: vloggerId }, { authRequired: true })
|
return postJson<MessageResponse>('/social/follow', { vlogger_id: vloggerId }, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unfollow(vloggerId: number) {
|
export function unfollow(vloggerId: number) {
|
||||||
return postJson<MessageResponse>('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true })
|
return postJson<MessageResponse>('/social/unfollow', { vlogger_id: vloggerId }, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllFollowers(vloggerId?: number) {
|
export async function getAllFollowers(vloggerId?: number) {
|
||||||
const res = await postJson<GetAllFollowersResponse>(
|
const res = await postJson<GetAllFollowersResponse>(
|
||||||
'/social/getAllFollowers',
|
'/social/getAllFollowers',
|
||||||
vloggerId ? { vlogger_id: vloggerId } : {},
|
vloggerId ? { vlogger_id: vloggerId } : {},
|
||||||
{ authRequired: true },
|
{ authRequired: true },
|
||||||
)
|
)
|
||||||
return { ...res, followers: listOrEmpty(res.followers).map(normalizeAccount) }
|
return { ...res, followers: listOrEmpty(res.followers).map(normalizeAccount) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllVloggers(followerId?: number) {
|
export async function getAllVloggers(followerId?: number) {
|
||||||
const res = await postJson<GetAllVloggersResponse>(
|
const res = await postJson<GetAllVloggersResponse>(
|
||||||
'/social/getAllVloggers',
|
'/social/getAllVloggers',
|
||||||
followerId ? { follower_id: followerId } : {},
|
followerId ? { follower_id: followerId } : {},
|
||||||
{ authRequired: true },
|
{ authRequired: true },
|
||||||
)
|
)
|
||||||
return { ...res, vloggers: listOrEmpty(res.vloggers).map(normalizeAccount) }
|
return { ...res, vloggers: listOrEmpty(res.vloggers).map(normalizeAccount) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,89 @@
|
|||||||
export type MessageResponse = { message: string }
|
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 = {
|
export type Account = {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
}
|
avatar_url?: string
|
||||||
|
bio?: string
|
||||||
export type Video = {
|
}
|
||||||
id: number
|
|
||||||
author_id: number
|
export type Video = {
|
||||||
username: string
|
id: number
|
||||||
title: string
|
author_id: number
|
||||||
description?: string
|
username: string
|
||||||
play_url: string
|
title: string
|
||||||
cover_url: string
|
description?: string
|
||||||
create_time: string
|
play_url: string
|
||||||
likes_count: number
|
cover_url: string
|
||||||
}
|
create_time: string
|
||||||
|
likes_count: number
|
||||||
export type Comment = {
|
}
|
||||||
id: number
|
|
||||||
username: string
|
export type Comment = {
|
||||||
video_id: number
|
id: number
|
||||||
author_id: number
|
username: string
|
||||||
content: string
|
video_id: number
|
||||||
created_at: string
|
author_id: number
|
||||||
}
|
content: string
|
||||||
|
created_at: string
|
||||||
export type FeedAuthor = {
|
}
|
||||||
id: number
|
|
||||||
username: string
|
export type FeedAuthor = {
|
||||||
}
|
id: number
|
||||||
|
username: string
|
||||||
export type FeedVideoItem = {
|
}
|
||||||
id: number
|
|
||||||
author: FeedAuthor
|
export type FeedVideoItem = {
|
||||||
title: string
|
id: number
|
||||||
description?: string
|
author: FeedAuthor
|
||||||
play_url: string
|
title: string
|
||||||
cover_url: string
|
description?: string
|
||||||
create_time: number
|
play_url: string
|
||||||
likes_count: number
|
cover_url: string
|
||||||
is_liked: boolean
|
create_time: number
|
||||||
}
|
likes_count: number
|
||||||
|
is_liked: boolean
|
||||||
export type ListLatestResponse = {
|
}
|
||||||
video_list: FeedVideoItem[]
|
|
||||||
next_time: number
|
export type ListLatestResponse = {
|
||||||
has_more: boolean
|
video_list: FeedVideoItem[]
|
||||||
}
|
next_time: number
|
||||||
|
has_more: boolean
|
||||||
export type ListLikesCountResponse = {
|
}
|
||||||
video_list: FeedVideoItem[]
|
|
||||||
next_likes_count_before?: number
|
export type ListLikesCountResponse = {
|
||||||
next_id_before?: number
|
video_list: FeedVideoItem[]
|
||||||
has_more: boolean
|
next_likes_count_before?: number
|
||||||
}
|
next_id_before?: number
|
||||||
|
has_more: boolean
|
||||||
export type ListByPopularityResponse = {
|
}
|
||||||
video_list: FeedVideoItem[]
|
|
||||||
as_of: number
|
export type ListByPopularityResponse = {
|
||||||
next_offset: number
|
video_list: FeedVideoItem[]
|
||||||
has_more: boolean
|
as_of: number
|
||||||
next_latest_popularity?: number
|
next_offset: number
|
||||||
next_latest_before?: string
|
has_more: boolean
|
||||||
next_latest_id_before?: number
|
next_latest_popularity?: number
|
||||||
}
|
next_latest_before?: string
|
||||||
|
next_latest_id_before?: number
|
||||||
export type ListByFollowingResponse = {
|
}
|
||||||
video_list: FeedVideoItem[]
|
|
||||||
next_time: number
|
export type ListByFollowingResponse = {
|
||||||
has_more: boolean
|
video_list: FeedVideoItem[]
|
||||||
}
|
next_time: number
|
||||||
|
has_more: boolean
|
||||||
export type IsLikedResponse = {
|
}
|
||||||
is_liked: boolean
|
|
||||||
}
|
export type IsLikedResponse = {
|
||||||
|
is_liked: boolean
|
||||||
export type GetAllFollowersResponse = {
|
}
|
||||||
followers: Account[]
|
|
||||||
}
|
export type GetAllFollowersResponse = {
|
||||||
|
followers: Account[]
|
||||||
export type GetAllVloggersResponse = {
|
}
|
||||||
vloggers: Account[]
|
|
||||||
}
|
export type GetAllVloggersResponse = {
|
||||||
|
vloggers: Account[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import { postForm, postJson } from './client'
|
import { postForm, postJson } from './client'
|
||||||
import { normalizeVideoList } from './normalize'
|
import { normalizeVideoList } from './normalize'
|
||||||
import type { Video } from './types'
|
import type { Video } from './types'
|
||||||
|
|
||||||
export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) {
|
export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) {
|
||||||
return postJson<Video>('/video/publish', input, { authRequired: true })
|
return postJson<Video>('/video/publish', input, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UploadResponse = { url: string; play_url?: string; cover_url?: string }
|
export type UploadResponse = { url: string; play_url?: string; cover_url?: string }
|
||||||
|
|
||||||
export function uploadVideo(file: File) {
|
export function uploadVideo(file: File) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
return postForm<UploadResponse>('/video/uploadVideo', fd, { authRequired: true })
|
return postForm<UploadResponse>('/video/uploadVideo', fd, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadCover(file: File) {
|
export function uploadCover(file: File) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
return postForm<UploadResponse>('/video/uploadCover', fd, { authRequired: true })
|
return postForm<UploadResponse>('/video/uploadCover', fd, { authRequired: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listByAuthorId(authorId: number) {
|
export async function listByAuthorId(authorId: number) {
|
||||||
const videos = await postJson<Video[] | null>('/video/listByAuthorID', { author_id: authorId })
|
const videos = await postJson<Video[] | null>('/video/listByAuthorID', { author_id: authorId })
|
||||||
return normalizeVideoList(videos)
|
return normalizeVideoList(videos)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDetail(id: number) {
|
export function getDetail(id: number) {
|
||||||
return postJson<Video>('/video/getDetail', { id })
|
return postJson<Video>('/video/getDetail', { id })
|
||||||
}
|
}
|
||||||
|
|||||||
644
frontend/src/components/AppShell.vue
vendored
644
frontend/src/components/AppShell.vue
vendored
@@ -1,322 +1,322 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useSocialStore } from '../stores/social'
|
import { useSocialStore } from '../stores/social'
|
||||||
import Toaster from './Toaster.vue'
|
import Toaster from './Toaster.vue'
|
||||||
|
|
||||||
const props = defineProps<{ full?: boolean }>()
|
const props = defineProps<{ full?: boolean }>()
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const social = useSocialStore()
|
const social = useSocialStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const search = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
const search = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
||||||
watch(
|
watch(
|
||||||
() => route.query.q,
|
() => route.query.q,
|
||||||
(v) => {
|
(v) => {
|
||||||
search.value = typeof v === 'string' ? v : ''
|
search.value = typeof v === 'string' ? v : ''
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => auth.isLoggedIn,
|
() => auth.isLoggedIn,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v) void social.refreshMine()
|
if (v) void social.refreshMine()
|
||||||
else social.clear()
|
else social.clear()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const userLabel = computed(() => {
|
const userLabel = computed(() => {
|
||||||
if (!auth.isLoggedIn) return '未登录'
|
if (!auth.isLoggedIn) return '未登录'
|
||||||
const username = auth.claims?.username ?? '(unknown)'
|
const username = auth.claims?.username ?? '(unknown)'
|
||||||
const accountId = auth.claims?.account_id
|
const accountId = auth.claims?.account_id
|
||||||
return accountId ? `${username} #${accountId}` : username
|
return accountId ? `${username} #${accountId}` : username
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onSearch() {
|
async function onSearch() {
|
||||||
const q = search.value.trim()
|
const q = search.value.trim()
|
||||||
await router.push({ path: '/', query: q ? { q } : {} })
|
await router.push({ path: '/', query: q ? { q } : {} })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goLogin() {
|
async function goLogin() {
|
||||||
await router.push('/account')
|
await router.push('/account')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goSettings() {
|
async function goSettings() {
|
||||||
await router.push('/settings')
|
await router.push('/settings')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dy-shell">
|
<div class="dy-shell">
|
||||||
<aside class="dy-aside">
|
<aside class="dy-aside">
|
||||||
<RouterLink class="dy-logo" to="/">ShortVideo</RouterLink>
|
<RouterLink class="dy-logo" to="/">ShortVideo</RouterLink>
|
||||||
|
|
||||||
<nav class="dy-nav">
|
<nav class="dy-nav">
|
||||||
<RouterLink class="dy-nav-link" to="/">推荐</RouterLink>
|
<RouterLink class="dy-nav-link" to="/">推荐</RouterLink>
|
||||||
<RouterLink class="dy-nav-link" to="/hot">热榜</RouterLink>
|
<RouterLink class="dy-nav-link" to="/hot">热榜</RouterLink>
|
||||||
<RouterLink class="dy-nav-link" to="/video">发布</RouterLink>
|
<RouterLink class="dy-nav-link" to="/video">发布</RouterLink>
|
||||||
<RouterLink class="dy-nav-link" to="/account">账号</RouterLink>
|
<RouterLink class="dy-nav-link" to="/account">账号</RouterLink>
|
||||||
<RouterLink class="dy-nav-link" to="/settings">设置</RouterLink>
|
<RouterLink class="dy-nav-link" to="/settings">设置</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="dy-aside-foot">
|
<div class="dy-aside-foot">
|
||||||
<div class="dy-user">
|
<div class="dy-user">
|
||||||
<span class="dy-user-dot" :class="auth.isLoggedIn ? 'ok' : 'bad'" />
|
<span class="dy-user-dot" :class="auth.isLoggedIn ? 'ok' : 'bad'" />
|
||||||
<span class="dy-user-name">{{ userLabel }}</span>
|
<span class="dy-user-name">{{ userLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dy-user-actions">
|
<div class="dy-user-actions">
|
||||||
<button v-if="!auth.isLoggedIn" class="dy-btn dy-btn-primary" type="button" @click="goLogin">登录</button>
|
<button v-if="!auth.isLoggedIn" class="dy-btn dy-btn-primary" type="button" @click="goLogin">登录</button>
|
||||||
<button v-else class="dy-btn dy-btn-primary" type="button" @click="goSettings">设置</button>
|
<button v-else class="dy-btn dy-btn-primary" type="button" @click="goSettings">设置</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="dy-main">
|
<div class="dy-main">
|
||||||
<header class="dy-topbar">
|
<header class="dy-topbar">
|
||||||
<div class="dy-top-left">
|
<div class="dy-top-left">
|
||||||
<div class="dy-tabs-hint">{{ route.name }}</div>
|
<div class="dy-tabs-hint">{{ route.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dy-search">
|
<div class="dy-search">
|
||||||
<input v-model="search" class="dy-search-input" placeholder="搜索标题 / 作者(本地过滤)" @keydown.enter="onSearch" />
|
<input v-model="search" class="dy-search-input" placeholder="搜索标题 / 作者(本地过滤)" @keydown.enter="onSearch" />
|
||||||
<button class="dy-btn dy-btn-primary" type="button" @click="onSearch">搜索</button>
|
<button class="dy-btn dy-btn-primary" type="button" @click="onSearch">搜索</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dy-top-right">
|
<div class="dy-top-right">
|
||||||
<RouterLink class="dy-btn dy-btn-ghost" to="/video">+ 发布视频</RouterLink>
|
<RouterLink class="dy-btn dy-btn-ghost" to="/video">+ 发布视频</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="dy-content" :class="props.full ? 'full' : 'padded'">
|
<div class="dy-content" :class="props.full ? 'full' : 'padded'">
|
||||||
<template v-if="props.full">
|
<template v-if="props.full">
|
||||||
<slot />
|
<slot />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dy-shell {
|
.dy-shell {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 240px 1fr;
|
grid-template-columns: 240px 1fr;
|
||||||
background: radial-gradient(1200px 900px at 20% -25%, rgba(254, 44, 85, 0.18), transparent 60%),
|
background: radial-gradient(1200px 900px at 20% -25%, rgba(254, 44, 85, 0.18), transparent 60%),
|
||||||
radial-gradient(900px 700px at 90% 10%, rgba(37, 244, 238, 0.12), transparent 55%), transparent;
|
radial-gradient(900px 700px at 90% 10%, rgba(37, 244, 238, 0.12), transparent 55%), transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-aside {
|
.dy-aside {
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.35);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
padding: 14px 12px;
|
padding: 14px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-logo {
|
.dy-logo {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
padding: 10px 10px;
|
padding: 10px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-nav {
|
.dy-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-nav-link {
|
.dy-nav-link {
|
||||||
padding: 10px 10px;
|
padding: 10px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-nav-link.router-link-active {
|
.dy-nav-link.router-link-active {
|
||||||
border-color: rgba(254, 44, 85, 0.42);
|
border-color: rgba(254, 44, 85, 0.42);
|
||||||
background: rgba(254, 44, 85, 0.12);
|
background: rgba(254, 44, 85, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-aside-foot {
|
.dy-aside-foot {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-user {
|
.dy-user {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-user-dot {
|
.dy-user-dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-user-dot.ok {
|
.dy-user-dot.ok {
|
||||||
background: rgba(34, 197, 94, 1);
|
background: rgba(34, 197, 94, 1);
|
||||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14);
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-user-dot.bad {
|
.dy-user-dot.bad {
|
||||||
background: rgba(254, 44, 85, 1);
|
background: rgba(254, 44, 85, 1);
|
||||||
box-shadow: 0 0 0 3px rgba(254, 44, 85, 0.14);
|
box-shadow: 0 0 0 3px rgba(254, 44, 85, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-user-name {
|
.dy-user-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(255, 255, 255, 0.86);
|
color: rgba(255, 255, 255, 0.86);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-user-actions {
|
.dy-user-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-btn {
|
.dy-btn {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-btn:hover {
|
.dy-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-btn-primary {
|
.dy-btn-primary {
|
||||||
border-color: rgba(254, 44, 85, 0.5);
|
border-color: rgba(254, 44, 85, 0.5);
|
||||||
background: rgba(254, 44, 85, 0.16);
|
background: rgba(254, 44, 85, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-btn-primary:hover {
|
.dy-btn-primary:hover {
|
||||||
background: rgba(254, 44, 85, 0.24);
|
background: rgba(254, 44, 85, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-btn-ghost {
|
.dy-btn-ghost {
|
||||||
border-color: rgba(255, 255, 255, 0.14);
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-main {
|
.dy-main {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-topbar {
|
.dy-topbar {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(0, 0, 0, 0.28);
|
background: rgba(0, 0, 0, 0.28);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 180px 1fr 180px;
|
grid-template-columns: 180px 1fr 180px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-tabs-hint {
|
.dy-tabs-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.55);
|
color: rgba(255, 255, 255, 0.55);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-search {
|
.dy-search {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-search-input {
|
.dy-search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-search-input:focus {
|
.dy-search-input:focus {
|
||||||
border-color: rgba(37, 244, 238, 0.42);
|
border-color: rgba(37, 244, 238, 0.42);
|
||||||
box-shadow: 0 0 0 3px rgba(37, 244, 238, 0.14);
|
box-shadow: 0 0 0 3px rgba(37, 244, 238, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-top-right {
|
.dy-top-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-content {
|
.dy-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-content.padded {
|
.dy-content.padded {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dy-content.full {
|
.dy-content.full {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.dy-shell {
|
.dy-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.dy-aside {
|
.dy-aside {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.dy-topbar {
|
.dy-topbar {
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
}
|
}
|
||||||
.dy-top-left {
|
.dy-top-left {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.dy-top-right {
|
.dy-top-right {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
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>
|
||||||
178
frontend/src/components/FeedVideoCard.vue
vendored
178
frontend/src/components/FeedVideoCard.vue
vendored
@@ -1,89 +1,89 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FeedVideoItem } from '../api/types'
|
import type { FeedVideoItem } from '../api/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
item: FeedVideoItem
|
item: FeedVideoItem
|
||||||
canLike: boolean
|
canLike: boolean
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'toggle-like', item: FeedVideoItem): void
|
(e: 'toggle-like', item: FeedVideoItem): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function onToggle() {
|
function onToggle() {
|
||||||
emit('toggle-like', props.item)
|
emit('toggle-like', props.item)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="feed-card">
|
<div class="feed-card">
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<img :src="item.cover_url" :alt="item.title" loading="lazy" />
|
<img :src="item.cover_url" :alt="item.title" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<RouterLink :to="`/video/${item.id}`">{{ item.title }}</RouterLink>
|
<RouterLink :to="`/video/${item.id}`">{{ item.title }}</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="subtle">
|
<div class="subtle">
|
||||||
作者:{{ item.author.username }} (#{{ item.author.id }}) · 创建时间:{{ new Date(item.create_time * 1000).toLocaleString() }}
|
作者:{{ item.author.username }} (#{{ item.author.id }}) · 创建时间:{{ new Date(item.create_time * 1000).toLocaleString() }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="pill mono">❤️ {{ item.likes_count }}</span>
|
<span class="pill mono">❤️ {{ item.likes_count }}</span>
|
||||||
<button
|
<button
|
||||||
v-if="canLike"
|
v-if="canLike"
|
||||||
class="primary"
|
class="primary"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@click="onToggle"
|
@click="onToggle"
|
||||||
:title="item.is_liked ? '取消点赞' : '点赞'"
|
:title="item.is_liked ? '取消点赞' : '点赞'"
|
||||||
>
|
>
|
||||||
{{ item.is_liked ? '已赞' : '点赞' }}
|
{{ item.is_liked ? '已赞' : '点赞' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.description" class="muted" style="margin-top: 8px">{{ item.description }}</div>
|
<div v-if="item.description" class="muted" style="margin-top: 8px">{{ item.description }}</div>
|
||||||
<div class="row" style="margin-top: 10px">
|
<div class="row" style="margin-top: 10px">
|
||||||
<a class="pill mono" :href="item.play_url" target="_blank" rel="noreferrer">播放地址</a>
|
<a class="pill mono" :href="item.play_url" target="_blank" rel="noreferrer">播放地址</a>
|
||||||
<RouterLink class="pill" :to="`/video/${item.id}`">查看详情 / 评论</RouterLink>
|
<RouterLink class="pill" :to="`/video/${item.id}`">查看详情 / 评论</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.feed-card {
|
.feed-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 240px minmax(0, 1fr);
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover {
|
.cover {
|
||||||
background: rgba(0, 0, 0, 0.25);
|
background: rgba(0, 0, 0, 0.25);
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover img {
|
.cover img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 12px 12px 14px;
|
padding: 12px 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.feed-card {
|
.feed-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
82
frontend/src/components/HelloWorld.vue
vendored
82
frontend/src/components/HelloWorld.vue
vendored
@@ -1,41 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
defineProps<{ msg: string }>()
|
defineProps<{ msg: string }>()
|
||||||
|
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>{{ msg }}</h1>
|
<h1>{{ msg }}</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<button type="button" @click="count++">count is {{ count }}</button>
|
<button type="button" @click="count++">count is {{ count }}</button>
|
||||||
<p>
|
<p>
|
||||||
Edit
|
Edit
|
||||||
<code>components/HelloWorld.vue</code> to test HMR
|
<code>components/HelloWorld.vue</code> to test HMR
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Check out
|
Check out
|
||||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||||
>create-vue</a
|
>create-vue</a
|
||||||
>, the official Vue + Vite starter
|
>, the official Vue + Vite starter
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Learn more about IDE Support for Vue in the
|
Learn more about IDE Support for Vue in the
|
||||||
<a
|
<a
|
||||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>Vue Docs Scaling up Guide</a
|
>Vue Docs Scaling up Guide</a
|
||||||
>.
|
>.
|
||||||
</p>
|
</p>
|
||||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.read-the-docs {
|
.read-the-docs {
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
34
frontend/src/components/JsonBox.vue
vendored
34
frontend/src/components/JsonBox.vue
vendored
@@ -1,17 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ value: unknown }>()
|
const props = defineProps<{ value: unknown }>()
|
||||||
|
|
||||||
const text = computed(() => {
|
const text = computed(() => {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(props.value, null, 2)
|
return JSON.stringify(props.value, null, 2)
|
||||||
} catch {
|
} catch {
|
||||||
return String(props.value)
|
return String(props.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<pre class="pre mono">{{ text }}</pre>
|
<pre class="pre mono">{{ text }}</pre>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
146
frontend/src/components/Toaster.vue
vendored
146
frontend/src/components/Toaster.vue
vendored
@@ -1,73 +1,73 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="toast-wrap" aria-live="polite" aria-relevant="additions removals">
|
<div class="toast-wrap" aria-live="polite" aria-relevant="additions removals">
|
||||||
<div v-for="t in toast.toasts" :key="t.id" class="toast" :class="t.type">
|
<div v-for="t in toast.toasts" :key="t.id" class="toast" :class="t.type">
|
||||||
<div class="toast-msg">{{ t.message }}</div>
|
<div class="toast-msg">{{ t.message }}</div>
|
||||||
<button class="toast-x" type="button" aria-label="关闭" @click="toast.remove(t.id)">×</button>
|
<button class="toast-x" type="button" aria-label="关闭" @click="toast.remove(t.id)">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.toast-wrap {
|
.toast-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
width: min(520px, calc(100vw - 24px));
|
width: min(520px, calc(100vw - 24px));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.success {
|
.toast.success {
|
||||||
border-color: rgba(34, 197, 94, 0.35);
|
border-color: rgba(34, 197, 94, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.error {
|
.toast.error {
|
||||||
border-color: rgba(254, 44, 85, 0.45);
|
border-color: rgba(254, 44, 85, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.info {
|
.toast.info {
|
||||||
border-color: rgba(37, 244, 238, 0.3);
|
border-color: rgba(37, 244, 238, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-msg {
|
.toast-msg {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-x {
|
.toast-x {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
color: rgba(255, 255, 255, 0.88);
|
color: rgba(255, 255, 255, 0.88);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
111
frontend/src/components/UserAvatar.vue
vendored
111
frontend/src/components/UserAvatar.vue
vendored
@@ -1,54 +1,57 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
username: string
|
username: string
|
||||||
id?: number
|
id?: number
|
||||||
size?: number
|
size?: number
|
||||||
}>()
|
src?: string
|
||||||
|
}>()
|
||||||
function hashToHue(input: string) {
|
|
||||||
let h = 0
|
function hashToHue(input: string) {
|
||||||
for (let i = 0; i < input.length; i += 1) {
|
let h = 0
|
||||||
h = (h * 31 + input.charCodeAt(i)) >>> 0
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
}
|
h = (h * 31 + input.charCodeAt(i)) >>> 0
|
||||||
return h % 360
|
}
|
||||||
}
|
return h % 360
|
||||||
|
}
|
||||||
const initial = computed(() => {
|
|
||||||
const s = (props.username ?? '').trim()
|
const initial = computed(() => {
|
||||||
if (!s) return '?'
|
const s = (props.username ?? '').trim()
|
||||||
return s.slice(0, 1).toUpperCase()
|
if (!s) return '?'
|
||||||
})
|
return s.slice(0, 1).toUpperCase()
|
||||||
|
})
|
||||||
const sizePx = computed(() => `${props.size ?? 40}px`)
|
|
||||||
|
const sizePx = computed(() => `${props.size ?? 40}px`)
|
||||||
const bg = computed(() => {
|
|
||||||
const seed = typeof props.id === 'number' ? String(props.id) : props.username
|
const bg = computed(() => {
|
||||||
const hue = hashToHue(seed || '0')
|
const seed = typeof props.id === 'number' ? String(props.id) : props.username
|
||||||
const h1 = hue
|
const hue = hashToHue(seed || '0')
|
||||||
const h2 = (hue + 40) % 360
|
const h1 = hue
|
||||||
return `linear-gradient(135deg, hsl(${h1} 90% 55%), hsl(${h2} 90% 55%))`
|
const h2 = (hue + 40) % 360
|
||||||
})
|
return `linear-gradient(135deg, hsl(${h1} 90% 55%), hsl(${h2} 90% 55%))`
|
||||||
</script>
|
})
|
||||||
|
</script>
|
||||||
<template>
|
|
||||||
<div class="avatar" :style="{ width: sizePx, height: sizePx, backgroundImage: bg }" aria-hidden="true">
|
<template>
|
||||||
{{ initial }}
|
<img v-if="src" :src="src" class="avatar" :style="{ width: sizePx, height: sizePx }" alt="" />
|
||||||
</div>
|
<div v-else class="avatar" :style="{ width: sizePx, height: sizePx, backgroundImage: bg }" aria-hidden="true">
|
||||||
</template>
|
{{ initial }}
|
||||||
|
</div>
|
||||||
<style scoped>
|
</template>
|
||||||
.avatar {
|
|
||||||
display: grid;
|
<style scoped>
|
||||||
place-items: center;
|
.avatar {
|
||||||
border-radius: 999px;
|
display: grid;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
place-items: center;
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
|
border-radius: 999px;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
font-weight: 900;
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
|
||||||
letter-spacing: 0.2px;
|
color: rgba(255, 255, 255, 0.92);
|
||||||
user-select: none;
|
font-weight: 900;
|
||||||
}
|
letter-spacing: 0.2px;
|
||||||
</style>
|
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 }
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { reportError } from './utils/error-reporter'
|
||||||
const app = createApp(App)
|
|
||||||
app.use(createPinia())
|
const app = createApp(App)
|
||||||
app.use(router)
|
app.use(createPinia())
|
||||||
app.mount('#app')
|
app.use(router)
|
||||||
|
|
||||||
|
app.config.errorHandler = (err, _instance, info) => {
|
||||||
|
reportError(err instanceof Error ? err : new Error(String(err)), { info })
|
||||||
|
}
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
|||||||
@@ -1,29 +1,39 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
import HotView from '../views/HotView.vue'
|
import HotView from '../views/HotView.vue'
|
||||||
import VideoView from '../views/VideoView.vue'
|
import VideoView from '../views/VideoView.vue'
|
||||||
import VideoDetailView from '../views/VideoDetailView.vue'
|
import VideoDetailView from '../views/VideoDetailView.vue'
|
||||||
import AccountView from '../views/AccountView.vue'
|
import AccountView from '../views/AccountView.vue'
|
||||||
import ChangePasswordView from '../views/ChangePasswordView.vue'
|
import ChangePasswordView from '../views/ChangePasswordView.vue'
|
||||||
import RegisterView from '../views/RegisterView.vue'
|
import RegisterView from '../views/RegisterView.vue'
|
||||||
import SettingsView from '../views/SettingsView.vue'
|
import SettingsView from '../views/SettingsView.vue'
|
||||||
import UserProfileView from '../views/UserProfileView.vue'
|
import UserProfileView from '../views/UserProfileView.vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(),
|
const router = createRouter({
|
||||||
routes: [
|
history: createWebHistory(),
|
||||||
{ path: '/', name: 'home', component: HomeView },
|
routes: [
|
||||||
{ path: '/feed', redirect: '/' },
|
{ path: '/', name: 'home', component: HomeView },
|
||||||
{ path: '/hot', name: 'hot', component: HotView },
|
{ path: '/feed', redirect: '/' },
|
||||||
{ path: '/video', name: 'video', component: VideoView },
|
{ path: '/hot', name: 'hot', component: HotView },
|
||||||
{ path: '/video/:id', name: 'video-detail', component: VideoDetailView, props: true },
|
{ path: '/video', name: 'video', component: VideoView, meta: { requiresAuth: true } },
|
||||||
{ path: '/account', name: 'account', component: AccountView },
|
{ path: '/video/:id', name: 'video-detail', component: VideoDetailView, props: true },
|
||||||
{ path: '/account/register', name: 'account-register', component: RegisterView },
|
{ path: '/account', name: 'account', component: AccountView },
|
||||||
{ path: '/account/change-password', name: 'account-change-password', component: ChangePasswordView },
|
{ path: '/account/register', name: 'account-register', component: RegisterView },
|
||||||
{ path: '/settings', name: 'settings', component: SettingsView },
|
{ path: '/account/change-password', name: 'account-change-password', component: ChangePasswordView },
|
||||||
{ path: '/u/:id', name: 'user-profile', component: UserProfileView, props: true },
|
{ path: '/settings', name: 'settings', component: SettingsView, meta: { requiresAuth: true } },
|
||||||
],
|
{ path: '/u/:id', name: 'user-profile', component: UserProfileView, props: true },
|
||||||
})
|
],
|
||||||
|
})
|
||||||
export default router
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,45 +1,48 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { decodeJwtPayload, type JwtPayload } from '../utils/jwt'
|
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 {
|
function readStored(key: string): string | null {
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
try { return localStorage.getItem(key) } catch { return null }
|
||||||
} catch {
|
}
|
||||||
return null
|
|
||||||
}
|
function writeStored(key: string, value: string) {
|
||||||
}
|
localStorage.setItem(key, value)
|
||||||
|
}
|
||||||
function writeToken(token: string) {
|
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
function removeStored(key: string) {
|
||||||
}
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
function removeToken() {
|
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
}
|
const token = ref<string | null>(readStored(ACCESS_KEY))
|
||||||
|
const refreshToken = ref<string | null>(readStored(REFRESH_KEY))
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
|
||||||
const token = ref<string | null>(readToken())
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
const claims = computed<JwtPayload | null>(() => (token.value ? decodeJwtPayload(token.value) : null))
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
|
||||||
const claims = computed<JwtPayload | null>(() => (token.value ? decodeJwtPayload(token.value) : null))
|
function setToken(newToken: string) {
|
||||||
|
token.value = newToken
|
||||||
function setToken(newToken: string) {
|
writeStored(ACCESS_KEY, newToken)
|
||||||
token.value = newToken
|
}
|
||||||
writeToken(newToken)
|
|
||||||
}
|
function setTokens(access: string, refresh: string) {
|
||||||
|
token.value = access
|
||||||
function clearToken() {
|
refreshToken.value = refresh
|
||||||
token.value = null
|
writeStored(ACCESS_KEY, access)
|
||||||
removeToken()
|
writeStored(REFRESH_KEY, refresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncFromStorage() {
|
function clearTokens() {
|
||||||
token.value = readToken()
|
token.value = null
|
||||||
}
|
refreshToken.value = null
|
||||||
|
removeStored(ACCESS_KEY)
|
||||||
return { token, isLoggedIn, claims, setToken, clearToken, syncFromStorage }
|
removeStored(REFRESH_KEY)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
return { token, refreshToken, isLoggedIn, claims, setToken, setTokens, clearTokens }
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,109 +1,109 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import type { Account } from '../api/types'
|
import type { Account } from '../api/types'
|
||||||
import * as socialApi from '../api/social'
|
import * as socialApi from '../api/social'
|
||||||
import { useAuthStore } from './auth'
|
import { useAuthStore } from './auth'
|
||||||
|
|
||||||
export const useSocialStore = defineStore('social', () => {
|
export const useSocialStore = defineStore('social', () => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const followers = ref<Account[]>([])
|
const followers = ref<Account[]>([])
|
||||||
const vloggers = ref<Account[]>([])
|
const vloggers = ref<Account[]>([])
|
||||||
|
|
||||||
const followersLoading = ref(false)
|
const followersLoading = ref(false)
|
||||||
const vloggersLoading = ref(false)
|
const vloggersLoading = ref(false)
|
||||||
|
|
||||||
const followersError = ref('')
|
const followersError = ref('')
|
||||||
const vloggersError = ref('')
|
const vloggersError = ref('')
|
||||||
|
|
||||||
const followerCount = computed(() => followers.value.length)
|
const followerCount = computed(() => followers.value.length)
|
||||||
const followingCount = computed(() => vloggers.value.length)
|
const followingCount = computed(() => vloggers.value.length)
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
followers.value = []
|
followers.value = []
|
||||||
vloggers.value = []
|
vloggers.value = []
|
||||||
followersError.value = ''
|
followersError.value = ''
|
||||||
vloggersError.value = ''
|
vloggersError.value = ''
|
||||||
followersLoading.value = false
|
followersLoading.value = false
|
||||||
vloggersLoading.value = false
|
vloggersLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFollowing(accountId: number) {
|
function isFollowing(accountId: number) {
|
||||||
return vloggers.value.some((a) => a.id === accountId)
|
return vloggers.value.some((a) => a.id === accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshFollowers(vloggerId?: number) {
|
async function refreshFollowers(vloggerId?: number) {
|
||||||
if (!auth.isLoggedIn) {
|
if (!auth.isLoggedIn) {
|
||||||
clear()
|
clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
followersLoading.value = true
|
followersLoading.value = true
|
||||||
followersError.value = ''
|
followersError.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await socialApi.getAllFollowers(vloggerId)
|
const res = await socialApi.getAllFollowers(vloggerId)
|
||||||
followers.value = res.followers
|
followers.value = res.followers
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
followersError.value = e instanceof ApiError ? e.message : String(e)
|
followersError.value = e instanceof ApiError ? e.message : String(e)
|
||||||
followers.value = []
|
followers.value = []
|
||||||
} finally {
|
} finally {
|
||||||
followersLoading.value = false
|
followersLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshVloggers(followerId?: number) {
|
async function refreshVloggers(followerId?: number) {
|
||||||
if (!auth.isLoggedIn) {
|
if (!auth.isLoggedIn) {
|
||||||
clear()
|
clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
vloggersLoading.value = true
|
vloggersLoading.value = true
|
||||||
vloggersError.value = ''
|
vloggersError.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await socialApi.getAllVloggers(followerId)
|
const res = await socialApi.getAllVloggers(followerId)
|
||||||
vloggers.value = res.vloggers
|
vloggers.value = res.vloggers
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
vloggersError.value = e instanceof ApiError ? e.message : String(e)
|
vloggersError.value = e instanceof ApiError ? e.message : String(e)
|
||||||
vloggers.value = []
|
vloggers.value = []
|
||||||
} finally {
|
} finally {
|
||||||
vloggersLoading.value = false
|
vloggersLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshMine() {
|
async function refreshMine() {
|
||||||
await Promise.all([refreshFollowers(), refreshVloggers()])
|
await Promise.all([refreshFollowers(), refreshVloggers()])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function follow(vloggerId: number) {
|
async function follow(vloggerId: number) {
|
||||||
if (!auth.isLoggedIn) throw new ApiError('需要先登录', 401)
|
if (!auth.isLoggedIn) throw new ApiError('需要先登录', 401)
|
||||||
await socialApi.follow(vloggerId)
|
await socialApi.follow(vloggerId)
|
||||||
await refreshVloggers()
|
await refreshVloggers()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unfollow(vloggerId: number) {
|
async function unfollow(vloggerId: number) {
|
||||||
if (!auth.isLoggedIn) throw new ApiError('需要先登录', 401)
|
if (!auth.isLoggedIn) throw new ApiError('需要先登录', 401)
|
||||||
await socialApi.unfollow(vloggerId)
|
await socialApi.unfollow(vloggerId)
|
||||||
await refreshVloggers()
|
await refreshVloggers()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
followers,
|
followers,
|
||||||
vloggers,
|
vloggers,
|
||||||
followerCount,
|
followerCount,
|
||||||
followingCount,
|
followingCount,
|
||||||
followersLoading,
|
followersLoading,
|
||||||
vloggersLoading,
|
vloggersLoading,
|
||||||
followersError,
|
followersError,
|
||||||
vloggersError,
|
vloggersError,
|
||||||
clear,
|
clear,
|
||||||
isFollowing,
|
isFollowing,
|
||||||
refreshMine,
|
refreshMine,
|
||||||
refreshFollowers,
|
refreshFollowers,
|
||||||
refreshVloggers,
|
refreshVloggers,
|
||||||
follow,
|
follow,
|
||||||
unfollow,
|
unfollow,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
export type ToastType = 'success' | 'error' | 'info'
|
export type ToastType = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
export type Toast = {
|
export type Toast = {
|
||||||
id: number
|
id: number
|
||||||
type: ToastType
|
type: ToastType
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextId = 1
|
let nextId = 1
|
||||||
|
|
||||||
export const useToastStore = defineStore('toast', () => {
|
export const useToastStore = defineStore('toast', () => {
|
||||||
const toasts = ref<Toast[]>([])
|
const toasts = ref<Toast[]>([])
|
||||||
|
|
||||||
function remove(id: number) {
|
function remove(id: number) {
|
||||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function push(type: ToastType, message: string, ttlMs = 2600) {
|
function push(type: ToastType, message: string, ttlMs = 2600) {
|
||||||
const id = nextId++
|
const id = nextId++
|
||||||
toasts.value.push({ id, type, message })
|
toasts.value.push({ id, type, message })
|
||||||
window.setTimeout(() => remove(id), ttlMs)
|
window.setTimeout(() => remove(id), ttlMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
function success(message: string) {
|
function success(message: string) {
|
||||||
push('success', message)
|
push('success', message)
|
||||||
}
|
}
|
||||||
|
|
||||||
function error(message: string) {
|
function error(message: string) {
|
||||||
push('error', message, 3600)
|
push('error', message, 3600)
|
||||||
}
|
}
|
||||||
|
|
||||||
function info(message: string) {
|
function info(message: string) {
|
||||||
push('info', message)
|
push('info', message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { toasts, push, remove, success, error, info }
|
return { toasts, push, remove, success, error, info }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,224 +1,224 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #0b0b0f;
|
--bg: #0b0b0f;
|
||||||
--panel: rgba(255, 255, 255, 0.06);
|
--panel: rgba(255, 255, 255, 0.06);
|
||||||
--panel-2: rgba(255, 255, 255, 0.1);
|
--panel-2: rgba(255, 255, 255, 0.1);
|
||||||
--text: rgba(255, 255, 255, 0.92);
|
--text: rgba(255, 255, 255, 0.92);
|
||||||
--muted: rgba(255, 255, 255, 0.64);
|
--muted: rgba(255, 255, 255, 0.64);
|
||||||
--border: rgba(255, 255, 255, 0.12);
|
--border: rgba(255, 255, 255, 0.12);
|
||||||
--primary: #fe2c55;
|
--primary: #fe2c55;
|
||||||
--primary-2: #25f4ee;
|
--primary-2: #25f4ee;
|
||||||
--danger: #fe2c55;
|
--danger: #fe2c55;
|
||||||
--ok: #22c55e;
|
--ok: #22c55e;
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji',
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji',
|
||||||
'Segoe UI Emoji';
|
'Segoe UI Emoji';
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide scrollbars globally (still scrollable). */
|
/* Hide scrollbars globally (still scrollable). */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
-ms-overflow-style: none; /* IE/Edge legacy */
|
-ms-overflow-style: none; /* IE/Edge legacy */
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: radial-gradient(1200px 900px at 20% -25%, rgba(254, 44, 85, 0.18), transparent 60%),
|
background: radial-gradient(1200px 900px at 20% -25%, rgba(254, 44, 85, 0.18), transparent 60%),
|
||||||
radial-gradient(900px 700px at 90% 10%, rgba(37, 244, 238, 0.12), transparent 55%), var(--bg);
|
radial-gradient(900px 700px at 90% 10%, rgba(37, 244, 238, 0.12), transparent 55%), var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px 16px 56px;
|
padding: 24px 16px 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card + .card {
|
.card + .card {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid.two {
|
.grid.two {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid.three {
|
.grid.three {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.grid.two,
|
.grid.two,
|
||||||
.grid.three {
|
.grid.three {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtle {
|
.subtle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
min-height: 88px;
|
min-height: 88px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
select:focus {
|
select:focus {
|
||||||
border-color: rgba(124, 92, 255, 0.8);
|
border-color: rgba(124, 92, 255, 0.8);
|
||||||
box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.22);
|
box-shadow: 0 0 0 3px rgba(124, 92, 255, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: 120ms ease;
|
transition: 120ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary {
|
button.primary {
|
||||||
border-color: rgba(254, 44, 85, 0.55);
|
border-color: rgba(254, 44, 85, 0.55);
|
||||||
background: rgba(254, 44, 85, 0.18);
|
background: rgba(254, 44, 85, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary:hover {
|
button.primary:hover {
|
||||||
background: rgba(254, 44, 85, 0.26);
|
background: rgba(254, 44, 85, 0.26);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.danger {
|
button.danger {
|
||||||
border-color: rgba(255, 77, 109, 0.65);
|
border-color: rgba(255, 77, 109, 0.65);
|
||||||
background: rgba(255, 77, 109, 0.18);
|
background: rgba(255, 77, 109, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.danger:hover {
|
button.danger:hover {
|
||||||
background: rgba(255, 77, 109, 0.26);
|
background: rgba(255, 77, 109, 0.26);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill.ok {
|
.pill.ok {
|
||||||
border-color: rgba(34, 197, 94, 0.5);
|
border-color: rgba(34, 197, 94, 0.5);
|
||||||
background: rgba(34, 197, 94, 0.12);
|
background: rgba(34, 197, 94, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill.bad {
|
.pill.bad {
|
||||||
border-color: rgba(255, 77, 109, 0.55);
|
border-color: rgba(255, 77, 109, 0.55);
|
||||||
background: rgba(255, 77, 109, 0.12);
|
background: rgba(255, 77, 109, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pre {
|
.pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
background: rgba(0, 0, 0, 0.25);
|
background: rgba(0, 0, 0, 0.25);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
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(() => {
|
||||||
|
/* 静默失败,避免错误上报自身导致循环 */
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,35 +1,35 @@
|
|||||||
export type JwtPayload = {
|
export type JwtPayload = {
|
||||||
account_id?: number
|
account_id?: number
|
||||||
username?: string
|
username?: string
|
||||||
exp?: number
|
exp?: number
|
||||||
iat?: number
|
iat?: number
|
||||||
nbf?: number
|
nbf?: number
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64UrlToBase64(input: string) {
|
function base64UrlToBase64(input: string) {
|
||||||
const base64 = input.replace(/-/g, '+').replace(/_/g, '/')
|
const base64 = input.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
const pad = base64.length % 4
|
const pad = base64.length % 4
|
||||||
return pad === 0 ? base64 : base64 + '='.repeat(4 - pad)
|
return pad === 0 ? base64 : base64 + '='.repeat(4 - pad)
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64ToUtf8String(base64: string) {
|
function base64ToUtf8String(base64: string) {
|
||||||
const binary = atob(base64)
|
const binary = atob(base64)
|
||||||
const bytes = new Uint8Array(binary.length)
|
const bytes = new Uint8Array(binary.length)
|
||||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||||
return new TextDecoder().decode(bytes)
|
return new TextDecoder().decode(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeJwtPayload(token: string): JwtPayload | null {
|
export function decodeJwtPayload(token: string): JwtPayload | null {
|
||||||
const [, payload] = token.split('.')
|
const [, payload] = token.split('.')
|
||||||
if (!payload) return null
|
if (!payload) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const json = base64ToUtf8String(base64UrlToBase64(payload))
|
const json = base64ToUtf8String(base64UrlToBase64(payload))
|
||||||
const parsed = JSON.parse(json)
|
const parsed = JSON.parse(json)
|
||||||
if (!parsed || typeof parsed !== 'object') return null
|
if (!parsed || typeof parsed !== 'object') return null
|
||||||
return parsed as JwtPayload
|
return parsed as JwtPayload
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1136
frontend/src/views/AccountView.vue
vendored
1136
frontend/src/views/AccountView.vue
vendored
File diff suppressed because it is too large
Load Diff
144
frontend/src/views/ChangePasswordView.vue
vendored
144
frontend/src/views/ChangePasswordView.vue
vendored
@@ -1,72 +1,72 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AppShell from '../components/AppShell.vue'
|
import AppShell from '../components/AppShell.vue'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import * as accountApi from '../api/account'
|
import * as accountApi from '../api/account'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const form = reactive({ username: '', oldPassword: '', newPassword: '' })
|
const form = reactive({ username: '', oldPassword: '', newPassword: '' })
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
const username = form.username.trim()
|
const username = form.username.trim()
|
||||||
const oldPassword = form.oldPassword.trim()
|
const oldPassword = form.oldPassword.trim()
|
||||||
const newPassword = form.newPassword.trim()
|
const newPassword = form.newPassword.trim()
|
||||||
if (!username || !oldPassword || !newPassword) {
|
if (!username || !oldPassword || !newPassword) {
|
||||||
toast.error('请把信息填完整')
|
toast.error('请把信息填完整')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
await accountApi.changePassword(username, oldPassword, newPassword)
|
await accountApi.changePassword(username, oldPassword, newPassword)
|
||||||
toast.success('密码已修改,请重新登录')
|
toast.success('密码已修改,请重新登录')
|
||||||
await router.push('/account')
|
await router.push('/account')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof ApiError ? e.message : String(e)
|
const msg = e instanceof ApiError ? e.message : String(e)
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div class="grid two">
|
<div class="grid two">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="title">修改密码</p>
|
<p class="title">修改密码</p>
|
||||||
<p class="subtle">不需要登录(对应后端 `/account/changePassword`)。</p>
|
<p class="subtle">不需要登录(对应后端 `/account/changePassword`)。</p>
|
||||||
<div class="grid" style="margin-top: 12px">
|
<div class="grid" style="margin-top: 12px">
|
||||||
<div>
|
<div>
|
||||||
<label>username</label>
|
<label>username</label>
|
||||||
<input v-model.trim="form.username" autocomplete="username" />
|
<input v-model.trim="form.username" autocomplete="username" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>old_password</label>
|
<label>old_password</label>
|
||||||
<input v-model.trim="form.oldPassword" type="password" autocomplete="current-password" />
|
<input v-model.trim="form.oldPassword" type="password" autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>new_password</label>
|
<label>new_password</label>
|
||||||
<input v-model.trim="form.newPassword" type="password" autocomplete="new-password" />
|
<input v-model.trim="form.newPassword" type="password" autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="justify-content: flex-end">
|
<div class="row" style="justify-content: flex-end">
|
||||||
<button class="primary" type="button" :disabled="busy" @click="submit">提交</button>
|
<button class="primary" type="button" :disabled="busy" @click="submit">提交</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="title">提示</p>
|
<p class="title">提示</p>
|
||||||
<p class="muted">改密成功后后端会让旧 token 失效;请在「账号」页重新登录。</p>
|
<p class="muted">改密成功后后端会让旧 token 失效;请在「账号」页重新登录。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
524
frontend/src/views/FeedView.vue
vendored
524
frontend/src/views/FeedView.vue
vendored
@@ -1,262 +1,262 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, watch } from 'vue'
|
import { computed, onMounted, reactive, watch } from 'vue'
|
||||||
|
|
||||||
import AppShell from '../components/AppShell.vue'
|
import AppShell from '../components/AppShell.vue'
|
||||||
import JsonBox from '../components/JsonBox.vue'
|
import JsonBox from '../components/JsonBox.vue'
|
||||||
import FeedVideoCard from '../components/FeedVideoCard.vue'
|
import FeedVideoCard from '../components/FeedVideoCard.vue'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import * as feedApi from '../api/feed'
|
import * as feedApi from '../api/feed'
|
||||||
import * as likeApi from '../api/like'
|
import * as likeApi from '../api/like'
|
||||||
import type { FeedVideoItem } from '../api/types'
|
import type { FeedVideoItem } from '../api/types'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
type ListState = {
|
type ListState = {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string
|
error: string
|
||||||
items: FeedVideoItem[]
|
items: FeedVideoItem[]
|
||||||
has_more: boolean
|
has_more: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = reactive<ListState & { limit: number; next_time: number }>({
|
const latest = reactive<ListState & { limit: number; next_time: number }>({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: '',
|
error: '',
|
||||||
items: [],
|
items: [],
|
||||||
has_more: false,
|
has_more: false,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
next_time: 0,
|
next_time: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const likesCount = reactive<ListState & { limit: number; next_likes_count_before?: number; next_id_before?: number }>({
|
const likesCount = reactive<ListState & { limit: number; next_likes_count_before?: number; next_id_before?: number }>({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: '',
|
error: '',
|
||||||
items: [],
|
items: [],
|
||||||
has_more: false,
|
has_more: false,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
next_likes_count_before: undefined,
|
next_likes_count_before: undefined,
|
||||||
next_id_before: undefined,
|
next_id_before: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const following = reactive<ListState & { limit: number; next_time: number }>({
|
const following = reactive<ListState & { limit: number; next_time: number }>({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: '',
|
error: '',
|
||||||
items: [],
|
items: [],
|
||||||
has_more: false,
|
has_more: false,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
next_time: 0,
|
next_time: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const action = reactive<{ loading: boolean; error: string; payload: unknown; name: string }>({
|
const action = reactive<{ loading: boolean; error: string; payload: unknown; name: string }>({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: '',
|
error: '',
|
||||||
payload: null,
|
payload: null,
|
||||||
name: '',
|
name: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const canLike = computed(() => auth.isLoggedIn)
|
const canLike = computed(() => auth.isLoggedIn)
|
||||||
|
|
||||||
async function runAction(name: string, fn: () => Promise<unknown>) {
|
async function runAction(name: string, fn: () => Promise<unknown>) {
|
||||||
action.name = name
|
action.name = name
|
||||||
action.loading = true
|
action.loading = true
|
||||||
action.error = ''
|
action.error = ''
|
||||||
action.payload = null
|
action.payload = null
|
||||||
try {
|
try {
|
||||||
action.payload = await fn()
|
action.payload = await fn()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
action.error = e instanceof ApiError ? e.message : String(e)
|
action.error = e instanceof ApiError ? e.message : String(e)
|
||||||
action.payload = e instanceof ApiError ? e.payload : null
|
action.payload = e instanceof ApiError ? e.payload : null
|
||||||
} finally {
|
} finally {
|
||||||
action.loading = false
|
action.loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLatest(reset: boolean) {
|
async function loadLatest(reset: boolean) {
|
||||||
latest.loading = true
|
latest.loading = true
|
||||||
latest.error = ''
|
latest.error = ''
|
||||||
try {
|
try {
|
||||||
const latest_time = reset ? 0 : latest.next_time
|
const latest_time = reset ? 0 : latest.next_time
|
||||||
const res = await feedApi.listLatest({ limit: latest.limit, latest_time })
|
const res = await feedApi.listLatest({ limit: latest.limit, latest_time })
|
||||||
latest.has_more = res.has_more
|
latest.has_more = res.has_more
|
||||||
latest.next_time = res.next_time
|
latest.next_time = res.next_time
|
||||||
latest.items = reset ? res.video_list : latest.items.concat(res.video_list)
|
latest.items = reset ? res.video_list : latest.items.concat(res.video_list)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
latest.error = e instanceof ApiError ? e.message : String(e)
|
latest.error = e instanceof ApiError ? e.message : String(e)
|
||||||
} finally {
|
} finally {
|
||||||
latest.loading = false
|
latest.loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLikesCount(reset: boolean) {
|
async function loadLikesCount(reset: boolean) {
|
||||||
likesCount.loading = true
|
likesCount.loading = true
|
||||||
likesCount.error = ''
|
likesCount.error = ''
|
||||||
try {
|
try {
|
||||||
const res = await feedApi.listLikesCount({
|
const res = await feedApi.listLikesCount({
|
||||||
limit: likesCount.limit,
|
limit: likesCount.limit,
|
||||||
likes_count_before: reset ? undefined : likesCount.next_likes_count_before,
|
likes_count_before: reset ? undefined : likesCount.next_likes_count_before,
|
||||||
id_before: reset ? undefined : likesCount.next_id_before,
|
id_before: reset ? undefined : likesCount.next_id_before,
|
||||||
})
|
})
|
||||||
likesCount.has_more = res.has_more
|
likesCount.has_more = res.has_more
|
||||||
likesCount.next_likes_count_before = res.next_likes_count_before
|
likesCount.next_likes_count_before = res.next_likes_count_before
|
||||||
likesCount.next_id_before = res.next_id_before
|
likesCount.next_id_before = res.next_id_before
|
||||||
likesCount.items = reset ? res.video_list : likesCount.items.concat(res.video_list)
|
likesCount.items = reset ? res.video_list : likesCount.items.concat(res.video_list)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
likesCount.error = e instanceof ApiError ? e.message : String(e)
|
likesCount.error = e instanceof ApiError ? e.message : String(e)
|
||||||
} finally {
|
} finally {
|
||||||
likesCount.loading = false
|
likesCount.loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFollowing(reset: boolean) {
|
async function loadFollowing(reset: boolean) {
|
||||||
following.loading = true
|
following.loading = true
|
||||||
following.error = ''
|
following.error = ''
|
||||||
try {
|
try {
|
||||||
const latest_time = reset ? 0 : following.next_time
|
const latest_time = reset ? 0 : following.next_time
|
||||||
const res = await feedApi.listByFollowing({ limit: following.limit, latest_time })
|
const res = await feedApi.listByFollowing({ limit: following.limit, latest_time })
|
||||||
following.has_more = res.has_more
|
following.has_more = res.has_more
|
||||||
following.next_time = res.next_time
|
following.next_time = res.next_time
|
||||||
following.items = reset ? res.video_list : following.items.concat(res.video_list)
|
following.items = reset ? res.video_list : following.items.concat(res.video_list)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
following.error = e instanceof ApiError ? e.message : String(e)
|
following.error = e instanceof ApiError ? e.message : String(e)
|
||||||
} finally {
|
} finally {
|
||||||
following.loading = false
|
following.loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleLike(item: FeedVideoItem) {
|
async function toggleLike(item: FeedVideoItem) {
|
||||||
if (!auth.isLoggedIn) return
|
if (!auth.isLoggedIn) return
|
||||||
|
|
||||||
await runAction(item.is_liked ? '取消点赞' : '点赞', async () => {
|
await runAction(item.is_liked ? '取消点赞' : '点赞', async () => {
|
||||||
if (item.is_liked) await likeApi.unlike(item.id)
|
if (item.is_liked) await likeApi.unlike(item.id)
|
||||||
else await likeApi.like(item.id)
|
else await likeApi.like(item.id)
|
||||||
|
|
||||||
item.is_liked = !item.is_liked
|
item.is_liked = !item.is_liked
|
||||||
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
|
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
|
||||||
return { ok: true, is_liked: item.is_liked, likes_count: item.likes_count }
|
return { ok: true, is_liked: item.is_liked, likes_count: item.likes_count }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadLatest(true)
|
await loadLatest(true)
|
||||||
await loadLikesCount(true)
|
await loadLikesCount(true)
|
||||||
if (auth.isLoggedIn) {
|
if (auth.isLoggedIn) {
|
||||||
await loadFollowing(true)
|
await loadFollowing(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => auth.isLoggedIn,
|
() => auth.isLoggedIn,
|
||||||
async (v) => {
|
async (v) => {
|
||||||
if (v && following.items.length === 0) {
|
if (v && following.items.length === 0) {
|
||||||
await loadFollowing(true)
|
await loadFollowing(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div class="grid two">
|
<div class="grid two">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="title">Feed</p>
|
<p class="title">Feed</p>
|
||||||
<p class="subtle">`/feed/listLatest` 与 `/feed/listLikesCount` 支持匿名(可选 JWT);`/feed/listByFollowing` 需要 JWT。</p>
|
<p class="subtle">`/feed/listLatest` 与 `/feed/listLikesCount` 支持匿名(可选 JWT);`/feed/listByFollowing` 需要 JWT。</p>
|
||||||
|
|
||||||
<div class="card" style="margin-top: 12px">
|
<div class="card" style="margin-top: 12px">
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="title">最新流(listLatest)</p>
|
<p class="title">最新流(listLatest)</p>
|
||||||
<div class="subtle">limit:{{ latest.limit }} · next_time:{{ latest.next_time }} · has_more:{{ latest.has_more }}</div>
|
<div class="subtle">limit:{{ latest.limit }} · next_time:{{ latest.next_time }} · has_more:{{ latest.has_more }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="subtle" style="margin: 0">limit</label>
|
<label class="subtle" style="margin: 0">limit</label>
|
||||||
<input v-model.number="latest.limit" type="number" min="1" max="50" style="width: 90px" />
|
<input v-model.number="latest.limit" type="number" min="1" max="50" style="width: 90px" />
|
||||||
<button class="primary" type="button" :disabled="latest.loading" @click="loadLatest(true)">刷新</button>
|
<button class="primary" type="button" :disabled="latest.loading" @click="loadLatest(true)">刷新</button>
|
||||||
<button type="button" :disabled="latest.loading || !latest.has_more" @click="loadLatest(false)">加载更多</button>
|
<button type="button" :disabled="latest.loading || !latest.has_more" @click="loadLatest(false)">加载更多</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="latest.error" class="pill bad" style="margin-top: 10px">错误:{{ latest.error }}</div>
|
<div v-if="latest.error" class="pill bad" style="margin-top: 10px">错误:{{ latest.error }}</div>
|
||||||
<div class="grid" style="gap: 10px; margin-top: 12px">
|
<div class="grid" style="gap: 10px; margin-top: 12px">
|
||||||
<FeedVideoCard
|
<FeedVideoCard
|
||||||
v-for="item in latest.items"
|
v-for="item in latest.items"
|
||||||
:key="`latest-${item.id}`"
|
:key="`latest-${item.id}`"
|
||||||
:item="item"
|
:item="item"
|
||||||
:can-like="canLike"
|
:can-like="canLike"
|
||||||
:busy="action.loading"
|
:busy="action.loading"
|
||||||
@toggle-like="toggleLike"
|
@toggle-like="toggleLike"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top: 12px">
|
<div class="card" style="margin-top: 12px">
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="title">点赞数流(listLikesCount)</p>
|
<p class="title">点赞数流(listLikesCount)</p>
|
||||||
<div class="subtle">
|
<div class="subtle">
|
||||||
limit:{{ likesCount.limit }} · next=(likes={{ likesCount.next_likes_count_before }}, id={{ likesCount.next_id_before }})
|
limit:{{ likesCount.limit }} · next=(likes={{ likesCount.next_likes_count_before }}, id={{ likesCount.next_id_before }})
|
||||||
· has_more:{{ likesCount.has_more }}
|
· has_more:{{ likesCount.has_more }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="subtle" style="margin: 0">limit</label>
|
<label class="subtle" style="margin: 0">limit</label>
|
||||||
<input v-model.number="likesCount.limit" type="number" min="1" max="50" style="width: 90px" />
|
<input v-model.number="likesCount.limit" type="number" min="1" max="50" style="width: 90px" />
|
||||||
<button class="primary" type="button" :disabled="likesCount.loading" @click="loadLikesCount(true)">刷新</button>
|
<button class="primary" type="button" :disabled="likesCount.loading" @click="loadLikesCount(true)">刷新</button>
|
||||||
<button type="button" :disabled="likesCount.loading || !likesCount.has_more" @click="loadLikesCount(false)">
|
<button type="button" :disabled="likesCount.loading || !likesCount.has_more" @click="loadLikesCount(false)">
|
||||||
加载更多
|
加载更多
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="likesCount.error" class="pill bad" style="margin-top: 10px">错误:{{ likesCount.error }}</div>
|
<div v-if="likesCount.error" class="pill bad" style="margin-top: 10px">错误:{{ likesCount.error }}</div>
|
||||||
<div class="grid" style="gap: 10px; margin-top: 12px">
|
<div class="grid" style="gap: 10px; margin-top: 12px">
|
||||||
<FeedVideoCard
|
<FeedVideoCard
|
||||||
v-for="item in likesCount.items"
|
v-for="item in likesCount.items"
|
||||||
:key="`likes-${item.id}`"
|
:key="`likes-${item.id}`"
|
||||||
:item="item"
|
:item="item"
|
||||||
:can-like="canLike"
|
:can-like="canLike"
|
||||||
:busy="action.loading"
|
:busy="action.loading"
|
||||||
@toggle-like="toggleLike"
|
@toggle-like="toggleLike"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top: 12px">
|
<div class="card" style="margin-top: 12px">
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="title">关注流(listByFollowing,JWT)</p>
|
<p class="title">关注流(listByFollowing,JWT)</p>
|
||||||
<div class="subtle">
|
<div class="subtle">
|
||||||
limit:{{ following.limit }} · next_time:{{ following.next_time }} · has_more:{{ following.has_more }}
|
limit:{{ following.limit }} · next_time:{{ following.next_time }} · has_more:{{ following.has_more }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="subtle" style="margin: 0">limit</label>
|
<label class="subtle" style="margin: 0">limit</label>
|
||||||
<input v-model.number="following.limit" type="number" min="1" max="50" style="width: 90px" />
|
<input v-model.number="following.limit" type="number" min="1" max="50" style="width: 90px" />
|
||||||
<button class="primary" type="button" :disabled="following.loading" @click="loadFollowing(true)">刷新</button>
|
<button class="primary" type="button" :disabled="following.loading" @click="loadFollowing(true)">刷新</button>
|
||||||
<button type="button" :disabled="following.loading || !following.has_more" @click="loadFollowing(false)">加载更多</button>
|
<button type="button" :disabled="following.loading || !following.has_more" @click="loadFollowing(false)">加载更多</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!auth.isLoggedIn" class="pill bad" style="margin-top: 10px">未登录:无法访问关注流</div>
|
<div v-if="!auth.isLoggedIn" class="pill bad" style="margin-top: 10px">未登录:无法访问关注流</div>
|
||||||
<div v-if="following.error" class="pill bad" style="margin-top: 10px">错误:{{ following.error }}</div>
|
<div v-if="following.error" class="pill bad" style="margin-top: 10px">错误:{{ following.error }}</div>
|
||||||
<div class="grid" style="gap: 10px; margin-top: 12px">
|
<div class="grid" style="gap: 10px; margin-top: 12px">
|
||||||
<FeedVideoCard
|
<FeedVideoCard
|
||||||
v-for="item in following.items"
|
v-for="item in following.items"
|
||||||
:key="`following-${item.id}`"
|
:key="`following-${item.id}`"
|
||||||
:item="item"
|
:item="item"
|
||||||
:can-like="canLike"
|
:can-like="canLike"
|
||||||
:busy="action.loading"
|
:busy="action.loading"
|
||||||
@toggle-like="toggleLike"
|
@toggle-like="toggleLike"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="title">动作输出(点赞等)</p>
|
<p class="title">动作输出(点赞等)</p>
|
||||||
<div class="row" style="margin-bottom: 10px">
|
<div class="row" style="margin-bottom: 10px">
|
||||||
<span class="pill">动作:{{ action.name || '-' }}</span>
|
<span class="pill">动作:{{ action.name || '-' }}</span>
|
||||||
<span v-if="action.loading" class="pill">请求中…</span>
|
<span v-if="action.loading" class="pill">请求中…</span>
|
||||||
<span v-if="action.error" class="pill bad">错误:{{ action.error }}</span>
|
<span v-if="action.error" class="pill bad">错误:{{ action.error }}</span>
|
||||||
</div>
|
</div>
|
||||||
<JsonBox :value="action.payload" />
|
<JsonBox :value="action.payload" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
893
frontend/src/views/HomeView.vue
vendored
893
frontend/src/views/HomeView.vue
vendored
File diff suppressed because it is too large
Load Diff
314
frontend/src/views/HotView.vue
vendored
314
frontend/src/views/HotView.vue
vendored
@@ -1,157 +1,157 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive } from 'vue'
|
import { computed, onMounted, reactive } from 'vue'
|
||||||
|
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import * as feedApi from '../api/feed'
|
import * as feedApi from '../api/feed'
|
||||||
import * as likeApi from '../api/like'
|
import * as likeApi from '../api/like'
|
||||||
import type { FeedVideoItem } from '../api/types'
|
import type { FeedVideoItem } from '../api/types'
|
||||||
import AppShell from '../components/AppShell.vue'
|
import AppShell from '../components/AppShell.vue'
|
||||||
import FeedVideoCard from '../components/FeedVideoCard.vue'
|
import FeedVideoCard from '../components/FeedVideoCard.vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
const canLike = computed(() => auth.isLoggedIn)
|
const canLike = computed(() => auth.isLoggedIn)
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: '',
|
error: '',
|
||||||
items: [] as FeedVideoItem[],
|
items: [] as FeedVideoItem[],
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
asOf: 0,
|
asOf: 0,
|
||||||
nextOffset: 0,
|
nextOffset: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const likeBusy = reactive<Record<string, boolean>>({})
|
const likeBusy = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
async function loadHot(reset: boolean) {
|
async function loadHot(reset: boolean) {
|
||||||
if (state.loading) return
|
if (state.loading) return
|
||||||
state.loading = true
|
state.loading = true
|
||||||
state.error = ''
|
state.error = ''
|
||||||
try {
|
try {
|
||||||
const res = await feedApi.listByPopularity({
|
const res = await feedApi.listByPopularity({
|
||||||
limit: state.limit,
|
limit: state.limit,
|
||||||
as_of: reset ? 0 : state.asOf,
|
as_of: reset ? 0 : state.asOf,
|
||||||
offset: reset ? 0 : state.nextOffset,
|
offset: reset ? 0 : state.nextOffset,
|
||||||
})
|
})
|
||||||
state.hasMore = res.has_more
|
state.hasMore = res.has_more
|
||||||
state.asOf = res.as_of
|
state.asOf = res.as_of
|
||||||
state.nextOffset = res.next_offset
|
state.nextOffset = res.next_offset
|
||||||
state.items = reset ? res.video_list : state.items.concat(res.video_list)
|
state.items = reset ? res.video_list : state.items.concat(res.video_list)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.error = e instanceof ApiError ? e.message : String(e)
|
state.error = e instanceof ApiError ? e.message : String(e)
|
||||||
} finally {
|
} finally {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleLike(item: FeedVideoItem) {
|
async function toggleLike(item: FeedVideoItem) {
|
||||||
if (!auth.isLoggedIn) {
|
if (!auth.isLoggedIn) {
|
||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const key = String(item.id)
|
const key = String(item.id)
|
||||||
if (likeBusy[key]) return
|
if (likeBusy[key]) return
|
||||||
likeBusy[key] = true
|
likeBusy[key] = true
|
||||||
try {
|
try {
|
||||||
if (item.is_liked) await likeApi.unlike(item.id)
|
if (item.is_liked) await likeApi.unlike(item.id)
|
||||||
else await likeApi.like(item.id)
|
else await likeApi.like(item.id)
|
||||||
item.is_liked = !item.is_liked
|
item.is_liked = !item.is_liked
|
||||||
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
|
item.likes_count = Math.max(0, item.likes_count + (item.is_liked ? 1 : -1))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof ApiError ? e.message : String(e)
|
const msg = e instanceof ApiError ? e.message : String(e)
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
likeBusy[key] = false
|
likeBusy[key] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadHot(true)
|
await loadHot(true)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row" style="justify-content: space-between; align-items: baseline">
|
<div class="row" style="justify-content: space-between; align-items: baseline">
|
||||||
<div>
|
<div>
|
||||||
<p class="title" style="margin: 0">热榜</p>
|
<p class="title" style="margin: 0">热榜</p>
|
||||||
<p class="subtle" style="margin: 6px 0 0">按热度排序(/feed/listByPopularity)</p>
|
<p class="subtle" style="margin: 6px 0 0">按热度排序(/feed/listByPopularity)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="subtle" style="margin: 0">limit</label>
|
<label class="subtle" style="margin: 0">limit</label>
|
||||||
<input v-model.number="state.limit" type="number" min="1" max="50" style="width: 90px" :disabled="state.loading" />
|
<input v-model.number="state.limit" type="number" min="1" max="50" style="width: 90px" :disabled="state.loading" />
|
||||||
<button class="primary" type="button" :disabled="state.loading" @click="loadHot(true)">刷新</button>
|
<button class="primary" type="button" :disabled="state.loading" @click="loadHot(true)">刷新</button>
|
||||||
<button type="button" :disabled="state.loading || !state.hasMore" @click="loadHot(false)">加载更多</button>
|
<button type="button" :disabled="state.loading || !state.hasMore" @click="loadHot(false)">加载更多</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="state.error" class="pill bad" style="margin-top: 12px">错误:{{ state.error }}</div>
|
<div v-if="state.error" class="pill bad" style="margin-top: 12px">错误:{{ state.error }}</div>
|
||||||
<div v-else-if="state.loading && state.items.length === 0" class="subtle" style="margin-top: 12px">加载中…</div>
|
<div v-else-if="state.loading && state.items.length === 0" class="subtle" style="margin-top: 12px">加载中…</div>
|
||||||
<div v-else-if="state.items.length === 0" class="subtle" style="margin-top: 12px">暂无内容</div>
|
<div v-else-if="state.items.length === 0" class="subtle" style="margin-top: 12px">暂无内容</div>
|
||||||
|
|
||||||
<div v-if="state.items.length" class="rank-list" style="margin-top: 14px">
|
<div v-if="state.items.length" class="rank-list" style="margin-top: 14px">
|
||||||
<div v-for="(item, idx) in state.items" :key="`hot-${item.id}`" class="rank-row">
|
<div v-for="(item, idx) in state.items" :key="`hot-${item.id}`" class="rank-row">
|
||||||
<div class="rank-num" :class="idx < 3 ? 'top' : ''">{{ idx + 1 }}</div>
|
<div class="rank-num" :class="idx < 3 ? 'top' : ''">{{ idx + 1 }}</div>
|
||||||
<FeedVideoCard
|
<FeedVideoCard
|
||||||
:item="item"
|
:item="item"
|
||||||
:can-like="canLike"
|
:can-like="canLike"
|
||||||
:busy="!!likeBusy[String(item.id)]"
|
:busy="!!likeBusy[String(item.id)]"
|
||||||
@toggle-like="toggleLike"
|
@toggle-like="toggleLike"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.rank-list {
|
.rank-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank-row {
|
.rank-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 44px minmax(0, 1fr);
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank-num {
|
.rank-num {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
width: 44px;
|
width: 44px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: rgba(255, 255, 255, 0.86);
|
color: rgba(255, 255, 255, 0.86);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank-num.top {
|
.rank-num.top {
|
||||||
border-color: rgba(254, 44, 85, 0.55);
|
border-color: rgba(254, 44, 85, 0.55);
|
||||||
background: rgba(254, 44, 85, 0.18);
|
background: rgba(254, 44, 85, 0.18);
|
||||||
color: rgba(255, 255, 255, 0.96);
|
color: rgba(255, 255, 255, 0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.rank-row {
|
.rank-row {
|
||||||
grid-template-columns: 38px minmax(0, 1fr);
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.rank-num {
|
.rank-num {
|
||||||
height: 38px;
|
height: 38px;
|
||||||
width: 38px;
|
width: 38px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
134
frontend/src/views/RegisterView.vue
vendored
134
frontend/src/views/RegisterView.vue
vendored
@@ -1,67 +1,67 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AppShell from '../components/AppShell.vue'
|
import AppShell from '../components/AppShell.vue'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import * as accountApi from '../api/account'
|
import * as accountApi from '../api/account'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const form = reactive({ username: '', password: '' })
|
const form = reactive({ username: '', password: '' })
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
const username = form.username.trim()
|
const username = form.username.trim()
|
||||||
const password = form.password.trim()
|
const password = form.password.trim()
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
toast.error('请输入 username 和 password')
|
toast.error('请输入 username 和 password')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
await accountApi.register(username, password)
|
await accountApi.register(username, password)
|
||||||
toast.success('注册成功,请登录')
|
toast.success('注册成功,请登录')
|
||||||
await router.push('/account')
|
await router.push('/account')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof ApiError ? e.message : String(e)
|
const msg = e instanceof ApiError ? e.message : String(e)
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div class="grid two">
|
<div class="grid two">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="title">注册</p>
|
<p class="title">注册</p>
|
||||||
<p class="subtle">创建新账号(对应后端 `/account/register`)。</p>
|
<p class="subtle">创建新账号(对应后端 `/account/register`)。</p>
|
||||||
<div class="grid" style="margin-top: 12px">
|
<div class="grid" style="margin-top: 12px">
|
||||||
<div>
|
<div>
|
||||||
<label>username</label>
|
<label>username</label>
|
||||||
<input v-model.trim="form.username" autocomplete="username" />
|
<input v-model.trim="form.username" autocomplete="username" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>password</label>
|
<label>password</label>
|
||||||
<input v-model.trim="form.password" type="password" autocomplete="new-password" />
|
<input v-model.trim="form.password" type="password" autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="justify-content: flex-end">
|
<div class="row" style="justify-content: flex-end">
|
||||||
<button class="primary" type="button" :disabled="busy" @click="submit">注册</button>
|
<button class="primary" type="button" :disabled="busy" @click="submit">注册</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="title">提示</p>
|
<p class="title">提示</p>
|
||||||
<p class="muted">注册成功后会跳回「账号」页进行登录。</p>
|
<p class="muted">注册成功后会跳回「账号」页进行登录。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
336
frontend/src/views/SettingsView.vue
vendored
336
frontend/src/views/SettingsView.vue
vendored
@@ -1,168 +1,168 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, reactive, ref } from 'vue'
|
import { computed, nextTick, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AppShell from '../components/AppShell.vue'
|
import AppShell from '../components/AppShell.vue'
|
||||||
import UserAvatar from '../components/UserAvatar.vue'
|
import UserAvatar from '../components/UserAvatar.vue'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import * as accountApi from '../api/account'
|
import * as accountApi from '../api/account'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
|
|
||||||
const me = computed(() => ({
|
const me = computed(() => ({
|
||||||
id: auth.claims?.account_id ?? 0,
|
id: auth.claims?.account_id ?? 0,
|
||||||
username: auth.claims?.username ?? '',
|
username: auth.claims?.username ?? '',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const rename = reactive({
|
const rename = reactive({
|
||||||
open: false,
|
open: false,
|
||||||
newUsername: '',
|
newUsername: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
async function openRename() {
|
async function openRename() {
|
||||||
if (!auth.isLoggedIn) return
|
if (!auth.isLoggedIn) return
|
||||||
rename.open = true
|
rename.open = true
|
||||||
rename.newUsername = me.value.username
|
rename.newUsername = me.value.username
|
||||||
await nextTick()
|
await nextTick()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitRename() {
|
async function submitRename() {
|
||||||
if (!auth.isLoggedIn) return
|
if (!auth.isLoggedIn) return
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
const newUsername = rename.newUsername.trim()
|
const newUsername = rename.newUsername.trim()
|
||||||
if (!newUsername) {
|
if (!newUsername) {
|
||||||
toast.error('请输入新用户名')
|
toast.error('请输入新用户名')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
const res = await accountApi.rename(newUsername)
|
const res = await accountApi.rename(newUsername)
|
||||||
auth.setToken(res.token)
|
auth.setToken(res.token)
|
||||||
rename.open = false
|
rename.open = false
|
||||||
toast.success('改名成功(已刷新 token)')
|
toast.success('改名成功(已刷新 token)')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof ApiError ? e.message : String(e)
|
const msg = e instanceof ApiError ? e.message : String(e)
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goLogin() {
|
async function goLogin() {
|
||||||
await router.push('/account')
|
await router.push('/account')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goChangePassword() {
|
async function goChangePassword() {
|
||||||
await router.push('/account/change-password')
|
await router.push('/account/change-password')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onLogout() {
|
async function onLogout() {
|
||||||
if (!auth.isLoggedIn) return
|
if (!auth.isLoggedIn) return
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
if (!window.confirm('确认退出登录?')) return
|
if (!window.confirm('确认退出登录?')) return
|
||||||
|
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
await accountApi.logout()
|
await accountApi.logout()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof ApiError ? e.message : String(e)
|
const msg = e instanceof ApiError ? e.message : String(e)
|
||||||
toast.error(`登出失败:${msg}`)
|
toast.error(`登出失败:${msg}`)
|
||||||
} finally {
|
} finally {
|
||||||
auth.clearToken()
|
auth.clearTokens()
|
||||||
rename.open = false
|
rename.open = false
|
||||||
toast.info('已退出登录')
|
toast.info('已退出登录')
|
||||||
busy.value = false
|
busy.value = false
|
||||||
await router.push('/')
|
await router.push('/')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div v-if="!auth.isLoggedIn" class="grid two">
|
<div v-if="!auth.isLoggedIn" class="grid two">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="title">设置</p>
|
<p class="title">设置</p>
|
||||||
<p class="subtle">需要先登录后才能进行改名/退出等操作。</p>
|
<p class="subtle">需要先登录后才能进行改名/退出等操作。</p>
|
||||||
<div class="row" style="margin-top: 12px; justify-content: flex-end">
|
<div class="row" style="margin-top: 12px; justify-content: flex-end">
|
||||||
<button class="primary" type="button" @click="goLogin">去登录</button>
|
<button class="primary" type="button" @click="goLogin">去登录</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="title">提示</p>
|
<p class="title">提示</p>
|
||||||
<p class="muted">登录入口在「账号」页。</p>
|
<p class="muted">登录入口在「账号」页。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid two">
|
<div v-else class="grid two">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row" style="justify-content: space-between; align-items: flex-start">
|
<div class="row" style="justify-content: space-between; align-items: flex-start">
|
||||||
<div class="row" style="gap: 12px; align-items: center">
|
<div class="row" style="gap: 12px; align-items: center">
|
||||||
<UserAvatar :username="me.username" :id="me.id" :size="56" />
|
<UserAvatar :username="me.username" :id="me.id" :size="56" />
|
||||||
<div>
|
<div>
|
||||||
<div class="title" style="margin: 0">@{{ me.username }}</div>
|
<div class="title" style="margin: 0">@{{ me.username }}</div>
|
||||||
<div class="subtle mono">#{{ me.id }}</div>
|
<div class="subtle mono">#{{ me.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top: 14px">
|
<div class="card" style="margin-top: 14px">
|
||||||
<div class="row" style="justify-content: space-between; align-items: center">
|
<div class="row" style="justify-content: space-between; align-items: center">
|
||||||
<p class="title" style="margin: 0">账号设置</p>
|
<p class="title" style="margin: 0">账号设置</p>
|
||||||
<button class="ghost" type="button" :disabled="busy" @click="openRename">改名</button>
|
<button class="ghost" type="button" :disabled="busy" @click="openRename">改名</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="rename.open" class="grid" style="margin-top: 12px">
|
<div v-if="rename.open" class="grid" style="margin-top: 12px">
|
||||||
<div>
|
<div>
|
||||||
<label>new_username</label>
|
<label>new_username</label>
|
||||||
<input v-model.trim="rename.newUsername" @keydown.enter="submitRename" />
|
<input v-model.trim="rename.newUsername" @keydown.enter="submitRename" />
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="justify-content: flex-end">
|
<div class="row" style="justify-content: flex-end">
|
||||||
<button type="button" :disabled="busy" @click="rename.open = false">取消</button>
|
<button type="button" :disabled="busy" @click="rename.open = false">取消</button>
|
||||||
<button class="primary" type="button" :disabled="busy" @click="submitRename">提交</button>
|
<button class="primary" type="button" :disabled="busy" @click="submitRename">提交</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top: 14px">
|
<div class="card" style="margin-top: 14px">
|
||||||
<p class="title">账号安全</p>
|
<p class="title">账号安全</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="ghost" type="button" :disabled="busy" @click="goChangePassword">修改密码</button>
|
<button class="ghost" type="button" :disabled="busy" @click="goChangePassword">修改密码</button>
|
||||||
<button class="danger" type="button" :disabled="busy" @click="onLogout">退出登录</button>
|
<button class="danger" type="button" :disabled="busy" @click="onLogout">退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="title">说明</p>
|
<p class="title">说明</p>
|
||||||
<div class="grid" style="margin-top: 10px">
|
<div class="grid" style="margin-top: 10px">
|
||||||
<div class="pill ok">改名后会返回新 token,旧 token 立即失效</div>
|
<div class="pill ok">改名后会返回新 token,旧 token 立即失效</div>
|
||||||
<div class="pill ok">退出登录会清空本地 token</div>
|
<div class="pill ok">退出登录会清空本地 token</div>
|
||||||
<div class="pill">修改密码无需登录,但成功后会让旧 token 失效</div>
|
<div class="pill">修改密码无需登录,但成功后会让旧 token 失效</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ghost {
|
.ghost {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
color: rgba(255, 255, 255, 0.86);
|
color: rgba(255, 255, 255, 0.86);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost:hover {
|
.ghost:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
910
frontend/src/views/UserProfileView.vue
vendored
910
frontend/src/views/UserProfileView.vue
vendored
@@ -1,455 +1,455 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, watch } from 'vue'
|
import { computed, onMounted, reactive, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AppShell from '../components/AppShell.vue'
|
import AppShell from '../components/AppShell.vue'
|
||||||
import UserAvatar from '../components/UserAvatar.vue'
|
import UserAvatar from '../components/UserAvatar.vue'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import * as accountApi from '../api/account'
|
import * as accountApi from '../api/account'
|
||||||
import * as socialApi from '../api/social'
|
import * as socialApi from '../api/social'
|
||||||
import type { Account, Video } from '../api/types'
|
import type { Account, Video } from '../api/types'
|
||||||
import * as videoApi from '../api/video'
|
import * as videoApi from '../api/video'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useSocialStore } from '../stores/social'
|
import { useSocialStore } from '../stores/social'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const social = useSocialStore()
|
const social = useSocialStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
const userId = computed(() => Number(route.params.id))
|
const userId = computed(() => Number(route.params.id))
|
||||||
const myId = computed(() => auth.claims?.account_id ?? 0)
|
const myId = computed(() => auth.claims?.account_id ?? 0)
|
||||||
const isMe = computed(() => myId.value > 0 && myId.value === userId.value)
|
const isMe = computed(() => myId.value > 0 && myId.value === userId.value)
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: '',
|
error: '',
|
||||||
user: null as Account | null,
|
user: null as Account | null,
|
||||||
videos: [] as Video[],
|
videos: [] as Video[],
|
||||||
followers: [] as Account[],
|
followers: [] as Account[],
|
||||||
vloggers: [] as Account[],
|
vloggers: [] as Account[],
|
||||||
socialLoading: false,
|
socialLoading: false,
|
||||||
socialError: '',
|
socialError: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const isFollowing = computed(() => (auth.isLoggedIn ? social.isFollowing(userId.value) : false))
|
const isFollowing = computed(() => (auth.isLoggedIn ? social.isFollowing(userId.value) : false))
|
||||||
|
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
if (!Number.isFinite(userId.value) || userId.value <= 0) {
|
if (!Number.isFinite(userId.value) || userId.value <= 0) {
|
||||||
state.error = '无效的用户 id'
|
state.error = '无效的用户 id'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state.loading = true
|
state.loading = true
|
||||||
state.error = ''
|
state.error = ''
|
||||||
try {
|
try {
|
||||||
const [u, vids] = await Promise.all([accountApi.findById(userId.value), videoApi.listByAuthorId(userId.value)])
|
const [u, vids] = await Promise.all([accountApi.findById(userId.value), videoApi.listByAuthorId(userId.value)])
|
||||||
state.user = u
|
state.user = u
|
||||||
state.videos = vids
|
state.videos = vids
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.error = e instanceof ApiError ? e.message : String(e)
|
state.error = e instanceof ApiError ? e.message : String(e)
|
||||||
state.user = null
|
state.user = null
|
||||||
state.videos = []
|
state.videos = []
|
||||||
} finally {
|
} finally {
|
||||||
state.loading = false
|
state.loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadSocialCounts()
|
await loadSocialCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSocialCounts() {
|
async function loadSocialCounts() {
|
||||||
state.socialError = ''
|
state.socialError = ''
|
||||||
state.followers = []
|
state.followers = []
|
||||||
state.vloggers = []
|
state.vloggers = []
|
||||||
|
|
||||||
if (!auth.isLoggedIn) return
|
if (!auth.isLoggedIn) return
|
||||||
if (!Number.isFinite(userId.value) || userId.value <= 0) return
|
if (!Number.isFinite(userId.value) || userId.value <= 0) return
|
||||||
|
|
||||||
state.socialLoading = true
|
state.socialLoading = true
|
||||||
try {
|
try {
|
||||||
const [followersRes, vloggersRes] = await Promise.all([
|
const [followersRes, vloggersRes] = await Promise.all([
|
||||||
socialApi.getAllFollowers(userId.value),
|
socialApi.getAllFollowers(userId.value),
|
||||||
socialApi.getAllVloggers(userId.value),
|
socialApi.getAllVloggers(userId.value),
|
||||||
])
|
])
|
||||||
state.followers = followersRes.followers
|
state.followers = followersRes.followers
|
||||||
state.vloggers = vloggersRes.vloggers
|
state.vloggers = vloggersRes.vloggers
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.socialError = e instanceof ApiError ? e.message : String(e)
|
state.socialError = e instanceof ApiError ? e.message : String(e)
|
||||||
} finally {
|
} finally {
|
||||||
state.socialLoading = false
|
state.socialLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFollow() {
|
async function toggleFollow() {
|
||||||
if (isMe.value) return
|
if (isMe.value) return
|
||||||
if (!auth.isLoggedIn) {
|
if (!auth.isLoggedIn) {
|
||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
await router.push('/account')
|
await router.push('/account')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isFollowing.value) {
|
if (isFollowing.value) {
|
||||||
await social.unfollow(userId.value)
|
await social.unfollow(userId.value)
|
||||||
toast.info('已取关')
|
toast.info('已取关')
|
||||||
} else {
|
} else {
|
||||||
await social.follow(userId.value)
|
await social.follow(userId.value)
|
||||||
toast.success('已关注')
|
toast.success('已关注')
|
||||||
}
|
}
|
||||||
await loadSocialCounts()
|
await loadSocialCounts()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof ApiError ? e.message : String(e)
|
const msg = e instanceof ApiError ? e.message : String(e)
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListTab = 'followers' | 'following'
|
type ListTab = 'followers' | 'following'
|
||||||
const drawer = reactive({
|
const drawer = reactive({
|
||||||
open: false,
|
open: false,
|
||||||
tab: 'followers' as ListTab,
|
tab: 'followers' as ListTab,
|
||||||
})
|
})
|
||||||
|
|
||||||
function openFollowers() {
|
function openFollowers() {
|
||||||
drawer.tab = 'followers'
|
drawer.tab = 'followers'
|
||||||
drawer.open = true
|
drawer.open = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFollowing() {
|
function openFollowing() {
|
||||||
drawer.tab = 'following'
|
drawer.tab = 'following'
|
||||||
drawer.open = true
|
drawer.open = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDrawer() {
|
function closeDrawer() {
|
||||||
drawer.open = false
|
drawer.open = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const listTitle = computed(() => (drawer.tab === 'followers' ? '粉丝' : '关注'))
|
const listTitle = computed(() => (drawer.tab === 'followers' ? '粉丝' : '关注'))
|
||||||
const listItems = computed(() => (drawer.tab === 'followers' ? state.followers : state.vloggers))
|
const listItems = computed(() => (drawer.tab === 'followers' ? state.followers : state.vloggers))
|
||||||
|
|
||||||
async function goUser(id: number) {
|
async function goUser(id: number) {
|
||||||
drawer.open = false
|
drawer.open = false
|
||||||
await router.push(`/u/${id}`)
|
await router.push(`/u/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goVideo(videoId: number) {
|
async function goVideo(videoId: number) {
|
||||||
await router.push(`/video/${videoId}`)
|
await router.push(`/video/${videoId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
async () => {
|
async () => {
|
||||||
drawer.open = false
|
drawer.open = false
|
||||||
await loadProfile()
|
await loadProfile()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => auth.isLoggedIn,
|
() => auth.isLoggedIn,
|
||||||
async () => {
|
async () => {
|
||||||
await loadSocialCounts()
|
await loadSocialCounts()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(loadProfile)
|
onMounted(loadProfile)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row" style="justify-content: space-between; align-items: flex-start">
|
<div class="row" style="justify-content: space-between; align-items: flex-start">
|
||||||
<div class="row" style="gap: 12px; align-items: center">
|
<div class="row" style="gap: 12px; align-items: center">
|
||||||
<UserAvatar :username="state.user?.username ?? 'User'" :id="state.user?.id ?? userId" :size="64" />
|
<UserAvatar :username="state.user?.username ?? 'User'" :id="state.user?.id ?? userId" :size="64" />
|
||||||
<div>
|
<div>
|
||||||
<div class="title" style="margin: 0">@{{ state.user?.username ?? '-' }}</div>
|
<div class="title" style="margin: 0">@{{ state.user?.username ?? '-' }}</div>
|
||||||
<div class="subtle mono">#{{ state.user?.id ?? userId }}</div>
|
<div class="subtle mono">#{{ state.user?.id ?? userId }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button v-if="isMe" class="ghost" type="button" @click="router.push('/account')">我的账号</button>
|
<button v-if="isMe" class="ghost" type="button" @click="router.push('/account')">我的账号</button>
|
||||||
<button v-else class="primary" type="button" :disabled="!state.user || state.loading" @click="toggleFollow">
|
<button v-else class="primary" type="button" :disabled="!state.user || state.loading" @click="toggleFollow">
|
||||||
{{ isFollowing ? '已关注' : '关注' }}
|
{{ isFollowing ? '已关注' : '关注' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="state.loading" class="hint" style="margin-top: 12px">加载中…</div>
|
<div v-if="state.loading" class="hint" style="margin-top: 12px">加载中…</div>
|
||||||
<div v-else-if="state.error" class="hint bad" style="margin-top: 12px">{{ state.error }}</div>
|
<div v-else-if="state.error" class="hint bad" style="margin-top: 12px">{{ state.error }}</div>
|
||||||
|
|
||||||
<div v-else class="row" style="margin-top: 14px">
|
<div v-else class="row" style="margin-top: 14px">
|
||||||
<button class="metric" type="button" :disabled="!auth.isLoggedIn || state.socialLoading" @click="openFollowers">
|
<button class="metric" type="button" :disabled="!auth.isLoggedIn || state.socialLoading" @click="openFollowers">
|
||||||
<div class="metric-num">{{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.followers.length) : '—' }}</div>
|
<div class="metric-num">{{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.followers.length) : '—' }}</div>
|
||||||
<div class="metric-label">粉丝</div>
|
<div class="metric-label">粉丝</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="metric" type="button" :disabled="!auth.isLoggedIn || state.socialLoading" @click="openFollowing">
|
<button class="metric" type="button" :disabled="!auth.isLoggedIn || state.socialLoading" @click="openFollowing">
|
||||||
<div class="metric-num">{{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.vloggers.length) : '—' }}</div>
|
<div class="metric-num">{{ auth.isLoggedIn ? (state.socialLoading ? '…' : state.vloggers.length) : '—' }}</div>
|
||||||
<div class="metric-label">关注</div>
|
<div class="metric-label">关注</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="metric static">
|
<div class="metric static">
|
||||||
<div class="metric-num">{{ state.videos.length }}</div>
|
<div class="metric-num">{{ state.videos.length }}</div>
|
||||||
<div class="metric-label">作品</div>
|
<div class="metric-label">作品</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!auth.isLoggedIn" class="subtle" style="margin-left: 8px">登录后可查看粉丝/关注列表</div>
|
<div v-if="!auth.isLoggedIn" class="subtle" style="margin-left: 8px">登录后可查看粉丝/关注列表</div>
|
||||||
<div v-else-if="state.socialError" class="subtle" style="margin-left: 8px">社交信息加载失败:{{ state.socialError }}</div>
|
<div v-else-if="state.socialError" class="subtle" style="margin-left: 8px">社交信息加载失败:{{ state.socialError }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top: 14px">
|
<div class="card" style="margin-top: 14px">
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
<p class="title" style="margin: 0">作品</p>
|
<p class="title" style="margin: 0">作品</p>
|
||||||
<div class="subtle">点击封面进入播放页</div>
|
<div class="subtle">点击封面进入播放页</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="state.videos.length === 0" class="hint" style="margin-top: 12px">暂无作品</div>
|
<div v-if="state.videos.length === 0" class="hint" style="margin-top: 12px">暂无作品</div>
|
||||||
|
|
||||||
<div v-else class="video-grid" style="margin-top: 12px">
|
<div v-else class="video-grid" style="margin-top: 12px">
|
||||||
<button v-for="v in state.videos" :key="v.id" class="video-card" type="button" @click="goVideo(v.id)">
|
<button v-for="v in state.videos" :key="v.id" class="video-card" type="button" @click="goVideo(v.id)">
|
||||||
<img class="video-cover" :src="v.cover_url" :alt="v.title" loading="lazy" />
|
<img class="video-cover" :src="v.cover_url" :alt="v.title" loading="lazy" />
|
||||||
<div class="video-meta">
|
<div class="video-meta">
|
||||||
<div class="video-title">{{ v.title }}</div>
|
<div class="video-title">{{ v.title }}</div>
|
||||||
<div class="video-sub subtle">❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }}</div>
|
<div class="video-sub subtle">❤️ {{ v.likes_count }} · {{ new Date(v.create_time).toLocaleDateString() }}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="drawer.open" class="drawer-backdrop" @click.self="closeDrawer">
|
<div v-if="drawer.open" class="drawer-backdrop" @click.self="closeDrawer">
|
||||||
<div class="drawer">
|
<div class="drawer">
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
<div class="drawer-title">{{ listTitle }}</div>
|
<div class="drawer-title">{{ listTitle }}</div>
|
||||||
<button class="drawer-x" type="button" @click="closeDrawer">×</button>
|
<button class="drawer-x" type="button" @click="closeDrawer">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-body">
|
<div class="drawer-body">
|
||||||
<div v-if="state.socialLoading" class="drawer-hint">加载中…</div>
|
<div v-if="state.socialLoading" class="drawer-hint">加载中…</div>
|
||||||
<div v-else-if="state.socialError" class="drawer-hint bad">{{ state.socialError }}</div>
|
<div v-else-if="state.socialError" class="drawer-hint bad">{{ state.socialError }}</div>
|
||||||
<div v-else-if="listItems.length === 0" class="drawer-hint">暂无</div>
|
<div v-else-if="listItems.length === 0" class="drawer-hint">暂无</div>
|
||||||
|
|
||||||
<button v-for="u in listItems" :key="u.id" class="user-row" type="button" @click="goUser(u.id)">
|
<button v-for="u in listItems" :key="u.id" class="user-row" type="button" @click="goUser(u.id)">
|
||||||
<UserAvatar :username="u.username" :id="u.id" :size="40" />
|
<UserAvatar :username="u.username" :id="u.id" :size="40" />
|
||||||
<div class="user-meta">
|
<div class="user-meta">
|
||||||
<div class="user-name">@{{ u.username }}</div>
|
<div class="user-name">@{{ u.username }}</div>
|
||||||
<div class="user-id mono">#{{ u.id }}</div>
|
<div class="user-id mono">#{{ u.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ghost {
|
.ghost {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
color: rgba(255, 255, 255, 0.86);
|
color: rgba(255, 255, 255, 0.86);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost:hover {
|
.ghost:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric {
|
.metric {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric.static {
|
.metric.static {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric:hover {
|
.metric:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric:disabled {
|
.metric:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-num {
|
.metric-num {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-label {
|
.metric-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.65);
|
color: rgba(255, 255, 255, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
color: rgba(255, 255, 255, 0.78);
|
color: rgba(255, 255, 255, 0.78);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint.bad {
|
.hint.bad {
|
||||||
color: rgba(254, 44, 85, 0.92);
|
color: rgba(254, 44, 85, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-grid {
|
.video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.video-grid {
|
.video-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.video-grid {
|
.video-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-card {
|
.video-card {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-card:hover {
|
.video-card:hover {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-cover {
|
.video-cover {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 9/12;
|
aspect-ratio: 9/12;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-meta {
|
.video-meta {
|
||||||
padding: 10px 10px;
|
padding: 10px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-title {
|
.video-title {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-sub {
|
.video-sub {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-backdrop {
|
.drawer-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer {
|
.drawer {
|
||||||
width: min(520px, calc(100vw - 18px));
|
width: min(520px, calc(100vw - 18px));
|
||||||
max-height: min(78vh, 720px);
|
max-height: min(78vh, 720px);
|
||||||
background: rgba(0, 0, 0, 0.65);
|
background: rgba(0, 0, 0, 0.65);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-head {
|
.drawer-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 14px 14px;
|
padding: 14px 14px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-title {
|
.drawer-title {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-x {
|
.drawer-x {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-body {
|
.drawer-body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-hint {
|
.drawer-hint {
|
||||||
color: rgba(255, 255, 255, 0.78);
|
color: rgba(255, 255, 255, 0.78);
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-hint.bad {
|
.drawer-hint.bad {
|
||||||
color: rgba(254, 44, 85, 0.92);
|
color: rgba(254, 44, 85, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-row {
|
.user-row {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 10px;
|
padding: 10px 10px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-row:hover {
|
.user-row:hover {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-meta {
|
.user-meta {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name {
|
.user-name {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-id {
|
.user-id {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
1372
frontend/src/views/VideoDetailView.vue
vendored
1372
frontend/src/views/VideoDetailView.vue
vendored
File diff suppressed because it is too large
Load Diff
692
frontend/src/views/VideoView.vue
vendored
692
frontend/src/views/VideoView.vue
vendored
@@ -1,346 +1,346 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onUnmounted, reactive, ref, watch } from 'vue'
|
import { onUnmounted, reactive, ref, watch } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import AppShell from '../components/AppShell.vue'
|
import AppShell from '../components/AppShell.vue'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import * as videoApi from '../api/video'
|
import * as videoApi from '../api/video'
|
||||||
import type { Video } from '../api/types'
|
import type { Video } from '../api/types'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const stage = ref('')
|
const stage = ref('')
|
||||||
const published = ref<Video | null>(null)
|
const published = ref<Video | null>(null)
|
||||||
|
|
||||||
const videoInput = ref<HTMLInputElement | null>(null)
|
const videoInput = ref<HTMLInputElement | null>(null)
|
||||||
const coverInput = ref<HTMLInputElement | null>(null)
|
const coverInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const publishForm = reactive({
|
const publishForm = reactive({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
video: null as File | null,
|
video: null as File | null,
|
||||||
cover: null as File | null,
|
cover: null as File | null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const preview = reactive({
|
const preview = reactive({
|
||||||
videoUrl: '',
|
videoUrl: '',
|
||||||
coverUrl: '',
|
coverUrl: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
function setPreviewVideo(file: File | null) {
|
function setPreviewVideo(file: File | null) {
|
||||||
if (preview.videoUrl) URL.revokeObjectURL(preview.videoUrl)
|
if (preview.videoUrl) URL.revokeObjectURL(preview.videoUrl)
|
||||||
preview.videoUrl = file ? URL.createObjectURL(file) : ''
|
preview.videoUrl = file ? URL.createObjectURL(file) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPreviewCover(file: File | null) {
|
function setPreviewCover(file: File | null) {
|
||||||
if (preview.coverUrl) URL.revokeObjectURL(preview.coverUrl)
|
if (preview.coverUrl) URL.revokeObjectURL(preview.coverUrl)
|
||||||
preview.coverUrl = file ? URL.createObjectURL(file) : ''
|
preview.coverUrl = file ? URL.createObjectURL(file) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => publishForm.video,
|
() => publishForm.video,
|
||||||
(f) => setPreviewVideo(f),
|
(f) => setPreviewVideo(f),
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => publishForm.cover,
|
() => publishForm.cover,
|
||||||
(f) => setPreviewCover(f),
|
(f) => setPreviewCover(f),
|
||||||
)
|
)
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
setPreviewVideo(null)
|
setPreviewVideo(null)
|
||||||
setPreviewCover(null)
|
setPreviewCover(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
function pickVideo(e: Event) {
|
function pickVideo(e: Event) {
|
||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
publishForm.video = input.files?.[0] ?? null
|
publishForm.video = input.files?.[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickCover(e: Event) {
|
function pickCover(e: Event) {
|
||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
publishForm.cover = input.files?.[0] ?? null
|
publishForm.cover = input.files?.[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function openVideoPicker() {
|
function openVideoPicker() {
|
||||||
videoInput.value?.click()
|
videoInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCoverPicker() {
|
function openCoverPicker() {
|
||||||
coverInput.value?.click()
|
coverInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearVideo() {
|
function clearVideo() {
|
||||||
publishForm.video = null
|
publishForm.video = null
|
||||||
if (videoInput.value) videoInput.value.value = ''
|
if (videoInput.value) videoInput.value.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCover() {
|
function clearCover() {
|
||||||
publishForm.cover = null
|
publishForm.cover = null
|
||||||
if (coverInput.value) coverInput.value.value = ''
|
if (coverInput.value) coverInput.value.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPublish() {
|
async function onPublish() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
if (!auth.isLoggedIn) {
|
if (!auth.isLoggedIn) {
|
||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
await router.push('/account')
|
await router.push('/account')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = publishForm.title.trim()
|
const title = publishForm.title.trim()
|
||||||
const description = publishForm.description.trim()
|
const description = publishForm.description.trim()
|
||||||
if (!title) {
|
if (!title) {
|
||||||
toast.error('请输入 title')
|
toast.error('请输入 title')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!publishForm.video) {
|
if (!publishForm.video) {
|
||||||
toast.error('请选择视频文件(.mp4)')
|
toast.error('请选择视频文件(.mp4)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!publishForm.cover) {
|
if (!publishForm.cover) {
|
||||||
toast.error('请选择封面图片(jpg/png/webp)')
|
toast.error('请选择封面图片(jpg/png/webp)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
busy.value = true
|
busy.value = true
|
||||||
stage.value = ''
|
stage.value = ''
|
||||||
published.value = null
|
published.value = null
|
||||||
try {
|
try {
|
||||||
stage.value = '上传封面'
|
stage.value = '上传封面'
|
||||||
const coverRes = await videoApi.uploadCover(publishForm.cover!)
|
const coverRes = await videoApi.uploadCover(publishForm.cover!)
|
||||||
|
|
||||||
stage.value = '上传视频'
|
stage.value = '上传视频'
|
||||||
const videoRes = await videoApi.uploadVideo(publishForm.video!)
|
const videoRes = await videoApi.uploadVideo(publishForm.video!)
|
||||||
|
|
||||||
const coverUrl = coverRes.url || coverRes.cover_url || ''
|
const coverUrl = coverRes.url || coverRes.cover_url || ''
|
||||||
const playUrl = videoRes.url || videoRes.play_url || ''
|
const playUrl = videoRes.url || videoRes.play_url || ''
|
||||||
if (!coverUrl || !playUrl) {
|
if (!coverUrl || !playUrl) {
|
||||||
toast.error('上传成功但缺少 url')
|
toast.error('上传成功但缺少 url')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stage.value = '发布视频'
|
stage.value = '发布视频'
|
||||||
const res = await videoApi.publishVideo({ title, description, play_url: playUrl, cover_url: coverUrl })
|
const res = await videoApi.publishVideo({ title, description, play_url: playUrl, cover_url: coverUrl })
|
||||||
|
|
||||||
published.value = res
|
published.value = res
|
||||||
toast.success('已发布')
|
toast.success('已发布')
|
||||||
|
|
||||||
publishForm.title = ''
|
publishForm.title = ''
|
||||||
publishForm.description = ''
|
publishForm.description = ''
|
||||||
clearVideo()
|
clearVideo()
|
||||||
clearCover()
|
clearCover()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof ApiError ? e.message : String(e)
|
const msg = e instanceof ApiError ? e.message : String(e)
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
stage.value = ''
|
stage.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div class="publish-wrap">
|
<div class="publish-wrap">
|
||||||
<div class="card publish-card">
|
<div class="card publish-card">
|
||||||
<div class="row" style="justify-content: space-between; align-items: baseline">
|
<div class="row" style="justify-content: space-between; align-items: baseline">
|
||||||
<p class="title" style="margin: 0">发布视频</p>
|
<p class="title" style="margin: 0">发布视频</p>
|
||||||
<div v-if="busy" class="pill">进行中:{{ stage || '…' }}</div>
|
<div v-if="busy" class="pill">进行中:{{ stage || '…' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="subtle" style="margin-top: 10px">选择视频文件与封面图片,上传到本机后自动生成 URL,再写入 `/video/publish`。</p>
|
<p class="subtle" style="margin-top: 10px">选择视频文件与封面图片,上传到本机后自动生成 URL,再写入 `/video/publish`。</p>
|
||||||
|
|
||||||
<div class="grid form-grid" style="margin-top: 16px">
|
<div class="grid form-grid" style="margin-top: 16px">
|
||||||
<div>
|
<div>
|
||||||
<label>title</label>
|
<label>title</label>
|
||||||
<input v-model.trim="publishForm.title" class="big-input" :disabled="busy" />
|
<input v-model.trim="publishForm.title" class="big-input" :disabled="busy" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>description</label>
|
<label>description</label>
|
||||||
<textarea v-model.trim="publishForm.description" class="big-input" :disabled="busy" />
|
<textarea v-model.trim="publishForm.description" class="big-input" :disabled="busy" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid two">
|
<div class="grid two">
|
||||||
<div>
|
<div>
|
||||||
<label>video (.mp4)</label>
|
<label>video (.mp4)</label>
|
||||||
<input ref="videoInput" class="file-native" type="file" accept="video/mp4" :disabled="busy" @change="pickVideo" />
|
<input ref="videoInput" class="file-native" type="file" accept="video/mp4" :disabled="busy" @change="pickVideo" />
|
||||||
<div class="file-box">
|
<div class="file-box">
|
||||||
<button type="button" :disabled="busy" @click="openVideoPicker">选择视频</button>
|
<button type="button" :disabled="busy" @click="openVideoPicker">选择视频</button>
|
||||||
<div class="file-name" :class="publishForm.video ? '' : 'muted'">
|
<div class="file-name" :class="publishForm.video ? '' : 'muted'">
|
||||||
{{ publishForm.video ? publishForm.video.name : '未选择文件' }}
|
{{ publishForm.video ? publishForm.video.name : '未选择文件' }}
|
||||||
</div>
|
</div>
|
||||||
<button v-if="publishForm.video" type="button" :disabled="busy" @click="clearVideo">清除</button>
|
<button v-if="publishForm.video" type="button" :disabled="busy" @click="clearVideo">清除</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="publishForm.video" class="subtle" style="margin-top: 6px">
|
<div v-if="publishForm.video" class="subtle" style="margin-top: 6px">
|
||||||
已选择:{{ publishForm.video.name }}({{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB)
|
已选择:{{ publishForm.video.name }}({{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>cover (jpg/png/webp)</label>
|
<label>cover (jpg/png/webp)</label>
|
||||||
<input
|
<input
|
||||||
ref="coverInput"
|
ref="coverInput"
|
||||||
class="file-native"
|
class="file-native"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp"
|
accept="image/jpeg,image/png,image/webp"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@change="pickCover"
|
@change="pickCover"
|
||||||
/>
|
/>
|
||||||
<div class="file-box">
|
<div class="file-box">
|
||||||
<button type="button" :disabled="busy" @click="openCoverPicker">选择封面</button>
|
<button type="button" :disabled="busy" @click="openCoverPicker">选择封面</button>
|
||||||
<div class="file-name" :class="publishForm.cover ? '' : 'muted'">
|
<div class="file-name" :class="publishForm.cover ? '' : 'muted'">
|
||||||
{{ publishForm.cover ? publishForm.cover.name : '未选择文件' }}
|
{{ publishForm.cover ? publishForm.cover.name : '未选择文件' }}
|
||||||
</div>
|
</div>
|
||||||
<button v-if="publishForm.cover" type="button" :disabled="busy" @click="clearCover">清除</button>
|
<button v-if="publishForm.cover" type="button" :disabled="busy" @click="clearCover">清除</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="publishForm.cover" class="subtle" style="margin-top: 6px">已选择:{{ publishForm.cover.name }}</div>
|
<div v-if="publishForm.cover" class="subtle" style="margin-top: 6px">已选择:{{ publishForm.cover.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="preview.coverUrl || preview.videoUrl" class="grid two">
|
<div v-if="preview.coverUrl || preview.videoUrl" class="grid two">
|
||||||
<div v-if="preview.coverUrl" class="preview-card">
|
<div v-if="preview.coverUrl" class="preview-card">
|
||||||
<div class="subtle">封面预览</div>
|
<div class="subtle">封面预览</div>
|
||||||
<img class="cover" :src="preview.coverUrl" alt="cover preview" />
|
<img class="cover" :src="preview.coverUrl" alt="cover preview" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="preview.videoUrl" class="preview-card">
|
<div v-if="preview.videoUrl" class="preview-card">
|
||||||
<div class="subtle">视频预览</div>
|
<div class="subtle">视频预览</div>
|
||||||
<video class="video" :src="preview.videoUrl" controls playsinline preload="metadata" />
|
<video class="video" :src="preview.videoUrl" controls playsinline preload="metadata" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" style="justify-content: flex-end; margin-top: 8px">
|
<div class="row" style="justify-content: flex-end; margin-top: 8px">
|
||||||
<button class="primary big-btn" type="button" :disabled="busy" @click="onPublish">发布</button>
|
<button class="primary big-btn" type="button" :disabled="busy" @click="onPublish">发布</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="published" class="card" style="margin-top: 14px">
|
<div v-if="published" class="card" style="margin-top: 14px">
|
||||||
<p class="title">已发布</p>
|
<p class="title">已发布</p>
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="title" style="margin: 0">{{ published.title }}</div>
|
<div class="title" style="margin: 0">{{ published.title }}</div>
|
||||||
<div class="subtle mono">#{{ published.id }}</div>
|
<div class="subtle mono">#{{ published.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<RouterLink class="pill" :to="`/video/${published.id}`">去播放</RouterLink>
|
<RouterLink class="pill" :to="`/video/${published.id}`">去播放</RouterLink>
|
||||||
<a class="pill mono" :href="published.play_url" target="_blank" rel="noreferrer">play_url</a>
|
<a class="pill mono" :href="published.play_url" target="_blank" rel="noreferrer">play_url</a>
|
||||||
<a class="pill mono" :href="published.cover_url" target="_blank" rel="noreferrer">cover_url</a>
|
<a class="pill mono" :href="published.cover_url" target="_blank" rel="noreferrer">cover_url</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.publish-wrap {
|
.publish-wrap {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publish-card {
|
.publish-card {
|
||||||
width: min(980px, 100%);
|
width: min(980px, 100%);
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid .grid.two {
|
.form-grid .grid.two {
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid .grid.two > * {
|
.form-grid .grid.two > * {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid input[type='file'] {
|
.form-grid input[type='file'] {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-native {
|
.file-native {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: -1px;
|
margin: -1px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
clip: rect(0, 0, 0, 0);
|
clip: rect(0, 0, 0, 0);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-box {
|
.file-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-box button {
|
.file-box button {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name {
|
.file-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(255, 255, 255, 0.88);
|
color: rgba(255, 255, 255, 0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: rgba(255, 255, 255, 0.55);
|
color: rgba(255, 255, 255, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.big-input {
|
.big-input {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.big-btn {
|
.big-btn {
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-card {
|
.preview-card {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover {
|
.cover {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 9/12;
|
aspect-ratio: 9/12;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video {
|
.video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user