Compare commits

...

2 Commits

17 changed files with 401 additions and 106 deletions

136
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,136 @@
name: CI/CD
on:
pull_request:
push:
branches:
- main
- master
concurrency:
group: ci-${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
backend:
name: Backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.x"
cache-dependency-path: backend/go.sum
- name: Download dependencies
run: go mod download
- name: Vet
run: go vet ./...
- name: Test
run: go test -race -count=1 ./...
frontend:
name: Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
build-and-push:
name: Build & Push
runs-on: ubuntu-latest
needs: [backend, frontend]
if: ${{ gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Container Registry
run: |
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build & Push Backend API
run: |
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
REPO="${{ gitea.repository }}"
docker build -f backend/Dockerfile --target api \
-t "$REGISTRY_HOST/$REPO/backend-api:${{ gitea.sha }}" \
-t "$REGISTRY_HOST/$REPO/backend-api:latest" \
.
docker push "$REGISTRY_HOST/$REPO/backend-api" --all-tags
- name: Build & Push Backend Worker
run: |
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
REPO="${{ gitea.repository }}"
docker build -f backend/Dockerfile --target worker \
-t "$REGISTRY_HOST/$REPO/backend-worker:${{ gitea.sha }}" \
-t "$REGISTRY_HOST/$REPO/backend-worker:latest" \
.
docker push "$REGISTRY_HOST/$REPO/backend-worker" --all-tags
- name: Build & Push Frontend
run: |
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||')
REPO="${{ gitea.repository }}"
docker build -f frontend/Dockerfile \
-t "$REGISTRY_HOST/$REPO/frontend:${{ gitea.sha }}" \
-t "$REGISTRY_HOST/$REPO/frontend:latest" \
.
docker push "$REGISTRY_HOST/$REPO/frontend" --all-tags
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: [build-and-push]
if: ${{ gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/vloop-deploy
chmod 600 ~/.ssh/vloop-deploy
- name: Copy nginx.prod.conf to server
run: |
scp -o StrictHostKeyChecking=no -i ~/.ssh/vloop-deploy \
frontend/nginx.prod.conf \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:/opt/vloop/nginx.prod.conf
- name: Pull images & restart services
run: |
ssh -o StrictHostKeyChecking=no -i ~/.ssh/vloop-deploy \
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \
"cd /opt/vloop && \
sed -i 's/^TAG=.*/TAG=${{ gitea.sha }}/' .env && \
docker compose -f docker-compose.prod.yml pull && \
docker compose -f docker-compose.prod.yml up -d --remove-orphans && \
docker image prune -f"

View File

@@ -1,64 +0,0 @@
name: CI
on:
pull_request:
push:
branches:
- main
- master
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Backend
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24.x"
cache-dependency-path: backend/go.sum
- name: Download dependencies
run: go mod download
- name: Vet
run: go vet ./...
- name: Test
run: go test -race -count=1 ./...
frontend:
name: Frontend
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build

View File

@@ -17,8 +17,8 @@ import (
"syscall"
"time"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/joho/godotenv"
amqp "github.com/rabbitmq/amqp091-go"
"gorm.io/gorm"
)
@@ -40,6 +40,7 @@ const (
popularityBindingKey = "video.popularity.*"
)
// 带重试机制的基础设施连接函数
func connectWithRetry(name string, maxRetries int, fn func() error) {
for i := 0; i < maxRetries; i++ {
if err := fn(); err == nil {
@@ -55,7 +56,7 @@ func connectWithRetry(name string, maxRetries int, fn func() error) {
log.Fatalf("%s: 超过最大重试次数", name)
}
// runWorkerWithRetry 为每个 Worker 创建独立 Channel断开后自动重连
// runWorkerWithRetry 为每个 Worker 创建独立 Channel 并设置 QoS,断开后自动重连
func runWorkerWithRetry(ctx context.Context, name string, conn *amqp.Connection, fn func(*amqp.Channel) error) {
for {
select {

View File

@@ -168,6 +168,7 @@ func (f *FeedHandler) ListByPopularity(c *gin.Context) {
c.JSON(200, resp)
}
// 在返回的 FeedVideoItem 列表中,如果列表为 nil则返回空切片避免 JSON 序列化为 null
func nonNilFeedVideoItems(items []FeedVideoItem) []FeedVideoItem {
if items == nil {
return []FeedVideoItem{}

View File

@@ -17,6 +17,7 @@ func NewFeedRepository(db *gorm.DB) *FeedRepository {
return &FeedRepository{db: db}
}
// 查询最新视频. limit: 查询数量 latestBefore: 查询早于此时间的视频(零值则不限制)
func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBefore time.Time) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
@@ -30,6 +31,7 @@ func (repo *FeedRepository) ListLatest(ctx context.Context, limit int, latestBef
return videos, nil
}
// 查询点赞数最多的视频. limit: 查询数量 cursor: 游标
func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit int, cursor *LikesCountCursor) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
@@ -49,6 +51,7 @@ func (repo *FeedRepository) ListLikesCountWithCursor(ctx context.Context, limit
return videos, nil
}
// 查询关注用户的视频. limit: 查询数量 viewerAccountID: 查看者账户ID latestBefore: 查询早于此时间的视频(零值则不限制)
func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, viewerAccountID uint, latestBefore time.Time) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).
@@ -69,6 +72,7 @@ func (repo *FeedRepository) ListByFollowing(ctx context.Context, limit int, view
return videos, nil
}
// 查询热门视频. limit: 查询数量 popularityBefore: 查询热度低于此值的视频 timeBefore: 查询早于此时间的视频 idBefore: 查询ID小于此值的视频
func (repo *FeedRepository) ListByPopularity(ctx context.Context, limit int, popularityBefore int64, timeBefore time.Time, idBefore uint) ([]*video.Video, error) {
var videos []*video.Video
query := repo.db.WithContext(ctx).Model(&video.Video{}).

View File

@@ -33,9 +33,9 @@ func NewFeedService(repo *FeedRepository, likeRepo *video.LikeRepository, redisc
return &FeedService{repo: repo, likeRepo: likeRepo, rediscache: rediscache, localcache: cache.New(3*time.Second, 5*time.Second), cacheTTL: 24 * time.Hour}
}
// GetVideoByIDs 批量获取视频信息
// 采用 L1(本地缓存) -> L2(Redis) -> L3(MySQL) 三级架构
func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*video.Video, error) {
// GetVideoByIDs 批量获取视频信息
// 采用 L1(本地缓存) -> L2(Redis) -> L3(MySQL) 三级架构
if len(videoIDs) == 0 {
return []*video.Video{}, nil
}
@@ -109,6 +109,8 @@ func (f *FeedService) GetVideoByIDs(ctx context.Context, videoIDs []uint) ([]*vi
wg.Add(1)
go func(videoID uint) {
defer wg.Done()
// singleflight 防止缓存击穿
sfKey := f.rediscache.Key("sf:entity:%d", videoID)
v, err, _ := f.requestGroup.Do(sfKey, func() (interface{}, error) {
@@ -160,6 +162,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
isZsetEmpty := len(zsetTail) == 0
// ZSet 为空时尝试重建 ZSet
if isZsetEmpty {
//全局静态锁:无视所有用户的不同时间戳游标
sfKey := f.rediscache.Key("sf:fallback:global_timeline_rebuild")
@@ -195,10 +198,11 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
return ListLatestResponse{HasMore: false}, nil
}
// 让所有被阻塞的请求重新查一遍
// 递归调用自己,让所有被阻塞的请求重新查一遍
return f.ListLatest(ctx, limit, latestBefore, viewerAccountID)
}
// watermark 是 ZSET 中最老的一条数据的时间戳; reqTime 是本次请求的时间戳(如果没有传 latestBefore则使用当前时间)
watermark := int64(zsetTail[0].Score)
reqTime := time.Now().UnixMilli()
if !latestBefore.IsZero() {
@@ -228,11 +232,19 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
maxScore = fmt.Sprintf("%d", reqTime-1) // 防重复
}
videoIDsStr, err := f.rediscache.ZRevRangeByScore(ctx, f.rediscache.Key("feed:global_timeline"), maxScore, "-inf", 0, int64(limit))
videoIDsStr, err := f.rediscache.ZRevRangeByScore(
ctx,
f.rediscache.Key("feed:global_timeline"),
maxScore,
"-inf",
0,
int64(limit),
)
if err != nil {
return ListLatestResponse{}, err
}
// 将字符串 ID 转换为 uint
var videoIDs []uint
for _, idStr := range videoIDsStr {
if id, err := strconv.ParseUint(idStr, 10, 64); err == nil {
@@ -247,7 +259,7 @@ func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore ti
}
}
// 刚好击穿了冷热边界
// 刚好击穿了冷热边界,从数据库中再拉一些冷数据补齐
if len(baseVideos) < limit {
remainLimit := limit - len(baseVideos) // 计算还差几个
@@ -381,6 +393,7 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo
token, locked, _ := f.rediscache.Lock(cacheCtx, lockKey, 500*time.Millisecond)
if locked {
defer func() { _ = f.rediscache.Unlock(context.Background(), lockKey, token) }()
// Double check再次检查缓存是否被其他请求回写
if b, err := f.rediscache.GetBytes(cacheCtx, cacheKey); err == nil {
var cached ListByFollowingResponse
if err := json.Unmarshal(b, &cached); err == nil {
@@ -396,7 +409,7 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo
}
return resp, nil
}
} else {
} else { // 加锁失败,循环等待缓存被其他请求回写
for i := 0; i < 5; i++ {
time.Sleep(20 * time.Millisecond)
if b, err := f.rediscache.GetBytes(cacheCtx, cacheKey); err == nil {
@@ -410,11 +423,12 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo
}
}
// 缓存未命中或 Redis 不可用,降级成数据库查询
resp, err := doListByFollowingFromDB()
if err != nil {
return ListByFollowingResponse{}, err
}
if cacheKey != "" {
if cacheKey != "" { // 缓存回写
if b, err := json.Marshal(resp); err == nil {
cacheCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
@@ -427,22 +441,25 @@ func (f *FeedService) ListByFollowing(ctx context.Context, limit int, latestBefo
func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf int64, offset int, viewerAccountID uint, latestPopularity int64, latestBefore time.Time, latestIDBefore uint) (ListByPopularityResponse, error) {
// Redis 热榜稳定分页as_of + offset
if f.rediscache != nil {
// 将 as_of 截断到分钟级
asOf := time.Now().UTC().Truncate(time.Minute)
if reqAsOf > 0 {
asOf = time.Unix(reqAsOf, 0).UTC().Truncate(time.Minute)
}
// 创建时间窗口,获取过去 60 分钟的 ZSET
const win = 60
keys := make([]string, 0, win)
for i := 0; i < win; i++ {
keys = append(keys, f.rediscache.Key("hot:video:1m:%s", asOf.Add(-time.Duration(i)*time.Minute).Format("200601021504")))
}
dest := f.rediscache.Key("hot:video:merge:1m:%s", asOf.Format("200601021504")) // 快照key同一个as_of页内复用
// 创建快照key同一个as_of页内复用
dest := f.rediscache.Key("hot:video:merge:1m:%s", asOf.Format("200601021504"))
opCtx, cancel := context.WithTimeout(ctx, 80*time.Millisecond)
defer cancel()
exists, _ := f.rediscache.Exists(opCtx, dest)
exists, _ := f.rediscache.Exists(opCtx, dest) // 检查合并快照是否已存在
if !exists {
_ = f.rediscache.ZUnionStore(opCtx, dest, keys, "SUM")
_ = f.rediscache.Expire(opCtx, dest, 2*time.Minute) // 给翻页留时间
@@ -462,6 +479,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
}
}
if err == nil && len(members) > 0 {
// 将字符串 ID 转换为 uint
ids := make([]uint, 0, len(members))
for _, m := range members {
u, err := strconv.ParseUint(m, 10, 64)
@@ -470,6 +488,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
}
}
// 根据 ID 批量获取视频信息
videos, err := f.repo.GetByIDs(ctx, ids)
if err == nil {
byID := make(map[uint]*video.Video, len(videos))
@@ -477,7 +496,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
byID[v.ID] = v
}
ordered := make([]*video.Video, 0, len(ids))
for _, id := range ids {
for _, id := range ids { // 按 Redis 返回的顺序重新排列
if v := byID[id]; v != nil {
ordered = append(ordered, v)
}
@@ -492,7 +511,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
NextOffset: offset + len(items),
HasMore: len(items) == limit,
}
if len(ordered) > 0 {
if len(ordered) > 0 { // 准备最后一条视频的游标信息,供 DB fallback 使用
last := ordered[len(ordered)-1]
nextPopularity := last.Popularity
nextBefore := last.CreateTime
@@ -506,6 +525,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
}
}
// DB fallback游标分页latestPopularity + latestBefore + latestIDBefore
videos, err := f.repo.ListByPopularity(ctx, limit, latestPopularity, latestBefore, latestIDBefore)
if err != nil {
return ListByPopularityResponse{}, err
@@ -532,6 +552,7 @@ func (f *FeedService) ListByPopularity(ctx context.Context, limit int, reqAsOf i
return resp, nil
}
// 将 Video 列表转换为 FeedVideoItem 列表,并批量查询填充当前用户对所有视频的点赞状态
func (f *FeedService) buildFeedVideos(ctx context.Context, videos []*video.Video, viewerAccountID uint) ([]FeedVideoItem, error) {
feedVideos := make([]FeedVideoItem, 0, len(videos))
videoIDs := make([]uint, len(videos))
@@ -558,6 +579,7 @@ func (f *FeedService) buildFeedVideos(ctx context.Context, videos []*video.Video
return feedVideos, nil
}
// 将视频列表按照给定的 ID 顺序(orderedIDs)重新排序
func buildOrderedResult(orderedIDs []uint, dataMap map[uint]*video.Video) []*video.Video {
res := make([]*video.Video, 0, len(orderedIDs))
for _, id := range orderedIDs {

View File

@@ -23,7 +23,7 @@ func DeclareDLX(ch *amqp.Channel, queueName string) error {
}
dlxQueue := queueName + ".dlx"
_, err := ch.QueueDeclare(
dlxQueue, true, false, false, false, nil,
dlxQueue, true, false, false, false, nil, // 死信队列不设置 DLX
)
if err != nil {
return err

View File

@@ -17,8 +17,8 @@ const (
likeQueue = "like.events"
likeBindingKey = "like.*"
likeLikeRK = "like.like"
likeUnlikeRK = "like.unlike"
likeLikeRK = "like.like" // 点赞路由键
likeUnlikeRK = "like.unlike" // 取消点赞路由键
)
type LikeEvent struct {

View File

@@ -69,12 +69,12 @@ func DeclareTopic(ch *amqp.Channel, exchange string, queue string, bindingKey st
}
q, err := ch.QueueDeclare(
queue,
true,
false,
false,
false,
amqp.Table{"x-dead-letter-exchange": DLXExchange},
queue, // 队列名称
true, // 持久化
false, // autoDelete 是否在未使用时自动删除
false, // exclusive 是否排他(允许多个消费者共享)
false, // noWait 是否不等待 broker 确认
amqp.Table{"x-dead-letter-exchange": DLXExchange}, // 死信交换机
)
if err != nil {
return err
@@ -84,8 +84,8 @@ func DeclareTopic(ch *amqp.Channel, exchange string, queue string, bindingKey st
q.Name,
bindingKey,
exchange,
false,
nil,
false, // noWait
nil, // args
); err != nil {
return err
}
@@ -107,10 +107,10 @@ func PublishJSON(ctx context.Context, ch *amqp.Channel, exchange string, routing
return err
}
return ch.PublishWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),
Body: b,
ContentType: "application/json", // 消息格式
DeliveryMode: amqp.Persistent, // 消息持久化到磁盘, 值为 1 则不持久化, 值为 2 则持久化, amqp.Presistent 为 2
Timestamp: time.Now(), // 消息产生的时间戳
Body: b, // 实际消息内容(JSON字节)
})
}

View File

@@ -91,6 +91,7 @@ func (s *SocialService) Unfollow(ctx context.Context, social *Social) error {
return nil
}
// 失效关注列表缓存,确保下次查询时能获取到最新的关注列表
func (s *SocialService) invalidateFollowingFeedCache(ctx context.Context, accountID uint) {
if s.cache == nil {
return

View File

@@ -62,6 +62,7 @@ func (r *LikeRepository) IsLiked(ctx context.Context, videoID, accountID uint) (
return count > 0, nil
}
// 查询给定视频 ID 列表中,哪些视频被指定账户点赞过
func (r *LikeRepository) BatchGetLiked(ctx context.Context, videoIDs []uint, accountID uint) (map[uint]bool, error) {
likeMap := make(map[uint]bool)
if len(videoIDs) == 0 {

View File

@@ -32,13 +32,13 @@ func (w *LikeWorker) Run(ctx context.Context) error {
}
deliveries, err := w.ch.Consume(
w.queue,
"",
false,
false,
false,
false,
nil,
w.queue, // 队列名
"", // 消费者标签,空代表让 RabbitMQ 自动生成一个唯一的标签
false, // autoAck = false 采用手动确认模式
false, // exclusive = false 允许多个消费者同时消费同一个队列
false, // noLocal = false 允许消费者接收自己发送的消息
false, // noWait = false 阻塞等待 RabbitMQ 的响应
nil, // args
)
if err != nil {
return err

View File

@@ -95,7 +95,7 @@ func (w *NotificationWorker) process(ctx context.Context, d amqp.Delivery) error
if len(body) == 0 {
return nil
}
routingKey := d.RoutingKey
routingKey := d.RoutingKey // 复用路由键充当事件类型标识
var notif *Notification

View File

@@ -14,6 +14,7 @@ import (
"gorm.io/gorm"
)
// 轮询器,轮询数据库中的 outbox 表,获取待投递的消息,投递到 MQ 中
func StartOutboxPoller(db *gorm.DB, tmq *rabbitmq.TimelineMQ) {
if db == nil || tmq == nil {
log.Printf("Outbox poller disabled: timeline mq is not initialized")
@@ -46,6 +47,7 @@ func StartOutboxPoller(db *gorm.DB, tmq *rabbitmq.TimelineMQ) {
}()
}
// 消费者,消费 MQ 中的消息,写入 Redis 的 Zset 中
func StartConsumer(tmq *rabbitmq.TimelineMQ, queueName string, redisClient *rediscache.Client, rmq *rabbitmq.RabbitMQ) {
if tmq == nil || rmq == nil || rmq.Conn == nil {
log.Printf("Timeline consumer disabled: rabbitmq is not initialized")
@@ -66,6 +68,8 @@ func StartConsumer(tmq *rabbitmq.TimelineMQ, queueName string, redisClient *redi
continue
}
// 设置当前 Channel 的 QoSQuality of Service参数控制消息的预取数量和大小
// prefetch count = 10 一次最多取10个消息, prefetch size = 0 不限制预取的字节大小, global = false Qos 设置只对当前 Channel 生效
if err := ch.Qos(10, 0, false); err != nil {
log.Printf("Timeline consumer: QoS 设置失败: %v", err)
}

124
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,124 @@
services:
mysql:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
TZ: "Asia/Shanghai"
volumes:
- mysql_data:/var/lib/mysql
command:
- --default-authentication-plugin=mysql_native_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
restart: always
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping"]
interval: 5s
timeout: 3s
retries: 20
rabbitmq:
image: rabbitmq:3-management
restart: always
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD-SHELL", "rabbitmq-diagnostics -q ping"]
interval: 5s
timeout: 5s
retries: 20
backend:
image: ${REGISTRY}/${OWNER}/${REPO}/backend-api:${TAG}
restart: always
environment:
CONFIG_PATH: /app/configs/config.yaml
# Docker 内部服务发现 —— 覆盖 baked-in config.yaml 中的 localhost
MYSQL_HOST: mysql
REDIS_HOST: redis
RABBITMQ_HOST: rabbitmq
# 密码和密钥 —— 从 .env 注入
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
REDIS_PASSWORD: ${REDIS_PASSWORD}
RABBITMQ_USER: ${RABBITMQ_USER}
RABBITMQ_PASS: ${RABBITMQ_PASS}
JWT_SECRET: ${JWT_SECRET}
volumes:
- backend_uploads:/app/.run/uploads
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 10s
timeout: 5s
retries: 3
worker:
image: ${REGISTRY}/${OWNER}/${REPO}/backend-worker:${TAG}
restart: always
environment:
CONFIG_PATH: /app/configs/config.yaml
MYSQL_HOST: mysql
REDIS_HOST: redis
RABBITMQ_HOST: rabbitmq
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
REDIS_PASSWORD: ${REDIS_PASSWORD}
RABBITMQ_USER: ${RABBITMQ_USER}
RABBITMQ_PASS: ${RABBITMQ_PASS}
JWT_SECRET: ${JWT_SECRET}
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pgrep worker || exit 1"]
interval: 15s
timeout: 5s
retries: 3
frontend:
image: ${REGISTRY}/${OWNER}/${REPO}/frontend:${TAG}
restart: always
ports:
- "127.0.0.1:9001:80"
volumes:
- ./nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:80/ || exit 1"]
interval: 10s
timeout: 5s
retries: 3
volumes:
mysql_data:
redis_data:
rabbitmq_data:
backend_uploads:

61
frontend/nginx.prod.conf Normal file
View File

@@ -0,0 +1,61 @@
server {
listen 80;
server_name _;
# Allow large uploads (e.g. videos)
client_max_body_size 300m;
root /usr/share/nginx/html;
index index.html;
# SSE notification stream — must disable buffering for real-time push
location /notification/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Health check
location /healthz {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA routing (Vue Router history mode)
location / {
try_files $uri $uri/ /index.html;
}
# Reverse proxy to backend (strip /api prefix)
location /api/ {
proxy_pass http://backend:8080/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Serve uploaded files via backend static route
location /static/ {
proxy_pass http://backend:8080/static/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
}

View File

@@ -2,18 +2,20 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"lib": [
"ES2023"
],
"module": "ESNext",
"types": ["node"],
"types": [
"node"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
@@ -22,5 +24,7 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
"include": [
"vite.config.ts"
]
}