Compare commits
149 Commits
d3065b30cc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a971fb0e29 | ||
|
|
eb931bf1c6 | ||
|
|
d240485d0e | ||
|
|
d8e56e60dc | ||
|
|
28cfcbf9a8 | ||
|
|
322aa9eebb | ||
|
|
cb85c62093 | ||
|
|
821e066446 | ||
|
|
5b83f98fb8 | ||
|
|
755392e999 | ||
|
|
a0e210feab | ||
|
|
d9aa4b46c3 | ||
|
|
9951043034 | ||
|
|
60654bc0f4 | ||
|
|
c741a7d61d | ||
|
|
34992030b9 | ||
|
|
14114db68d | ||
|
|
9356cb6876 | ||
|
|
2084473fb5 | ||
|
|
2748c80095 | ||
|
|
3fc8b38184 | ||
|
|
6bf87fec46 | ||
|
|
f63c795dcb | ||
|
|
de0766df5e | ||
|
|
6c14309624 | ||
|
|
570b2d7afe | ||
|
|
9a1851f023 | ||
|
|
d2894066f8 | ||
|
|
d3e5d6d76b | ||
| dfca33da1c | |||
|
|
f0abb624ee | ||
|
|
aa23314ab8 | ||
|
|
9632117ad1 | ||
|
|
b872831c64 | ||
| a74aa8fdc9 | |||
|
|
d0852f0e78 | ||
|
|
06db8e36e4 | ||
|
|
8d7e8c427b | ||
|
|
2f2bbc646e | ||
|
|
628a6acbe9 | ||
|
|
43348615c0 | ||
|
|
0b7faf08c0 | ||
|
|
9f3215d4eb | ||
|
|
51fe517cb2 | ||
|
|
8aa85ca4b2 | ||
|
|
dc27eb5cde | ||
|
|
5f1b8bb451 | ||
|
|
c630cbf4d0 | ||
|
|
d481742790 | ||
|
|
af1ecaf71e | ||
|
|
90c57f1a1c | ||
|
|
ee78071d4d | ||
|
|
b2b55ea52d | ||
|
|
67a5660952 | ||
|
|
179e468131 | ||
|
|
56c61f1895 | ||
|
|
8b1e411c89 | ||
|
|
496a0103ea | ||
|
|
fb85c9c20a | ||
| f03bbf6faa | |||
|
|
5d12397e29 | ||
|
|
f99687c405 | ||
| 19fd159cdf | |||
|
|
3e32b28578 | ||
| 84683d5efa | |||
|
|
0ca02f7ef3 | ||
|
|
1b50d6c792 | ||
|
|
27ea8689f9 | ||
|
|
02466603f9 | ||
|
|
23d7f1151e | ||
|
|
898c0e6d9c | ||
|
|
e5c8fd7860 | ||
|
|
d8b0825ac0 | ||
|
|
52f62ef230 | ||
|
|
3215039ff6 | ||
|
|
48f0c4cf2d | ||
|
|
e0f34653a2 | ||
|
|
323f872aa9 | ||
|
|
3e90eefd51 | ||
|
|
496c8bb1aa | ||
|
|
813e54e5b2 | ||
|
|
05c7f8a5eb | ||
|
|
15e7c99353 | ||
|
|
869d603fb0 | ||
|
|
64f5cd9ad6 | ||
|
|
2f584c1f2c | ||
|
|
38d628c696 | ||
|
|
90495385cd | ||
|
|
b6f2df87c4 | ||
|
|
1bb82e1e2b | ||
|
|
6af6aaa9d0 | ||
|
|
539bec6f63 | ||
|
|
9440df66ba | ||
|
|
ef9a30ee54 | ||
|
|
98b8abd26a | ||
|
|
6429322217 | ||
|
|
b640c9a249 | ||
|
|
a69b2026f4 | ||
|
|
7fa49155dd | ||
|
|
5f7b02ee8e | ||
|
|
2205b8ad04 | ||
|
|
736344f123 | ||
|
|
d357998321 | ||
|
|
63cfbad65c | ||
|
|
bac3cbbf60 | ||
|
|
20b1e04764 | ||
|
|
7e6a65d29d | ||
|
|
9ecb29225a | ||
|
|
ebebbbc165 | ||
|
|
359e624e7c | ||
|
|
6d335e393d | ||
|
|
c6848aba06 | ||
|
|
7b41dfeb00 | ||
|
|
56bd971e42 | ||
|
|
0ef8e5a7e1 | ||
|
|
28a45caad3 | ||
|
|
a887e8ea23 | ||
|
|
7c14cf5bab | ||
|
|
fc0d6ff19d | ||
|
|
15f0f1605e | ||
|
|
176cd20847 | ||
|
|
b41567a39a | ||
|
|
ab4ee30690 | ||
|
|
081e44c4ff | ||
|
|
d2969a32de | ||
|
|
9d546b9989 | ||
|
|
fa68f101de | ||
|
|
d7c7a51f77 | ||
|
|
639ee55696 | ||
|
|
1a48bb440c | ||
|
|
687ac92aea | ||
|
|
57058b04da | ||
|
|
92babe509f | ||
|
|
98f0c9f2b6 | ||
|
|
bdfcbdadea | ||
|
|
2c1f2f47ec | ||
|
|
4c2d605411 | ||
|
|
ad17349944 | ||
|
|
b0f209fdf8 | ||
|
|
d387cc493e | ||
|
|
a6fa82bf50 | ||
|
|
226a088ec3 | ||
|
|
99cc4d9549 | ||
|
|
8a2b0e0a5e | ||
|
|
5d6c982c9c | ||
|
|
b028f7e1d3 | ||
|
|
700b8b6bad | ||
| 9a2ef67c85 | |||
|
|
ebd9cb8b04 |
99
.gitea/workflows/build.yml
Normal file
99
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Build Backend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.26'
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
|
||||
- name: Go cache info
|
||||
run: |
|
||||
echo "Go cache directory:"
|
||||
go env GOCACHE
|
||||
echo "Go module cache directory:"
|
||||
go env GOMODCACHE
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i 's|http://archive.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
sudo sed -i 's|http://security.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' /etc/apt/sources.list.d/ubuntu.sources
|
||||
else
|
||||
sudo sed -i 's|http://archive.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list
|
||||
sudo sed -i 's|http://security.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list
|
||||
fi
|
||||
sudo apt-get update && sudo apt-get install -y gcc
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GOOS: linux
|
||||
GOARCH: amd64
|
||||
CGO_ENABLED: 1
|
||||
run: go build -v -o server ./cmd/server
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: backend-server-linux-amd64
|
||||
path: server
|
||||
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: code.littlelan.cn
|
||||
username: ${{ secrets.GIT_USERNAME }}
|
||||
password: ${{ secrets.GIT_TOKEN }}
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: backend-server-linux-amd64
|
||||
|
||||
- name: Ensure binary executable
|
||||
run: chmod +x ./server
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
code.littlelan.cn/carrot_bbs/backend:latest
|
||||
code.littlelan.cn/carrot_bbs/backend:${{ github.sha }}
|
||||
platforms: linux/amd64
|
||||
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/backend:buildcache
|
||||
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/backend:buildcache,mode=max
|
||||
149
.gitea/workflows/deploy.yml
Normal file
149
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,149 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Image tag to deploy'
|
||||
required: false
|
||||
default: 'latest'
|
||||
environment:
|
||||
description: 'Deployment environment'
|
||||
required: true
|
||||
default: 'production'
|
||||
type: choice
|
||||
options:
|
||||
- production
|
||||
- staging
|
||||
|
||||
env:
|
||||
REGISTRY: code.littlelan.cn
|
||||
IMAGE_NAME: carrot_bbs/backend
|
||||
SERVER_HOST: 124.222.83.58
|
||||
SERVER_USER: ubuntu
|
||||
CONTAINER_NAME: with_you
|
||||
CONTAINER_NETWORK: 1panel-network
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ (github.event.pull_request.merged == true) || (github.event_name == 'workflow_dispatch') }}
|
||||
# workflow_dispatch 可选 staging;合并进主分支默认 production
|
||||
environment: ${{ github.event.inputs.environment || 'production' }}
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ env.SERVER_HOST }}
|
||||
username: ${{ env.SERVER_USER }}
|
||||
password: ${{ secrets.SERVER_PASSWORD }}
|
||||
script: |
|
||||
set -e
|
||||
|
||||
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.tag || 'latest' }}"
|
||||
CONTAINER="${{ env.CONTAINER_NAME }}"
|
||||
NETWORK="${{ env.CONTAINER_NETWORK }}"
|
||||
|
||||
echo "=== Pulling image: ${IMAGE} ==="
|
||||
sudo docker pull ${IMAGE}
|
||||
|
||||
echo "=== Stopping old container ==="
|
||||
sudo docker stop ${CONTAINER} 2>/dev/null || true
|
||||
sudo docker rm ${CONTAINER} 2>/dev/null || true
|
||||
|
||||
echo "=== Starting new container ==="
|
||||
sudo docker run -d \
|
||||
--name ${CONTAINER} \
|
||||
--network ${NETWORK} \
|
||||
--restart unless-stopped \
|
||||
-p 8080:8080 \
|
||||
-p 50051:50051 \
|
||||
-e APP_DATABASE_TYPE=postgres \
|
||||
-e APP_DATABASE_POSTGRES_HOST=1Panel-postgresql-Mjd1 \
|
||||
-e APP_DATABASE_POSTGRES_PORT=5432 \
|
||||
-e APP_DATABASE_POSTGRES_USER=with_you \
|
||||
-e "APP_DATABASE_POSTGRES_PASSWORD=${{ secrets.DB_PASSWORD }}" \
|
||||
-e APP_DATABASE_POSTGRES_DBNAME=with_you \
|
||||
-e APP_REDIS_TYPE=redis \
|
||||
-e APP_REDIS_REDIS_HOST=1Panel-redis-fq2g \
|
||||
-e APP_REDIS_REDIS_PORT=6379 \
|
||||
-e "APP_REDIS_REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" \
|
||||
-e APP_REDIS_REDIS_DB=0 \
|
||||
-e APP_S3_ENDPOINT=storage.littlelan.cn \
|
||||
-e "APP_S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY }}" \
|
||||
-e "APP_S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY }}" \
|
||||
-e APP_S3_BUCKET=withyou \
|
||||
-e APP_S3_DOMAIN=storage.littlelan.cn \
|
||||
-e APP_S3_USE_SSL=true \
|
||||
-e "APP_JWT_SECRET=${{ secrets.JWT_SECRET }}" \
|
||||
-e APP_ENCRYPTION_ENABLED=true \
|
||||
-e "APP_ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }}" \
|
||||
-e APP_ENCRYPTION_KEY_VERSION=1 \
|
||||
-e APP_HOT_RANK_ENABLED=true \
|
||||
-e APP_HOT_RANK_REFRESH_INTERVAL_SECONDS=300 \
|
||||
-e APP_HOT_RANK_TOP_N=100 \
|
||||
-e APP_HOT_RANK_RECENT_WINDOW_HOURS=168 \
|
||||
-e APP_HOT_RANK_RECENT_FETCH_LIMIT=500 \
|
||||
-e APP_HOT_RANK_CANDIDATE_CAP=800 \
|
||||
-e APP_OPENAI_ENABLED=true \
|
||||
-e APP_OPENAI_BASE_URL=https://api.littlelan.cn/ \
|
||||
-e "APP_OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" \
|
||||
-e APP_OPENAI_MODERATION_MODEL=qwen3.6-plus \
|
||||
-e APP_OPENAI_MODERATION_MAX_IMAGES_PER_REQUEST=3 \
|
||||
-e APP_OPENAI_REQUEST_TIMEOUT=30 \
|
||||
-e APP_OPENAI_STRICT_MODERATION=false \
|
||||
-e APP_EMAIL_ENABLED=true \
|
||||
-e APP_EMAIL_HOST=smtp.exmail.qq.com \
|
||||
-e APP_EMAIL_PORT=465 \
|
||||
-e APP_EMAIL_USERNAME=no-reply@qczlit.cn \
|
||||
-e "APP_EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }}" \
|
||||
-e APP_EMAIL_FROM_ADDRESS=no-reply@qczlit.cn \
|
||||
-e APP_EMAIL_FROM_NAME=WithYou \
|
||||
-e APP_EMAIL_USE_TLS=true \
|
||||
-e APP_EMAIL_TIMEOUT=15 \
|
||||
-e APP_GRPC_ENABLED=true \
|
||||
-e APP_GRPC_PORT=50051 \
|
||||
-e APP_SERVER_MODE=release \
|
||||
-e APP_TENCENT_TMS_ENABLED=false \
|
||||
-e "APP_TENCENT_TMS_SECRET_ID=${{ secrets.TENCENT_TMS_SECRET_ID }}" \
|
||||
-e "APP_TENCENT_TMS_SECRET_KEY=${{ secrets.TENCENT_TMS_SECRET_KEY }}" \
|
||||
-e APP_TENCENT_TMS_REGION=ap-guangzhou \
|
||||
-e APP_TENCENT_TMS_TIMEOUT=30 \
|
||||
-e APP_JPUSH_ENABLED=true \
|
||||
-e "APP_JPUSH_APP_KEY=${{ secrets.JPUSH_APP_KEY }}" \
|
||||
-e "APP_JPUSH_MASTER_SECRET=${{ secrets.JPUSH_MASTER_SECRET }}" \
|
||||
-e APP_JPUSH_PRODUCTION=true \
|
||||
-e APP_SENSITIVE_ENABLED=true \
|
||||
-e APP_REPORT_AUTO_HIDE_THRESHOLD=3 \
|
||||
-e APP_WEBSOCKET_MODE=standalone \
|
||||
-e APP_WEBSOCKET_CLUSTER_MSG_CHANNEL=ws:msg \
|
||||
-e APP_WEBSOCKET_CLUSTER_ONLINE_TTL=60 \
|
||||
-e APP_WEBSOCKET_CLUSTER_HEARTBEAT_INTERVAL=20 \
|
||||
-e APP_LOG_ROTATION_MAX_AGE=180 \
|
||||
-e "APP_SETUP_SECRET=${{ secrets.SETUP_SECRET }}" \
|
||||
${IMAGE}
|
||||
|
||||
echo "=== Waiting for startup ==="
|
||||
sleep 5
|
||||
|
||||
echo "=== Health check ==="
|
||||
for i in 1 2 3; do
|
||||
if curl -sf http://localhost:8080/health; then
|
||||
echo "Health check passed!"
|
||||
break
|
||||
fi
|
||||
echo "Attempt $i failed, retrying..."
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "=== Container logs ==="
|
||||
sudo docker logs ${CONTAINER} --tail 20
|
||||
|
||||
echo "=== Deployment completed ==="
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Build artifacts
|
||||
server
|
||||
/server
|
||||
*.exe
|
||||
*.tar
|
||||
|
||||
# Runtime files
|
||||
@@ -14,3 +15,7 @@ logs/
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
|
||||
plans/
|
||||
docs/
|
||||
376
.kilo/package-lock.json
generated
Normal file
376
.kilo/package-lock.json
generated
Normal file
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"name": ".kilo",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@kilocode/plugin": "7.2.31"
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/plugin": {
|
||||
"version": "7.2.31",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.31.tgz",
|
||||
"integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "7.2.31",
|
||||
"effect": "4.0.0-beta.48",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.100",
|
||||
"@opentui/solid": ">=0.1.100"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@kilocode/sdk": {
|
||||
"version": "7.2.31",
|
||||
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.31.tgz",
|
||||
"integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "4.0.0-beta.48",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
|
||||
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"fast-check": "^4.6.0",
|
||||
"find-my-way-ts": "^0.1.6",
|
||||
"ini": "^6.0.0",
|
||||
"kubernetes-types": "^1.30.0",
|
||||
"msgpackr": "^1.11.9",
|
||||
"multipasta": "^0.2.7",
|
||||
"toml": "^4.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
|
||||
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pure-rand": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-my-way-ts": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
|
||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kubernetes-types": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/msgpackr": {
|
||||
"version": "1.11.12",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz",
|
||||
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"msgpackr-extract": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/msgpackr-extract": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-gyp-build-optional-packages": "5.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/multipasta": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
|
||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-gyp-build-optional-packages": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp-build-optional-packages": "bin.js",
|
||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
|
||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
|
||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz",
|
||||
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,9 @@ COPY server ./carrot_bbs
|
||||
# 复制配置文件
|
||||
COPY configs ./configs
|
||||
|
||||
# 给可执行文件添加执行权限
|
||||
RUN chmod +x ./carrot_bbs
|
||||
# 给可执行文件添加执行权限,并创建运行时所需目录
|
||||
RUN chmod +x ./carrot_bbs \
|
||||
&& mkdir -p logs data
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
59
Makefile
Normal file
59
Makefile
Normal file
@@ -0,0 +1,59 @@
|
||||
.PHONY: build test run docker docker-push clean help
|
||||
|
||||
APP_NAME := carrot_bbs
|
||||
VERSION := $(shell git rev-parse --short HEAD 2>/dev/null || echo "dev")
|
||||
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
LDFLAGS := -ldflags "-s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)"
|
||||
|
||||
REGISTRY := code.littlelan.cn
|
||||
IMAGE_NAME := carrot_bbs/backend
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " make build - Build the binary"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make run - Run locally"
|
||||
@echo " make docker - Build Docker image"
|
||||
@echo " make docker-push - Push Docker image to registry"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
|
||||
build:
|
||||
@echo "Building $(APP_NAME)..."
|
||||
CGO_ENABLED=0 go build $(LDFLAGS) -o server ./cmd/server
|
||||
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
go test -v -race -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
run:
|
||||
@echo "Running $(APP_NAME)..."
|
||||
go run ./cmd/server
|
||||
|
||||
docker:
|
||||
@echo "Building Docker image..."
|
||||
docker build -t $(REGISTRY)/$(IMAGE_NAME):$(VERSION) -t $(REGISTRY)/$(IMAGE_NAME):latest .
|
||||
|
||||
docker-push:
|
||||
@echo "Pushing Docker image..."
|
||||
docker push $(REGISTRY)/$(IMAGE_NAME):$(VERSION)
|
||||
docker push $(REGISTRY)/$(IMAGE_NAME):latest
|
||||
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
rm -f server
|
||||
rm -f coverage.out coverage.html
|
||||
go clean
|
||||
|
||||
lint:
|
||||
@echo "Running linter..."
|
||||
golangci-lint run ./...
|
||||
|
||||
fmt:
|
||||
@echo "Formatting code..."
|
||||
go fmt ./...
|
||||
goimports -w .
|
||||
|
||||
wire:
|
||||
@echo "Generating wire..."
|
||||
cd cmd/server && wire
|
||||
200
cmd/server/app.go
Normal file
200
cmd/server/app.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/grpc/runner"
|
||||
"with_you/internal/pkg/ws"
|
||||
"with_you/internal/router"
|
||||
"with_you/internal/service"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// App 应用程序结构体
|
||||
type App struct {
|
||||
Config *config.Config
|
||||
DB *gorm.DB
|
||||
Router *router.Router
|
||||
PushService service.PushService
|
||||
HotRankWorker *service.HotRankWorker
|
||||
FileCleanupWorker *service.FileCleanupWorker
|
||||
DeviceCleanupWorker *service.DeviceCleanupWorker
|
||||
GRPCServer *runner.Server
|
||||
Server *http.Server
|
||||
Publisher ws.MessagePublisher
|
||||
TaskDispatcher runner.TaskDispatcher
|
||||
Logger *zap.Logger
|
||||
PushWorker *service.PushWorker
|
||||
}
|
||||
|
||||
// NewApp 创建应用程序
|
||||
func NewApp(
|
||||
cfg *config.Config,
|
||||
db *gorm.DB,
|
||||
r *router.Router,
|
||||
pushService service.PushService,
|
||||
hotRankWorker *service.HotRankWorker,
|
||||
fileCleanupWorker *service.FileCleanupWorker,
|
||||
deviceCleanupWorker *service.DeviceCleanupWorker,
|
||||
grpcServer *runner.Server,
|
||||
publisher ws.MessagePublisher,
|
||||
taskDispatcher runner.TaskDispatcher,
|
||||
logger *zap.Logger,
|
||||
pushWorker *service.PushWorker,
|
||||
) *App {
|
||||
return &App{
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
Router: r,
|
||||
PushService: pushService,
|
||||
HotRankWorker: hotRankWorker,
|
||||
FileCleanupWorker: fileCleanupWorker,
|
||||
DeviceCleanupWorker: deviceCleanupWorker,
|
||||
GRPCServer: grpcServer,
|
||||
Publisher: publisher,
|
||||
TaskDispatcher: taskDispatcher,
|
||||
Logger: logger,
|
||||
PushWorker: pushWorker,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动应用程序
|
||||
func (a *App) Start(ctx context.Context) error {
|
||||
// 1. 启动 gRPC 服务器(在 HTTP 服务器之前)
|
||||
if a.GRPCServer != nil {
|
||||
a.Logger.Info("Starting gRPC server")
|
||||
if err := a.GRPCServer.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start gRPC server: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 启动推送 Worker
|
||||
a.PushService.StartPushWorker(ctx)
|
||||
|
||||
// 2.1 热门榜定时重算
|
||||
if a.HotRankWorker != nil {
|
||||
a.HotRankWorker.Start(ctx)
|
||||
}
|
||||
|
||||
// 2.1.1 聊天文件过期清理
|
||||
if a.FileCleanupWorker != nil {
|
||||
a.FileCleanupWorker.Start(ctx)
|
||||
}
|
||||
|
||||
// 2.1.2 设备 registration_id 清理(清理不活跃设备 token)
|
||||
if a.DeviceCleanupWorker != nil {
|
||||
a.DeviceCleanupWorker.Start(ctx)
|
||||
}
|
||||
|
||||
// 2.2 启动 PushWorker(Redis Stream 异步推送)
|
||||
if a.PushWorker != nil {
|
||||
a.PushWorker.Start(ctx)
|
||||
}
|
||||
|
||||
// 3. 创建 HTTP 服务器
|
||||
addr := fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port)
|
||||
a.Logger.Info("Starting HTTP server",
|
||||
zap.String("address", addr),
|
||||
)
|
||||
|
||||
a.Server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: a.Router.Engine(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 4. 启动 HTTP 服务器
|
||||
go func() {
|
||||
if err := a.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
a.Logger.Fatal("HTTP server listen error",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 停止应用程序
|
||||
func (a *App) Stop(ctx context.Context) error {
|
||||
a.Logger.Info("Shutting down server")
|
||||
|
||||
// 1. 关闭所有 WebSocket 连接
|
||||
if a.Publisher != nil {
|
||||
a.Logger.Info("Closing WebSocket connections")
|
||||
switch p := a.Publisher.(type) {
|
||||
case *ws.Bus:
|
||||
p.Close()
|
||||
case *ws.Hub:
|
||||
p.CloseAllConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 停止 HTTP 服务器
|
||||
if a.Server != nil {
|
||||
a.Logger.Info("Stopping HTTP server")
|
||||
if err := a.Server.Shutdown(ctx); err != nil {
|
||||
a.Logger.Error("HTTP server forced to shutdown",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 停止 gRPC 服务器
|
||||
if a.GRPCServer != nil {
|
||||
a.Logger.Info("Stopping gRPC server")
|
||||
a.GRPCServer.Stop()
|
||||
}
|
||||
|
||||
// 4.1 停止 TaskBus(集群模式)
|
||||
if a.TaskDispatcher != nil {
|
||||
if bus, ok := a.TaskDispatcher.(*runner.TaskBus); ok {
|
||||
a.Logger.Info("Stopping TaskBus")
|
||||
bus.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 停止推送 Worker
|
||||
a.Logger.Info("Stopping push worker")
|
||||
a.PushService.StopPushWorker()
|
||||
|
||||
if a.HotRankWorker != nil {
|
||||
a.Logger.Info("Stopping hot rank worker")
|
||||
a.HotRankWorker.Stop()
|
||||
}
|
||||
|
||||
if a.FileCleanupWorker != nil {
|
||||
a.Logger.Info("Stopping file cleanup worker")
|
||||
a.FileCleanupWorker.Stop()
|
||||
}
|
||||
|
||||
if a.DeviceCleanupWorker != nil {
|
||||
a.Logger.Info("Stopping device cleanup worker")
|
||||
a.DeviceCleanupWorker.Stop()
|
||||
}
|
||||
|
||||
// 5.1 停止 PushWorker
|
||||
if a.PushWorker != nil {
|
||||
a.Logger.Info("Stopping push worker (stream)")
|
||||
a.PushWorker.Stop()
|
||||
}
|
||||
|
||||
// 6. 关闭数据库连接
|
||||
a.Logger.Info("Closing database connection")
|
||||
sqlDB, err := a.DB.DB()
|
||||
if err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
a.Logger.Info("Server exited")
|
||||
return nil
|
||||
}
|
||||
43
cmd/server/main.go
Normal file
43
cmd/server/main.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 初始化应用程序(Wire 自动生成)
|
||||
app, err := InitializeApp()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to initialize app: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 启动应用程序
|
||||
ctx := context.Background()
|
||||
if err := app.Start(ctx); err != nil {
|
||||
app.Logger.Fatal("failed to start app",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := app.Stop(shutdownCtx); err != nil {
|
||||
app.Logger.Error("Error during shutdown",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
121
cmd/server/wire.go
Normal file
121
cmd/server/wire.go
Normal file
@@ -0,0 +1,121 @@
|
||||
//go:build wireinject
|
||||
// +build wireinject
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
|
||||
"with_you/internal/cache"
|
||||
"with_you/internal/handler"
|
||||
"with_you/internal/middleware"
|
||||
"with_you/internal/repository"
|
||||
"with_you/internal/router"
|
||||
"with_you/internal/service"
|
||||
appwire "with_you/internal/wire"
|
||||
)
|
||||
|
||||
// InitializeApp 初始化应用程序
|
||||
func InitializeApp() (*App, error) {
|
||||
wire.Build(
|
||||
appwire.AllSet,
|
||||
NewApp,
|
||||
ProvideRouter,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ProvideRouter 提供路由
|
||||
func ProvideRouter(
|
||||
userRepo repository.UserRepository,
|
||||
userHandler *handler.UserHandler,
|
||||
postHandler *handler.PostHandler,
|
||||
commentHandler *handler.CommentHandler,
|
||||
messageHandler *handler.MessageHandler,
|
||||
notificationHandler *handler.NotificationHandler,
|
||||
uploadHandler *handler.UploadHandler,
|
||||
jwtService service.JWTService,
|
||||
sessionService service.SessionService,
|
||||
identityProvider middleware.IdentityProvider,
|
||||
cache cache.Cache,
|
||||
pushHandler *handler.PushHandler,
|
||||
systemMessageHandler *handler.SystemMessageHandler,
|
||||
groupHandler *handler.GroupHandler,
|
||||
stickerHandler *handler.StickerHandler,
|
||||
voteHandler *handler.VoteHandler,
|
||||
channelHandler *handler.ChannelHandler,
|
||||
scheduleHandler *handler.ScheduleHandler,
|
||||
gradeHandler *handler.GradeHandler,
|
||||
examHandler *handler.ExamHandler,
|
||||
emptyClassroomHandler *handler.EmptyClassroomHandler,
|
||||
roleHandler *handler.RoleHandler,
|
||||
adminUserHandler *handler.AdminUserHandler,
|
||||
adminPostHandler *handler.AdminPostHandler,
|
||||
adminCommentHandler *handler.AdminCommentHandler,
|
||||
adminGroupHandler *handler.AdminGroupHandler,
|
||||
adminDashboardHandler *handler.AdminDashboardHandler,
|
||||
adminLogHandler *handler.AdminLogHandler,
|
||||
qrcodeHandler *handler.QRCodeHandler,
|
||||
materialHandler *handler.MaterialHandler,
|
||||
callHandler *handler.CallHandler,
|
||||
reportHandler *handler.ReportHandler,
|
||||
adminReportHandler *handler.AdminReportHandler,
|
||||
verificationHandler *handler.VerificationHandler,
|
||||
adminVerificationHandler *handler.AdminVerificationHandler,
|
||||
adminProfileAuditHandler *handler.AdminProfileAuditHandler,
|
||||
setupHandler *handler.SetupHandler,
|
||||
tradeHandler *handler.TradeHandler,
|
||||
logService *service.LogService,
|
||||
activityService service.UserActivityService,
|
||||
casbinService service.CasbinService,
|
||||
userService service.UserService,
|
||||
wsHandler *handler.WSHandler,
|
||||
liveKitHandler *handler.LiveKitHandler,
|
||||
) *router.Router {
|
||||
return router.New(router.RouterDeps{
|
||||
UserRepo: userRepo,
|
||||
UserHandler: userHandler,
|
||||
PostHandler: postHandler,
|
||||
CommentHandler: commentHandler,
|
||||
MessageHandler: messageHandler,
|
||||
NotificationHandler: notificationHandler,
|
||||
UploadHandler: uploadHandler,
|
||||
JWTService: jwtService,
|
||||
SessionService: sessionService,
|
||||
IdentityProvider: identityProvider,
|
||||
Cache: cache,
|
||||
PushHandler: pushHandler,
|
||||
SystemMessageHandler: systemMessageHandler,
|
||||
GroupHandler: groupHandler,
|
||||
StickerHandler: stickerHandler,
|
||||
VoteHandler: voteHandler,
|
||||
ChannelHandler: channelHandler,
|
||||
ScheduleHandler: scheduleHandler,
|
||||
GradeHandler: gradeHandler,
|
||||
ExamHandler: examHandler,
|
||||
EmptyClassroomHandler: emptyClassroomHandler,
|
||||
RoleHandler: roleHandler,
|
||||
AdminUserHandler: adminUserHandler,
|
||||
AdminPostHandler: adminPostHandler,
|
||||
AdminCommentHandler: adminCommentHandler,
|
||||
AdminGroupHandler: adminGroupHandler,
|
||||
AdminDashboardHandler: adminDashboardHandler,
|
||||
AdminLogHandler: adminLogHandler,
|
||||
QRCodeHandler: qrcodeHandler,
|
||||
MaterialHandler: materialHandler,
|
||||
CallHandler: callHandler,
|
||||
LiveKitHandler: liveKitHandler,
|
||||
ReportHandler: reportHandler,
|
||||
AdminReportHandler: adminReportHandler,
|
||||
VerificationHandler: verificationHandler,
|
||||
AdminVerificationHandler: adminVerificationHandler,
|
||||
AdminProfileAuditHandler: adminProfileAuditHandler,
|
||||
SetupHandler: setupHandler,
|
||||
TradeHandler: tradeHandler,
|
||||
LogService: logService,
|
||||
ActivityService: activityService,
|
||||
CasbinService: casbinService,
|
||||
UserService: userService,
|
||||
WSHandler: wsHandler,
|
||||
})
|
||||
}
|
||||
276
cmd/server/wire_gen.go
Normal file
276
cmd/server/wire_gen.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"with_you/internal/cache"
|
||||
"with_you/internal/handler"
|
||||
"with_you/internal/middleware"
|
||||
"with_you/internal/repository"
|
||||
"with_you/internal/router"
|
||||
"with_you/internal/service"
|
||||
"with_you/internal/wire"
|
||||
)
|
||||
|
||||
// Injectors from wire.go:
|
||||
|
||||
// InitializeApp 初始化应用程序
|
||||
func InitializeApp() (*App, error) {
|
||||
config, err := wire.ProvideConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := wire.ProvideDB(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userRepository := repository.NewUserRepository(db)
|
||||
systemNotificationRepository := repository.NewSystemNotificationRepository(db)
|
||||
pushRecordRepository := repository.NewPushRecordRepository(db)
|
||||
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
|
||||
messageRepository := repository.NewMessageRepository(db)
|
||||
client := wire.ProvideRedisClient(config)
|
||||
messagePublisher := wire.ProvideWSMessagePublisher(config, client)
|
||||
logger := wire.ProvideLogger()
|
||||
jpushClient := wire.ProvideJPushClient(config, logger)
|
||||
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, messagePublisher, jpushClient, config)
|
||||
postRepository := repository.NewPostRepository(db)
|
||||
cache := wire.ProvideCache(config, client)
|
||||
systemMessageService := wire.ProvideSystemMessageService(systemNotificationRepository, pushService, userRepository, postRepository, cache)
|
||||
emailClient := wire.ProvideEmailClient(config)
|
||||
emailService := wire.ProvideEmailService(emailClient)
|
||||
operationLogRepository := repository.NewOperationLogRepository(db)
|
||||
loginLogRepository := repository.NewLoginLogRepository(db)
|
||||
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
|
||||
asyncLogManager := wire.ProvideAsyncLogManager(operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config)
|
||||
operationLogService := wire.ProvideOperationLogService(asyncLogManager, operationLogRepository)
|
||||
loginLogService := wire.ProvideLoginLogService(asyncLogManager, loginLogRepository)
|
||||
dataChangeLogService := wire.ProvideDataChangeLogService(asyncLogManager, dataChangeLogRepository)
|
||||
logService := wire.ProvideLogService(operationLogService, loginLogService, dataChangeLogService)
|
||||
sessionRepository := repository.NewSessionRepository(db)
|
||||
jwtService := wire.ProvideJWTService(config)
|
||||
sessionService := wire.ProvideSessionService(sessionRepository, jwtService)
|
||||
userService := wire.ProvideUserService(userRepository, systemMessageService, emailService, cache, logService, sessionService)
|
||||
userActivityRepository := wire.ProvideUserActivityRepository(db, cache)
|
||||
userActivityService := wire.ProvideUserActivityService(userActivityRepository)
|
||||
userProfileAuditRepository := repository.NewUserProfileAuditRepository(db)
|
||||
manager := wire.ProvideHookManager()
|
||||
openaiClient := wire.ProvideOpenAIClient(config)
|
||||
tencentClient := wire.ProvideTencentClient(config)
|
||||
postAIService := wire.ProvidePostAIService(openaiClient, tencentClient, config)
|
||||
commentRepository := repository.NewCommentRepository(db)
|
||||
sensitiveWordRepository := repository.NewSensitiveWordRepository(db)
|
||||
sensitiveService := wire.ProvideSensitiveService(sensitiveWordRepository, client, config)
|
||||
moderationHooks := wire.ProvideModerationHooks(manager, postAIService, postRepository, commentRepository, userRepository, sensitiveService, config)
|
||||
userProfileAuditService := wire.ProvideUserProfileAuditService(userProfileAuditRepository, userRepository, manager, moderationHooks)
|
||||
userHandler := wire.ProvideUserHandler(userService, userActivityService, logService, userProfileAuditService)
|
||||
transactionManager := wire.ProvideTransactionManager(db)
|
||||
builtinHooks := wire.ProvideBuiltinHooks(manager)
|
||||
postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks, logService)
|
||||
postRefRepository := repository.NewPostRefRepository(db)
|
||||
postRefService := wire.ProvidePostRefService(postRefRepository, postRepository)
|
||||
channelRepository := repository.NewChannelRepository(db)
|
||||
channelService := wire.ProvideChannelService(channelRepository, cache)
|
||||
postHandler := handler.NewPostHandler(postService, postRefService, userService, channelService)
|
||||
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager, logService)
|
||||
commentHandler := handler.NewCommentHandler(commentService)
|
||||
s3Client, err := wire.ProvideS3Client(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uploadedFileRepository := repository.NewUploadedFileRepository(db)
|
||||
uploadService := wire.ProvideUploadService(s3Client, userService, uploadedFileRepository)
|
||||
seqBufferManager := wire.ProvideSeqBufferManager(config, client, cache)
|
||||
pushWorker := wire.ProvidePushWorker(config, client, messagePublisher, pushService, messageRepository, userRepository)
|
||||
conversationVersionLogRepository := repository.NewConversationVersionLogRepository(db)
|
||||
groupRepository := repository.NewGroupRepository(db)
|
||||
groupJoinRequestRepository := repository.NewGroupJoinRequestRepository(db)
|
||||
groupService := wire.ProvideGroupService(groupRepository, userRepository, messageRepository, groupJoinRequestRepository, systemNotificationRepository, messagePublisher, cache)
|
||||
chatService := wire.ProvideChatService(messageRepository, userRepository, messagePublisher, cache, uploadService, pushService, seqBufferManager, pushWorker, client, conversationVersionLogRepository, config, groupService)
|
||||
messageService := wire.ProvideMessageService(messageRepository, cache, uploadService)
|
||||
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, uploadService, messagePublisher)
|
||||
notificationRepository := repository.NewNotificationRepository(db)
|
||||
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||
uploadHandler := handler.NewUploadHandler(uploadService)
|
||||
enforcer := wire.ProvideCasbinEnforcer(config, db)
|
||||
roleRepository := wire.ProvideRoleRepository(db)
|
||||
casbinService := wire.ProvideCasbinService(enforcer, cache, roleRepository, config)
|
||||
identityProvider := wire.ProvideIdentityProvider(userService, casbinService, sessionRepository)
|
||||
pushHandler := handler.NewPushHandler(pushService)
|
||||
systemMessageHandler := wire.ProvideSystemMessageHandler(systemMessageService)
|
||||
groupHandler := wire.ProvideGroupHandler(groupService, userService)
|
||||
stickerRepository := repository.NewStickerRepository(db)
|
||||
stickerService := wire.ProvideStickerService(stickerRepository)
|
||||
stickerHandler := handler.NewStickerHandler(stickerService)
|
||||
voteRepository := repository.NewVoteRepository(db)
|
||||
voteService := wire.ProvideVoteService(voteRepository, postRepository, cache, postAIService, systemMessageService, manager, logService)
|
||||
voteHandler := handler.NewVoteHandler(voteService, postService)
|
||||
channelHandler := handler.NewChannelHandler(channelService)
|
||||
scheduleRepository := repository.NewScheduleRepository(db)
|
||||
scheduleService := wire.ProvideScheduleService(scheduleRepository)
|
||||
runnerHub := wire.ProvideRunnerHub(logger)
|
||||
taskManager := wire.ProvideTaskManager(runnerHub, logger)
|
||||
scheduleSyncService := wire.ProvideScheduleSyncService(taskManager, scheduleRepository, logger)
|
||||
scheduleHandler := wire.ProvideScheduleHandler(scheduleService, scheduleSyncService)
|
||||
gradeRepository := repository.NewGradeRepository(db)
|
||||
gradeSyncService := wire.ProvideGradeSyncService(taskManager, gradeRepository, logger)
|
||||
gradeHandler := handler.NewGradeHandler(gradeSyncService)
|
||||
examRepository := repository.NewExamRepository(db)
|
||||
examSyncService := wire.ProvideExamSyncService(taskManager, examRepository, logger)
|
||||
examHandler := handler.NewExamHandler(examSyncService)
|
||||
emptyClassroomRepository := repository.NewEmptyClassroomRepository(db)
|
||||
emptyClassroomSyncService := wire.ProvideEmptyClassroomSyncService(taskManager, emptyClassroomRepository, logger)
|
||||
emptyClassroomHandler := handler.NewEmptyClassroomHandler(emptyClassroomSyncService)
|
||||
roleService := wire.ProvideRoleService(roleRepository, casbinService)
|
||||
roleHandler := handler.NewRoleHandler(roleService)
|
||||
adminUserService := wire.ProvideAdminUserService(userRepository, sessionService, cache)
|
||||
adminUserHandler := handler.NewAdminUserHandler(adminUserService, pushService)
|
||||
adminPostService := wire.ProvideAdminPostService(postRepository, cache, logService)
|
||||
adminPostHandler := handler.NewAdminPostHandler(adminPostService)
|
||||
adminCommentService := wire.ProvideAdminCommentService(commentRepository, postRepository, cache)
|
||||
adminCommentHandler := handler.NewAdminCommentHandler(adminCommentService)
|
||||
adminGroupService := wire.ProvideAdminGroupService(groupRepository, userRepository)
|
||||
adminGroupHandler := handler.NewAdminGroupHandler(adminGroupService)
|
||||
adminDashboardService := wire.ProvideAdminDashboardService(db, userRepository, postRepository, commentRepository, groupRepository, userActivityRepository)
|
||||
adminDashboardHandler := handler.NewAdminDashboardHandler(adminDashboardService)
|
||||
adminLogHandler := handler.NewAdminLogHandler(operationLogService, loginLogService, dataChangeLogService)
|
||||
qrCodeLoginService := wire.ProvideQRCodeLoginService(client, messagePublisher, jwtService, userService, userActivityService, logService)
|
||||
qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService)
|
||||
materialSubjectRepository := repository.NewMaterialSubjectRepository(db)
|
||||
materialFileRepository := repository.NewMaterialFileRepository(db)
|
||||
materialService := wire.ProvideMaterialService(materialSubjectRepository, materialFileRepository)
|
||||
materialHandler := handler.NewMaterialHandler(materialService)
|
||||
callRepository := repository.NewCallRepository(db)
|
||||
callService := wire.ProvideCallService(callRepository, messagePublisher, config, db, client, groupService)
|
||||
callHandler := handler.NewCallHandler(callService)
|
||||
reportRepository := repository.NewReportRepository(db)
|
||||
reportService := wire.ProvideReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService, config)
|
||||
reportHandler := handler.NewReportHandler(reportService)
|
||||
adminReportService := wire.ProvideAdminReportService(reportRepository, postRepository, commentRepository, messageRepository, userRepository, systemNotificationRepository, pushService, transactionManager, operationLogService)
|
||||
adminReportHandler := handler.NewAdminReportHandler(adminReportService)
|
||||
verificationRepository := repository.NewVerificationRepository(db)
|
||||
verificationService := wire.ProvideVerificationService(verificationRepository, userRepository)
|
||||
verificationHandler := handler.NewVerificationHandler(verificationService)
|
||||
adminVerificationService := wire.ProvideAdminVerificationService(verificationRepository, userRepository)
|
||||
adminVerificationHandler := wire.ProvideAdminVerificationHandler(adminVerificationService, userService)
|
||||
adminProfileAuditHandler := handler.NewAdminProfileAuditHandler(userProfileAuditService)
|
||||
setupService := wire.ProvideSetupService(userRepository, roleRepository, casbinService, config)
|
||||
setupHandler := wire.ProvideSetupHandler(setupService)
|
||||
tradeRepository := repository.NewTradeRepository(db)
|
||||
tradeService := wire.ProvideTradeService(tradeRepository)
|
||||
tradeHandler := handler.NewTradeHandler(tradeService)
|
||||
wsHandler := wire.ProvideWSHandler(messagePublisher, chatService, groupService, jwtService, callService, userService)
|
||||
liveKitService := wire.ProvideLiveKitService(config, logger)
|
||||
liveKitHandler := wire.ProvideLiveKitHandler(liveKitService, callService, config, logger)
|
||||
router := ProvideRouter(userRepository, userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, sessionService, identityProvider, cache, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, gradeHandler, examHandler, emptyClassroomHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, materialHandler, callHandler, reportHandler, adminReportHandler, verificationHandler, adminVerificationHandler, adminProfileAuditHandler, setupHandler, tradeHandler, logService, userActivityService, casbinService, userService, wsHandler, liveKitHandler)
|
||||
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, channelRepository, cache, client)
|
||||
fileCleanupWorker := wire.ProvideFileCleanupWorker(config, uploadedFileRepository, s3Client, client)
|
||||
deviceCleanupWorker := wire.ProvideDeviceCleanupWorker(config, deviceTokenRepository, client)
|
||||
server := wire.ProvideGRPCServer(config, logger, runnerHub)
|
||||
runnerRegistry := wire.ProvideRunnerRegistry(config, client)
|
||||
taskDispatcher := wire.ProvideTaskDispatcher(runnerHub, runnerRegistry, config, client, logger)
|
||||
app := NewApp(config, db, router, pushService, hotRankWorker, fileCleanupWorker, deviceCleanupWorker, server, messagePublisher, taskDispatcher, logger, pushWorker)
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// wire.go:
|
||||
|
||||
// ProvideRouter 提供路由
|
||||
func ProvideRouter(
|
||||
userRepo repository.UserRepository,
|
||||
userHandler *handler.UserHandler,
|
||||
postHandler *handler.PostHandler,
|
||||
commentHandler *handler.CommentHandler,
|
||||
messageHandler *handler.MessageHandler,
|
||||
notificationHandler *handler.NotificationHandler,
|
||||
uploadHandler *handler.UploadHandler,
|
||||
jwtService service.JWTService,
|
||||
sessionService service.SessionService,
|
||||
identityProvider middleware.IdentityProvider, cache2 cache.Cache,
|
||||
|
||||
pushHandler *handler.PushHandler,
|
||||
systemMessageHandler *handler.SystemMessageHandler,
|
||||
groupHandler *handler.GroupHandler,
|
||||
stickerHandler *handler.StickerHandler,
|
||||
voteHandler *handler.VoteHandler,
|
||||
channelHandler *handler.ChannelHandler,
|
||||
scheduleHandler *handler.ScheduleHandler,
|
||||
gradeHandler *handler.GradeHandler,
|
||||
examHandler *handler.ExamHandler,
|
||||
emptyClassroomHandler *handler.EmptyClassroomHandler,
|
||||
roleHandler *handler.RoleHandler,
|
||||
adminUserHandler *handler.AdminUserHandler,
|
||||
adminPostHandler *handler.AdminPostHandler,
|
||||
adminCommentHandler *handler.AdminCommentHandler,
|
||||
adminGroupHandler *handler.AdminGroupHandler,
|
||||
adminDashboardHandler *handler.AdminDashboardHandler,
|
||||
adminLogHandler *handler.AdminLogHandler,
|
||||
qrcodeHandler *handler.QRCodeHandler,
|
||||
materialHandler *handler.MaterialHandler,
|
||||
callHandler *handler.CallHandler,
|
||||
reportHandler *handler.ReportHandler,
|
||||
adminReportHandler *handler.AdminReportHandler,
|
||||
verificationHandler *handler.VerificationHandler,
|
||||
adminVerificationHandler *handler.AdminVerificationHandler,
|
||||
adminProfileAuditHandler *handler.AdminProfileAuditHandler,
|
||||
setupHandler *handler.SetupHandler,
|
||||
tradeHandler *handler.TradeHandler,
|
||||
logService *service.LogService,
|
||||
activityService service.UserActivityService,
|
||||
casbinService service.CasbinService,
|
||||
userService service.UserService,
|
||||
wsHandler *handler.WSHandler,
|
||||
liveKitHandler *handler.LiveKitHandler,
|
||||
) *router.Router {
|
||||
return router.New(router.RouterDeps{
|
||||
UserRepo: userRepo,
|
||||
UserHandler: userHandler,
|
||||
PostHandler: postHandler,
|
||||
CommentHandler: commentHandler,
|
||||
MessageHandler: messageHandler,
|
||||
NotificationHandler: notificationHandler,
|
||||
UploadHandler: uploadHandler,
|
||||
JWTService: jwtService,
|
||||
SessionService: sessionService,
|
||||
IdentityProvider: identityProvider,
|
||||
Cache: cache2,
|
||||
PushHandler: pushHandler,
|
||||
SystemMessageHandler: systemMessageHandler,
|
||||
GroupHandler: groupHandler,
|
||||
StickerHandler: stickerHandler,
|
||||
VoteHandler: voteHandler,
|
||||
ChannelHandler: channelHandler,
|
||||
ScheduleHandler: scheduleHandler,
|
||||
GradeHandler: gradeHandler,
|
||||
ExamHandler: examHandler,
|
||||
EmptyClassroomHandler: emptyClassroomHandler,
|
||||
RoleHandler: roleHandler,
|
||||
AdminUserHandler: adminUserHandler,
|
||||
AdminPostHandler: adminPostHandler,
|
||||
AdminCommentHandler: adminCommentHandler,
|
||||
AdminGroupHandler: adminGroupHandler,
|
||||
AdminDashboardHandler: adminDashboardHandler,
|
||||
AdminLogHandler: adminLogHandler,
|
||||
QRCodeHandler: qrcodeHandler,
|
||||
MaterialHandler: materialHandler,
|
||||
CallHandler: callHandler,
|
||||
LiveKitHandler: liveKitHandler,
|
||||
ReportHandler: reportHandler,
|
||||
AdminReportHandler: adminReportHandler,
|
||||
VerificationHandler: verificationHandler,
|
||||
AdminVerificationHandler: adminVerificationHandler,
|
||||
AdminProfileAuditHandler: adminProfileAuditHandler,
|
||||
SetupHandler: setupHandler,
|
||||
TradeHandler: tradeHandler,
|
||||
LogService: logService,
|
||||
ActivityService: activityService,
|
||||
CasbinService: casbinService,
|
||||
UserService: userService,
|
||||
WSHandler: wsHandler,
|
||||
})
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"carrot_bbs/internal/config"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/repository"
|
||||
"carrot_bbs/internal/service"
|
||||
|
||||
"github.com/casbin/casbin/v3"
|
||||
gormadapter "github.com/casbin/gorm-adapter/v3"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "configs/config.yaml", "配置文件路径")
|
||||
flag.Parse()
|
||||
|
||||
fmt.Println("============================================")
|
||||
fmt.Println("Casbin RBAC1 权限系统测试")
|
||||
fmt.Println("============================================")
|
||||
|
||||
// 加载配置
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
fmt.Println("✓ 配置加载成功")
|
||||
|
||||
// 连接数据库
|
||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
cfg.Database.Postgres.Host,
|
||||
cfg.Database.Postgres.Port,
|
||||
cfg.Database.Postgres.User,
|
||||
cfg.Database.Postgres.Password,
|
||||
cfg.Database.Postgres.DBName,
|
||||
)
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect database: %v", err)
|
||||
}
|
||||
fmt.Println("✓ 数据库连接成功")
|
||||
|
||||
// 自动迁移
|
||||
if err := db.AutoMigrate(&model.Role{}, &model.UserRole{}, &model.CasbinRule{}); err != nil {
|
||||
log.Fatalf("Failed to migrate: %v", err)
|
||||
}
|
||||
fmt.Println("✓ 数据库迁移完成")
|
||||
|
||||
// 初始化角色数据
|
||||
if err := initRoles(db); err != nil {
|
||||
log.Printf("[WARNING] Failed to init roles: %v", err)
|
||||
}
|
||||
|
||||
// 初始化 Casbin 策略数据
|
||||
if err := initCasbinPolicies(db); err != nil {
|
||||
log.Printf("[WARNING] Failed to init casbin policies: %v", err)
|
||||
}
|
||||
|
||||
// 创建 GORM 适配器
|
||||
adapter, err := gormadapter.NewAdapterByDB(db)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create GORM adapter: %v", err)
|
||||
}
|
||||
|
||||
// 创建 Casbin Enforcer
|
||||
enforcer, err := casbin.NewEnforcer(cfg.Casbin.ModelPath, adapter)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create enforcer: %v", err)
|
||||
}
|
||||
fmt.Println("✓ Casbin enforcer 创建成功")
|
||||
|
||||
// 创建角色仓储
|
||||
roleRepo := repository.NewRoleRepository(db)
|
||||
|
||||
// 创建 Casbin 服务
|
||||
casbinSvc := service.NewCasbinService(enforcer, nil, roleRepo, &cfg.Casbin)
|
||||
fmt.Println("✓ Casbin 服务创建成功")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试权限检查
|
||||
fmt.Println("\n=== 测试权限检查 ===")
|
||||
|
||||
testCases := []struct {
|
||||
sub string
|
||||
obj string
|
||||
act string
|
||||
expected bool
|
||||
}{
|
||||
// banned 用户权限
|
||||
{"banned", "/api/v1/posts", "GET", true},
|
||||
{"banned", "/api/v1/posts", "POST", false},
|
||||
{"banned", "/api/v1/admin/users", "GET", false},
|
||||
|
||||
// user 用户权限
|
||||
{"user", "/api/v1/posts", "GET", true},
|
||||
{"user", "/api/v1/posts", "POST", true},
|
||||
{"user", "/api/v1/posts/123", "PUT", true},
|
||||
{"user", "/api/v1/posts/123", "DELETE", true},
|
||||
{"user", "/api/v1/comments", "POST", true},
|
||||
{"user", "/api/v1/users/me", "GET", true},
|
||||
{"user", "/api/v1/admin/users", "GET", false},
|
||||
|
||||
// moderator 权限 (继承 user)
|
||||
{"moderator", "/api/v1/posts", "POST", true},
|
||||
{"moderator", "/api/v1/admin/posts/123", "DELETE", true},
|
||||
{"moderator", "/api/v1/admin/comments/456", "DELETE", true},
|
||||
{"moderator", "/api/v1/admin/audit", "GET", true},
|
||||
{"moderator", "/api/v1/admin/users", "GET", false},
|
||||
|
||||
// admin 权限 (继承 moderator)
|
||||
{"admin", "/api/v1/posts", "POST", true},
|
||||
{"admin", "/api/v1/admin/posts/123", "DELETE", true},
|
||||
{"admin", "/api/v1/admin/users", "GET", true},
|
||||
{"admin", "/api/v1/admin/roles", "GET", true},
|
||||
{"admin", "/api/v1/some/unknown/path", "GET", false},
|
||||
|
||||
// super_admin 权限 (完全访问)
|
||||
{"super_admin", "/api/v1/admin/anything", "DELETE", true},
|
||||
{"super_admin", "/api/v1/admin/users", "GET", true},
|
||||
{"super_admin", "/any/path", "ANY", true},
|
||||
}
|
||||
|
||||
passed := 0
|
||||
failed := 0
|
||||
|
||||
for _, tc := range testCases {
|
||||
allowed, err := casbinSvc.Enforce(ctx, tc.sub, tc.obj, tc.act)
|
||||
if err != nil {
|
||||
fmt.Printf("✗ 错误: %s -> %s %s: %v\n", tc.sub, tc.obj, tc.act, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
status := "✗ DENIED"
|
||||
if allowed {
|
||||
status = "✓ ALLOWED"
|
||||
}
|
||||
|
||||
// 检查结果是否符合预期
|
||||
resultMatch := ""
|
||||
if allowed != tc.expected {
|
||||
resultMatch = fmt.Sprintf(" (预期: %v)", tc.expected)
|
||||
failed++
|
||||
} else {
|
||||
passed++
|
||||
}
|
||||
|
||||
fmt.Printf("%s: %s -> %s %s%s\n", status, tc.sub, tc.obj, tc.act, resultMatch)
|
||||
}
|
||||
|
||||
// 测试角色继承
|
||||
fmt.Println("\n=== 测试角色继承 ===")
|
||||
|
||||
// moderator 应该继承 user 的权限
|
||||
allowed, _ := casbinSvc.Enforce(ctx, "moderator", "/api/v1/posts", "POST")
|
||||
fmt.Printf("Moderator 继承 user 权限 - 可以发帖: %v\n", allowed)
|
||||
|
||||
// admin 应该继承 moderator 的权限
|
||||
allowed, _ = casbinSvc.Enforce(ctx, "admin", "/api/v1/admin/posts/123", "DELETE")
|
||||
fmt.Printf("Admin 继承 moderator 权限 - 可以删帖: %v\n", allowed)
|
||||
|
||||
// 测试用户角色管理
|
||||
fmt.Println("\n=== 测试用户角色管理 ===")
|
||||
|
||||
testUserID := "test_user_123"
|
||||
|
||||
// 获取用户当前角色
|
||||
roles, err := casbinSvc.GetRolesForUser(ctx, testUserID)
|
||||
if err != nil {
|
||||
fmt.Printf("获取用户角色失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("用户 %s 当前角色: %v\n", testUserID, roles)
|
||||
}
|
||||
|
||||
// 添加角色
|
||||
err = casbinSvc.AddRoleForUser(ctx, testUserID, "moderator")
|
||||
if err != nil {
|
||||
fmt.Printf("添加角色失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✓ 为用户 %s 添加 moderator 角色\n", testUserID)
|
||||
}
|
||||
|
||||
// 再次获取角色
|
||||
roles, err = casbinSvc.GetRolesForUser(ctx, testUserID)
|
||||
if err != nil {
|
||||
fmt.Printf("获取用户角色失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("用户 %s 当前角色: %v\n", testUserID, roles)
|
||||
}
|
||||
|
||||
// 检查用户是否有指定角色
|
||||
hasRole, err := casbinSvc.HasRoleForUser(ctx, testUserID, "moderator")
|
||||
if err != nil {
|
||||
fmt.Printf("检查角色失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("用户 %s 是否有 moderator 角色: %v\n", testUserID, hasRole)
|
||||
}
|
||||
|
||||
// 测试用户权限检查
|
||||
allowed, err = casbinSvc.EnforceForUser(ctx, testUserID, "/api/v1/admin/posts/123", "DELETE")
|
||||
if err != nil {
|
||||
fmt.Printf("用户权限检查失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("用户 %s 可以删除管理后台帖子: %v\n", testUserID, allowed)
|
||||
}
|
||||
|
||||
// 移除角色
|
||||
err = casbinSvc.DeleteRoleForUser(ctx, testUserID, "moderator")
|
||||
if err != nil {
|
||||
fmt.Printf("移除角色失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✓ 移除用户 %s 的 moderator 角色\n", testUserID)
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
cleanupTestData(db, testUserID)
|
||||
|
||||
// 输出测试结果
|
||||
fmt.Println("\n============================================")
|
||||
fmt.Printf("测试结果: %d 通过, %d 失败\n", passed, failed)
|
||||
fmt.Println("============================================")
|
||||
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// initRoles 初始化角色数据
|
||||
func initRoles(db *gorm.DB) error {
|
||||
roles := []model.Role{
|
||||
{Name: "banned", DisplayName: "被封禁用户", Description: "仅可浏览公开内容,无法交互", Priority: 0},
|
||||
{Name: "user", DisplayName: "普通用户", Description: "注册用户,拥有基本功能权限", ParentRole: strPtr("banned"), Priority: 10},
|
||||
{Name: "moderator", DisplayName: "版主", Description: "内容审核员,管理帖子和评论", ParentRole: strPtr("user"), Priority: 20},
|
||||
{Name: "admin", DisplayName: "管理员", Description: "系统管理员,管理用户和内容", ParentRole: strPtr("moderator"), Priority: 30},
|
||||
{Name: "super_admin", DisplayName: "超级管理员", Description: "系统最高权限者", ParentRole: strPtr("admin"), Priority: 40},
|
||||
}
|
||||
|
||||
for _, role := range roles {
|
||||
result := db.FirstOrCreate(&role, model.Role{Name: role.Name})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("✓ 角色数据初始化完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// initCasbinPolicies 初始化 Casbin 策略数据
|
||||
func initCasbinPolicies(db *gorm.DB) error {
|
||||
// 角色继承关系 (g 策略)
|
||||
gPolicies := []model.CasbinRule{
|
||||
{Ptype: "g", V0: "user", V1: "banned"},
|
||||
{Ptype: "g", V0: "moderator", V1: "user"},
|
||||
{Ptype: "g", V0: "admin", V1: "moderator"},
|
||||
{Ptype: "g", V0: "super_admin", V1: "admin"},
|
||||
}
|
||||
|
||||
// 权限策略 (p 策略)
|
||||
pPolicies := []model.CasbinRule{
|
||||
// banned 用户权限 (仅允许 GET 请求)
|
||||
{Ptype: "p", V0: "banned", V1: "/api/v1/posts", V2: "GET"},
|
||||
{Ptype: "p", V0: "banned", V1: "/api/v1/posts/*", V2: "GET"},
|
||||
{Ptype: "p", V0: "banned", V1: "/api/v1/comments/*", V2: "GET"},
|
||||
{Ptype: "p", V0: "banned", V1: "/api/v1/users/*", V2: "GET"},
|
||||
|
||||
// user 用户权限 (基本操作)
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/posts", V2: "POST"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/posts/*", V2: "PUT"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/posts/*", V2: "DELETE"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/posts/*/like", V2: "POST"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/posts/*/like", V2: "DELETE"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/posts/*/favorite", V2: "POST"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/posts/*/favorite", V2: "DELETE"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/comments", V2: "POST"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/comments/*", V2: "PUT"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/comments/*", V2: "DELETE"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/users/me", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/users/me/*", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/conversations", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/conversations/*", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/messages/*", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/notifications", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/notifications/*", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/uploads/*", V2: "POST"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/groups", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/groups/*", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/stickers", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/schedule", V2: "*"},
|
||||
{Ptype: "p", V0: "user", V1: "/api/v1/schedule/*", V2: "*"},
|
||||
|
||||
// moderator 权限 (内容管理)
|
||||
{Ptype: "p", V0: "moderator", V1: "/api/v1/admin/posts/*", V2: "*"},
|
||||
{Ptype: "p", V0: "moderator", V1: "/api/v1/admin/comments/*", V2: "*"},
|
||||
{Ptype: "p", V0: "moderator", V1: "/api/v1/admin/audit/*", V2: "GET"},
|
||||
|
||||
// admin 权限 (用户管理)
|
||||
{Ptype: "p", V0: "admin", V1: "/api/v1/admin/users/*", V2: "*"},
|
||||
{Ptype: "p", V0: "admin", V1: "/api/v1/admin/roles", V2: "GET"},
|
||||
{Ptype: "p", V0: "admin", V1: "/api/v1/admin/roles/*", V2: "GET"},
|
||||
|
||||
// super_admin 权限 (完全访问)
|
||||
{Ptype: "p", V0: "super_admin", V1: "/*", V2: "*"},
|
||||
}
|
||||
|
||||
// 插入 g 策略
|
||||
for _, policy := range gPolicies {
|
||||
var existing model.CasbinRule
|
||||
result := db.Where("ptype = ? AND v0 = ? AND v1 = ?", policy.Ptype, policy.V0, policy.V1).First(&existing)
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
if err := db.Create(&policy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 插入 p 策略
|
||||
for _, policy := range pPolicies {
|
||||
var existing model.CasbinRule
|
||||
result := db.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).First(&existing)
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
if err := db.Create(&policy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("✓ Casbin 策略数据初始化完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupTestData 清理测试数据
|
||||
func cleanupTestData(db *gorm.DB, userID string) {
|
||||
db.Where("user_id = ?", userID).Delete(&model.UserRole{})
|
||||
fmt.Printf("✓ 清理测试用户 %s 的数据\n", userID)
|
||||
}
|
||||
|
||||
// strPtr 返回字符串指针
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// 辅助函数:检查字符串是否包含通配符匹配
|
||||
func matchPattern(pattern, str string) bool {
|
||||
if pattern == "/*" {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(pattern, "/*") {
|
||||
prefix := strings.TrimSuffix(pattern, "/*")
|
||||
return strings.HasPrefix(str, prefix+"/") || str == prefix
|
||||
}
|
||||
return pattern == str
|
||||
}
|
||||
@@ -10,5 +10,14 @@ g = _, _
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
# 真源策略:user_roles 表为用户-角色唯一真源;Casbin 仅存 p, role, resource, action。
|
||||
# EnforceForUser 由 CasbinService 先查 DB 角色再对每个角色调 Enforce(role, obj, act),
|
||||
# 因此 matcher 直接匹配 r.sub == p.sub(角色直接匹配),不再依赖 g 分组。
|
||||
#
|
||||
# matcher 使用 globMatch(doublestar 语义):
|
||||
# - '* 匹配单层资源段(不跨 '/');
|
||||
# - '**' 跨多层(如 admin/** 命中 admin/users/roles/write)。
|
||||
# 资源命名采用路径式 admin/<domain>[/<sub>],避免 keyMatch2 把 '*' 当作正则通配
|
||||
# 跨 '.' 而误匹配;也避免 globMatch 在点号分隔下 '*' 仍跨 '.' 的过宽问题。
|
||||
[matchers]
|
||||
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*")
|
||||
m = r.sub == p.sub && globMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 服务器配置
|
||||
# 服务器配置
|
||||
# 环境变量: APP_SERVER_HOST, APP_SERVER_PORT, APP_SERVER_MODE
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
@@ -13,14 +13,38 @@ server:
|
||||
database:
|
||||
type: sqlite # sqlite 或 postgres
|
||||
sqlite:
|
||||
path: ./data/carrot_bbs.db
|
||||
path: ./data/with_you.db
|
||||
postgres:
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: postgres
|
||||
password: postgres
|
||||
dbname: carrot_bbs
|
||||
dbname: with_you
|
||||
sslmode: disable
|
||||
# 读写分离配置(仅 postgres 模式生效,不配置则所有请求走主库)
|
||||
# 注意:replica 字段已在 config.go 中显式 BindEnv,环境变量优先于 YAML。
|
||||
# 单副本配置(推荐用环境变量覆盖,无需取消下方注释):
|
||||
# APP_DATABASE_REPLICA_HOST, APP_DATABASE_REPLICA_PORT, APP_DATABASE_REPLICA_USER,
|
||||
# APP_DATABASE_REPLICA_PASSWORD, APP_DATABASE_REPLICA_DBNAME, APP_DATABASE_REPLICA_SSLMODE
|
||||
# APP_DATABASE_REPLICA_MAX_IDLE_CONNS, APP_DATABASE_REPLICA_MAX_OPEN_CONNS(可选,缺省回退主库连接池)
|
||||
# replica:
|
||||
# host: replica1.internal
|
||||
# port: 5432
|
||||
# user: readonly
|
||||
# password: readonly_pass
|
||||
# dbname: with_you
|
||||
# sslmode: disable
|
||||
# 多副本配置(仅 YAML,环境变量不支持切片结构体):
|
||||
# replicas:
|
||||
# - host: replica1.internal
|
||||
# port: 5432
|
||||
# user: readonly
|
||||
# password: readonly_pass
|
||||
# dbname: with_you
|
||||
# sslmode: disable
|
||||
# replica_policy: random # random
|
||||
# replica_max_idle_conns: 10
|
||||
# replica_max_open_conns: 100
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
log_level: warn
|
||||
@@ -64,6 +88,15 @@ cache:
|
||||
unread_count_ttl: 30
|
||||
group_members_ttl: 120
|
||||
|
||||
# 热门榜:定时在「候选池」上重算,只写入 Redis ZSET(TopN)+ top_ids(供本地缓存);不写数据库
|
||||
hot_rank:
|
||||
enabled: true
|
||||
refresh_interval_seconds: 300
|
||||
top_n: 100
|
||||
recent_window_hours: 168 # 7 天内新帖进入候选
|
||||
recent_fetch_limit: 500
|
||||
candidate_cap: 800 # 上届 Top + 新帖 ID 合并后上限
|
||||
|
||||
# S3对象存储配置
|
||||
# 环境变量: APP_S3_ENDPOINT, APP_S3_ACCESS_KEY, APP_S3_SECRET_KEY, APP_S3_BUCKET, APP_S3_DOMAIN
|
||||
s3:
|
||||
@@ -87,6 +120,32 @@ log:
|
||||
output_paths:
|
||||
- stdout
|
||||
- ./logs/app.log
|
||||
retention:
|
||||
access_log: 180 # HTTP访问日志 6个月
|
||||
operation_log: 365 # 操作日志 1年
|
||||
login_log: 1095 # 登录日志 3年
|
||||
data_change: 1095 # 数据变更日志 3年
|
||||
async:
|
||||
buffer_size: 10000
|
||||
batch_size: 100
|
||||
flush_interval: 3s
|
||||
worker_count: 3
|
||||
enable_fallback: true
|
||||
fallback_path: ./logs/fallback.log
|
||||
sensitive:
|
||||
enabled: true
|
||||
mask_fields:
|
||||
- password
|
||||
- token
|
||||
- secret
|
||||
- phone
|
||||
- id_card
|
||||
- bank_card
|
||||
rotation:
|
||||
max_size: 100
|
||||
max_age: 180
|
||||
max_backups: 10
|
||||
compress: true
|
||||
|
||||
rate_limit:
|
||||
enabled: true
|
||||
@@ -100,6 +159,14 @@ upload:
|
||||
- image/gif
|
||||
- image/webp
|
||||
|
||||
# 设备 registration_id 清理配置
|
||||
# 周期性清理超过保留期未使用的设备 token,避免无效 registration_id 累积
|
||||
# 环境变量: APP_DEVICE_CLEANUP_ENABLED, APP_DEVICE_CLEANUP_RETENTION_DAYS, APP_DEVICE_CLEANUP_INTERVAL_MINUTES
|
||||
device_cleanup:
|
||||
enabled: true # 是否启用清理 worker
|
||||
retention_days: 3 # 保留天数,3 天不活跃即清理(按 last_used_at 判断)
|
||||
interval_minutes: 360 # 扫描间隔(分钟),默认 6 小时
|
||||
|
||||
# 敏感词过滤配置
|
||||
sensitive:
|
||||
enabled: true
|
||||
@@ -127,18 +194,6 @@ audit:
|
||||
baidu_api_key: ""
|
||||
baidu_secret_key: ""
|
||||
|
||||
# Gorse推荐系统配置
|
||||
# 环境变量: APP_GORSE_ADDRESS, APP_GORSE_API_KEY, APP_GORSE_DASHBOARD, APP_GORSE_IMPORT_PASSWORD
|
||||
gorse:
|
||||
enabled: true
|
||||
address: "http://111.170.19.33:8088" # Gorse server地址
|
||||
api_key: "" # API密钥
|
||||
dashboard: "" # Gorse dashboard地址
|
||||
import_password: "lanyimin123" # 导入数据密码
|
||||
embedding_api_key: "sk-ZPN5NMPSqEaOGCPfD2LqndZ5Wwmw3DC4CQgzgKhM35fI3RpD"
|
||||
embedding_url: "https://api.littlelan.cn/v1/embeddings"
|
||||
embedding_model: "BAAI/bge-m3"
|
||||
|
||||
# OpenAI兼容接口配置(用于帖子审核,支持图文)
|
||||
# 环境变量:
|
||||
# APP_OPENAI_ENABLED, APP_OPENAI_BASE_URL, APP_OPENAI_API_KEY
|
||||
@@ -153,6 +208,19 @@ openai:
|
||||
request_timeout: 30
|
||||
strict_moderation: false
|
||||
|
||||
# 腾讯云文本内容安全配置(作为 OpenAI 备用)
|
||||
# 当 OpenAI 不可用或审核失败时,使用腾讯TMS进行文本审核
|
||||
# 环境变量:
|
||||
# APP_TENCENT_TMS_ENABLED, APP_TENCENT_TMS_SECRET_ID, APP_TENCENT_TMS_SECRET_KEY
|
||||
# APP_TENCENT_TMS_REGION, APP_TENCENT_TMS_BIZ_TYPE, APP_TENCENT_TMS_TIMEOUT
|
||||
tencent_tms:
|
||||
enabled: false
|
||||
secret_id: ""
|
||||
secret_key: ""
|
||||
region: "ap-guangzhou"
|
||||
biz_type: "" # 可选,使用控制台配置的自定义策略
|
||||
timeout: 30
|
||||
|
||||
# SMTP发信配置(gomail.v2)
|
||||
# 环境变量:
|
||||
# APP_EMAIL_ENABLED, APP_EMAIL_HOST, APP_EMAIL_PORT
|
||||
@@ -166,7 +234,7 @@ email:
|
||||
username: "no-reply@qczlit.cn"
|
||||
password: "HbvwwVjRyiWg9gsK"
|
||||
from_address: "no-reply@qczlit.cn"
|
||||
from_name: "Carrot BBS"
|
||||
from_name: "WithYou"
|
||||
use_tls: true
|
||||
insecure_skip_verify: false
|
||||
timeout: 15
|
||||
@@ -220,3 +288,125 @@ casbin:
|
||||
- /api/v1/auth/password/send-code
|
||||
- /api/v1/auth/password/reset
|
||||
- /api/v1/auth/refresh
|
||||
- /api/v1/admin/setup-super-admin
|
||||
|
||||
# 举报配置
|
||||
# 环境变量: APP_REPORT_AUTO_HIDE_THRESHOLD
|
||||
report:
|
||||
auto_hide_threshold: 3 # 自动隐藏阈值,举报次数达到此数值时自动隐藏内容
|
||||
|
||||
# 极光推送配置
|
||||
# 环境变量:
|
||||
# APP_JPUSH_ENABLED, APP_JPUSH_APP_KEY, APP_JPUSH_MASTER_SECRET, APP_JPUSH_PRODUCTION
|
||||
# 厂商通道 channel_id(区分系统消息与私聊消息,通过 options.third_party_channel 下发):
|
||||
# APP_JPUSH_CHANNEL_SYSTEM, APP_JPUSH_CHANNEL_CHAT
|
||||
# APP_JPUSH_CHANNEL_XIAOMI_SYSTEM, APP_JPUSH_CHANNEL_XIAOMI_CHAT
|
||||
# APP_JPUSH_CHANNEL_HUAWEI_SYSTEM, APP_JPUSH_CHANNEL_HUAWEI_CHAT
|
||||
# APP_JPUSH_CHANNEL_OPPO_SYSTEM, APP_JPUSH_CHANNEL_OPPO_CHAT
|
||||
# APP_JPUSH_CHANNEL_VIVO_SYSTEM, APP_JPUSH_CHANNEL_VIVO_CHAT
|
||||
# APP_JPUSH_CHANNEL_MEIZU_SYSTEM, APP_JPUSH_CHANNEL_MEIZU_CHAT
|
||||
# APP_JPUSH_CHANNEL_HONOR_SYSTEM, APP_JPUSH_CHANNEL_HONOR_CHAT
|
||||
# APP_JPUSH_CHANNEL_FCM_SYSTEM, APP_JPUSH_CHANNEL_FCM_CHAT
|
||||
# 小米消息模板(可选,配置后私信消息携带 channel_id 与 mi_template_id):
|
||||
# APP_JPUSH_CHANNEL_XIAOMI_MI_SYSTEM_TEMPLATE_ID, APP_JPUSH_CHANNEL_XIAOMI_MI_CHAT_TEMPLATE_ID
|
||||
# OPPO 私信模板(可选,仅 OPPO,配置后下发私信时携带):
|
||||
# APP_JPUSH_CHANNEL_OPPO_SYSTEM_PRIVATE_TEMPLATE_ID, APP_JPUSH_CHANNEL_OPPO_CHAT_PRIVATE_TEMPLATE_ID
|
||||
# OPPO 消息分类(2024.11.20 新规,category 必传时 notify_level 才生效):
|
||||
# APP_JPUSH_CHANNEL_OPPO_SYSTEM_CATEGORY, APP_JPUSH_CHANNEL_OPPO_CHAT_CATEGORY
|
||||
# APP_JPUSH_CHANNEL_OPPO_SYSTEM_NOTIFY_LEVEL, APP_JPUSH_CHANNEL_OPPO_CHAT_NOTIFY_LEVEL
|
||||
# 荣耀通知栏消息智能分类(importance,NORMAL=服务通讯/LOW=资讯营销):
|
||||
# APP_JPUSH_CHANNEL_HONOR_SYSTEM_IMPORTANCE, APP_JPUSH_CHANNEL_HONOR_CHAT_IMPORTANCE
|
||||
# vivo 厂商消息场景标识(category,classification=1 时必须为系统消息类):
|
||||
# APP_JPUSH_CHANNEL_VIVO_SYSTEM_CATEGORY, APP_JPUSH_CHANNEL_VIVO_CHAT_CATEGORY
|
||||
# iOS 通知分组 thread-id:
|
||||
# APP_JPUSH_CHANNEL_IOS_CHAT_THREAD_ID, APP_JPUSH_CHANNEL_IOS_SYSTEM_THREAD_ID
|
||||
jpush:
|
||||
enabled: false
|
||||
app_key: ""
|
||||
master_secret: ""
|
||||
production: false # true: 生产环境, false: 开发环境
|
||||
# 厂商通道 channel_id 配置(全部可选,留空则不下发对应厂商字段)
|
||||
# channel.system / channel.chat 为各厂商默认 channel_id
|
||||
# channel.vendor.{vendor}.{system|chat} 可单独覆盖某厂商,留空则回退到默认值
|
||||
# 小米/OPPO 额外支持消息模板 id(按场景区分),配置后私信消息下发时携带
|
||||
# 其余厂商(华为/VIVO/魅族/荣耀/FCM)仅 channel_id
|
||||
channel:
|
||||
system: "" # 系统消息/通知默认 channel_id
|
||||
chat: "" # 私聊/群聊消息默认 channel_id
|
||||
vendor:
|
||||
# xiaomi: 默认值已在代码内置(system=153609, chat=153608, mi_system_template_id=P10761, mi_chat_template_id=M10289)
|
||||
# 不在此声明,由 config.go 的 viper.SetDefault 提供,环境变量可覆盖
|
||||
xiaomi: {}
|
||||
huawei: { system: "", chat: "" }
|
||||
# OPPO: category/notify_level 默认值已在代码内置(chat=IM/16, system=ACCOUNT/2),不在此声明
|
||||
oppo:
|
||||
system: ""
|
||||
chat: ""
|
||||
oppo_system_private_template_id: "" # OPPO 系统消息私信模板 id(需厂商后台注册)
|
||||
oppo_chat_private_template_id: "" # OPPO 私聊消息私信模板 id(需厂商后台注册)
|
||||
# vivo: category 默认值已在代码内置(chat=IM, system=ACCOUNT),不在此声明
|
||||
vivo:
|
||||
system: ""
|
||||
chat: ""
|
||||
meizu: { system: "", chat: "" }
|
||||
# 荣耀: importance 默认值已在代码内置(chat=NORMAL, system=LOW),不在此声明
|
||||
honor:
|
||||
system: ""
|
||||
chat: ""
|
||||
fcm: { system: "", chat: "" }
|
||||
# iOS APNs 通知分组(按 thread-id 区分系统/私聊通知,不配置则不分组)
|
||||
ios:
|
||||
chat_thread_id: "" # 私聊/群聊通知分组 thread-id
|
||||
system_thread_id: "" # 系统消息/通知分组 thread-id
|
||||
|
||||
# 初始化超级管理员密钥
|
||||
# 环境变量: APP_SETUP_SECRET
|
||||
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
|
||||
# 该接口只能使用一次,一旦系统中存在超级管理员,此接口将永久拒绝请求
|
||||
setup_secret: ""
|
||||
|
||||
# WebSocket 配置
|
||||
# 环境变量:
|
||||
# APP_WEBSOCKET_MODE, APP_WEBSOCKET_CLUSTER_INSTANCE_ID
|
||||
# APP_WEBSOCKET_CLUSTER_MSG_CHANNEL, APP_WEBSOCKET_CLUSTER_ONLINE_TTL
|
||||
# APP_WEBSOCKET_CLUSTER_HEARTBEAT_INTERVAL
|
||||
websocket:
|
||||
mode: standalone # standalone 或 cluster
|
||||
cluster:
|
||||
instance_id: "" # 留空自动生成 UUID
|
||||
msg_channel: "ws:msg" # Redis Pub/Sub 频道
|
||||
online_ttl: 60 # 在线状态 TTL(秒)
|
||||
heartbeat_interval: 20 # 心跳间隔(秒)
|
||||
|
||||
# Seq 预分配配置
|
||||
# 环境变量:
|
||||
# APP_SEQ_BUFFER_ENABLED, APP_SEQ_BUFFER_PRIVATE_BUFFER_SIZE
|
||||
# APP_SEQ_BUFFER_GROUP_BUFFER_SIZE, APP_SEQ_BUFFER_LOCK_TIMEOUT_MS
|
||||
# APP_SEQ_BUFFER_MAX_RETRIES
|
||||
seq_buffer:
|
||||
enabled: false # 启用后使用 Lua 预分配代替 INCR,关闭则回退旧逻辑
|
||||
private_buffer_size: 50 # 私聊每次向 Redis 预分配步长
|
||||
group_buffer_size: 200 # 群聊每次向 Redis 预分配步长(群聊消息更频繁)
|
||||
lock_timeout_ms: 5000 # 分布式锁超时(毫秒)
|
||||
max_retries: 3 # 冷启动获取锁失败最大重试次数
|
||||
|
||||
# 版本日志增量同步配置
|
||||
# 环境变量:
|
||||
# APP_VERSION_LOG_ENABLED, APP_VERSION_LOG_SYNC_LIMIT
|
||||
# APP_VERSION_LOG_MAX_SYNC_GAP
|
||||
version_log:
|
||||
enabled: false # 启用后客户端可使用 /conversations/sync 增量同步
|
||||
sync_limit: 100 # 单次同步最大返回变更条数
|
||||
max_sync_gap: 1000 # 版本差距超过此值建议全量同步
|
||||
|
||||
# LiveKit SFU 配置(语音/视频通话)
|
||||
# 环境变量:
|
||||
# APP_LIVEKIT_ENABLED, APP_LIVEKIT_URL, APP_LIVEKIT_API_KEY, APP_LIVEKIT_API_SECRET
|
||||
# APP_LIVEKIT_TOKEN_TTL, APP_LIVEKIT_WEBHOOK_SECRET
|
||||
livekit:
|
||||
enabled: false
|
||||
url: "http://localhost:7880"
|
||||
api_key: "devkey"
|
||||
api_secret: "u1sLYiHWe4HIr3KrJrvknIleoHdgHdDqSjfkBvRs2AlA"
|
||||
token_ttl: 600 # token 有效期(秒)
|
||||
webhook_secret: "" # webhook 签名密钥(与 LiveKit 服务端配置一致)
|
||||
|
||||
32
configs/livekit.yaml
Normal file
32
configs/livekit.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# LiveKit Server Configuration
|
||||
# See https://docs.livekit.io/realtime/server/config/ for all options
|
||||
|
||||
port: 7880
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
use_external_ip: false
|
||||
# Enable TURN/TLS relay for restrictive networks
|
||||
port_range_start: 7881
|
||||
port_range_end: 7882
|
||||
node_ip: 127.0.0.1
|
||||
|
||||
keys:
|
||||
devkey: u1sLYiHWe4HIr3KrJrvknIleoHdgHdDqSjfkBvRs2AlA
|
||||
|
||||
logging:
|
||||
level: info
|
||||
|
||||
# Redis for distributed state (required)
|
||||
redis:
|
||||
address: livekit-redis:6379
|
||||
|
||||
# PostgreSQL for persistent storage (rooms, participants, egress)
|
||||
db:
|
||||
address: postgres://postgres:postgres@livekit-postgres:5432/livekit?sslmode=disable
|
||||
|
||||
# Webhook for call history persistence
|
||||
# webhook:
|
||||
# urls:
|
||||
# - http://app:8080/api/v1/calls/webhook
|
||||
# api_key: devkey
|
||||
@@ -55,6 +55,58 @@ services:
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
|
||||
# LiveKit SFU Server (voice/video calling)
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9.5
|
||||
container_name: livekit
|
||||
ports:
|
||||
- "7880:7880" # HTTP (token, webhooks, dashboard)
|
||||
- "7881:7881" # TCP/TURN fallback
|
||||
- "7882:7882" # UDP media
|
||||
volumes:
|
||||
- ./configs/livekit.yaml:/etc/livekit.yaml
|
||||
command: --config /etc/livekit.yaml --node-ip=127.0.0.1
|
||||
depends_on:
|
||||
livekit-redis:
|
||||
condition: service_started
|
||||
livekit-postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# LiveKit Redis (required for distributed coordination)
|
||||
livekit-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: livekit_redis
|
||||
ports:
|
||||
- "6380:6379"
|
||||
volumes:
|
||||
- livekit_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# LiveKit PostgreSQL (required for room/participant state)
|
||||
livekit-postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: livekit_postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: livekit
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- livekit_pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d livekit"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
@@ -76,3 +128,5 @@ volumes:
|
||||
# postgres_data:
|
||||
# redis_data:
|
||||
minio_data:
|
||||
livekit_redis_data:
|
||||
livekit_pg_data:
|
||||
|
||||
177
go.mod
177
go.mod
@@ -1,114 +1,159 @@
|
||||
module carrot_bbs
|
||||
module with_you
|
||||
|
||||
go 1.25
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.31.0
|
||||
github.com/casbin/casbin/v2 v2.135.0
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/casbin/casbin/v3 v3.10.0
|
||||
github.com/casbin/gorm-adapter/v3 v3.41.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/gorse-io/gorse-go v0.5.0-alpha.3
|
||||
github.com/minio/minio-go/v7 v7.0.66
|
||||
github.com/redis/go-redis/v9 v9.3.1
|
||||
github.com/spf13/viper v1.18.2
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/image v0.24.0
|
||||
google.golang.org/grpc v1.59.0
|
||||
google.golang.org/protobuf v1.31.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/livekit/protocol v1.39.4-0.20250721114233-52633eee694f
|
||||
github.com/minio/minio-go/v7 v7.0.99
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.38.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
gorm.io/plugin/dbresolver v1.6.2
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 // indirect
|
||||
buf.build/go/protovalidate v0.14.0 // indirect
|
||||
buf.build/go/protoyaml v0.6.0 // indirect
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dennwc/iters v1.1.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/frostbyte73/core v0.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gammazero/deque v1.1.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-logr/logr v1.2.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/google/cel-go v0.26.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/jxskiss/base62 v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
|
||||
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
|
||||
github.com/livekit/psrpc v0.6.1-0.20250511053145-465289d72c3c // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.8 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nats-io/nats.go v1.43.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/opencontainers/runc v1.1.14 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||
github.com/pion/interceptor v0.1.40 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
github.com/pion/rtp v1.8.21 // indirect
|
||||
github.com/pion/sctp v1.8.39 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.15 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.6 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||
github.com/pion/webrtc/v4 v4.1.3 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stoewer/go-strcase v1.3.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.4 // indirect
|
||||
go.opentelemetry.io/otel v1.11.1 // indirect
|
||||
go.opentelemetry.io/otel/metric v0.33.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.11.1 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/driver/sqlserver v1.6.3 // indirect
|
||||
gorm.io/plugin/dbresolver v1.6.2 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.42.2 // indirect
|
||||
modernc.org/sqlite v1.48.0 // indirect
|
||||
)
|
||||
|
||||
479
go.sum
479
go.sum
@@ -1,46 +1,64 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 h1:Lg6klmCi3v7VvpqeeLEER9/m5S8y9e9DjhqQnSCNy4k=
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
|
||||
buf.build/go/protovalidate v0.14.0 h1:kr/rC/no+DtRyYX+8KXLDxNnI1rINz0imk5K44ZpZ3A=
|
||||
buf.build/go/protovalidate v0.14.0/go.mod h1:+F/oISho9MO7gJQNYC2VWLzcO1fTPmaTA08SDYJZncA=
|
||||
buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w=
|
||||
buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q=
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||
github.com/alicebob/miniredis/v2 v2.31.0 h1:ObEFUNlJwoIiyjxdrYF0QIDE7qXcLc7D3WpSH4c22PU=
|
||||
github.com/alicebob/miniredis/v2 v2.31.0/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/casbin/casbin/v3 v3.10.0 h1:039ORla55vCeIZWd0LfzWFt1yiEA5X4W41xBW2bQuHs=
|
||||
github.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
|
||||
github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=
|
||||
@@ -48,44 +66,60 @@ github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrH
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8=
|
||||
github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dennwc/iters v1.1.0 h1:PsS3DbOU7GxSUQO0e7SGmzHkPhtwOlwbqggJ++Bgnr8=
|
||||
github.com/dennwc/iters v1.1.0/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/docker/cli v26.1.4+incompatible h1:I8PHdc0MtxEADqYJZvhBrW9bo8gawKwwenxRM7/rLu8=
|
||||
github.com/docker/cli v26.1.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
|
||||
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
|
||||
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gammazero/deque v1.1.0 h1:OyiyReBbnEG2PP0Bnv1AASLIYvyKqIFN5xfl1t8oGLo=
|
||||
github.com/gammazero/deque v1.1.0/go.mod h1:JVrR+Bj1NMQbPnYclvDlvSX0nVGReLrQZ0aUMuWLctg=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
|
||||
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -94,32 +128,42 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
|
||||
github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -127,21 +171,18 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorse-io/gorse-go v0.5.0-alpha.3 h1:GR/OWzq016VyFyTTxgQWeayGahRVzB1cGFIW/AaShC4=
|
||||
github.com/gorse-io/gorse-go v0.5.0-alpha.3/go.mod h1:ZxmVHzZPKm5pmEIlqaRDwK0rkfTRHlfziO033XZ+RW0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
@@ -156,12 +197,15 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
|
||||
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -171,27 +215,37 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
||||
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
|
||||
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
|
||||
github.com/livekit/protocol v1.39.4-0.20250721114233-52633eee694f h1:Cwe38+/ld3r5dnNmIZSALSoZPWNEMeYPZIi/qjpplLo=
|
||||
github.com/livekit/protocol v1.39.4-0.20250721114233-52633eee694f/go.mod h1:YlgUxAegtU8jZ0tVXoIV/4fHeHqqLvS+6JnPKDbpFPU=
|
||||
github.com/livekit/psrpc v0.6.1-0.20250511053145-465289d72c3c h1:WwEr0YBejYbKzk8LSaO9h8h0G9MnE7shyDu8yXQWmEc=
|
||||
github.com/livekit/psrpc v0.6.1-0.20250511053145-465289d72c3c/go.mod h1:kmD+AZPkWu0MaXIMv57jhNlbiSZZ/Jx4bzlxBDVmJes=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
|
||||
github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0=
|
||||
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
|
||||
github.com/microsoft/go-mssqldb v1.9.8 h1:d4IFMvF/o+HdpXUqbBfzHvn/NlFA75YGcfHUUvDFJEM=
|
||||
github.com/microsoft/go-mssqldb v1.9.8/go.mod h1:eGSRSGAW4hKMy5YcAenhCDjIRm2rhqIdmmwgciMzLus=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
|
||||
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE=
|
||||
github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -199,46 +253,100 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/nats-io/nats.go v1.43.0 h1:uRFZ2FEoRvP64+UUhaTokyS18XBCR/xM2vQZKO4i8ug=
|
||||
github.com/nats-io/nats.go v1.43.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/opencontainers/runc v1.1.14 h1:rgSuzbmgz5DUJjeSnw337TxDbRuqjs6iqQck/2weR6w=
|
||||
github.com/opencontainers/runc v1.1.14/go.mod h1:E4C2z+7BxR7GHXp0hAY53mek+x49X1LjPNeMTfRGvOA=
|
||||
github.com/ory/dockertest/v3 v3.11.0 h1:OiHcxKAvSDUwsEVh2BjxQQc/5EHz9n0va9awCtNGuyA=
|
||||
github.com/ory/dockertest/v3 v3.11.0/go.mod h1:VIPxS1gwT9NpPOrfD3rACs8Y9Z7yhzO4SB194iUDnUI=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||
github.com/pion/rtp v1.8.21 h1:3yrOwmZFyUpcIosNcWRpQaU+UXIJ6yxLuJ8Bx0mw37Y=
|
||||
github.com/pion/rtp v1.8.21/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sdp/v3 v3.0.15 h1:F0I1zds+K/+37ZrzdADmx2Q44OFDOPRLhPnNTaUX9hk=
|
||||
github.com/pion/sdp/v3 v3.0.15/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
|
||||
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
|
||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
||||
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||
github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
|
||||
github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds=
|
||||
github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
|
||||
github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -249,37 +357,66 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
|
||||
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE=
|
||||
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.4 h1:aUEBEdCa6iamGzg6fuYxDA8ThxvOG240mAvWDU+XLio=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.4/go.mod h1:l2MdsbKTocpPS5nQZscqTR9jd8u96VYZdcpF8Sye7mA=
|
||||
go.opentelemetry.io/otel v1.11.1 h1:4WLLAmcfkmDk2ukNXJyq3/kiz/3UzCaYq6PskJsaou4=
|
||||
go.opentelemetry.io/otel v1.11.1/go.mod h1:1nNhXBbWSD0nsL38H6btgnFN2k4i0sNLHNNMZMSbUGE=
|
||||
go.opentelemetry.io/otel/metric v0.33.0 h1:xQAyl7uGEYvrLAiV/09iTJlp1pZnQ9Wl793qbVvED1E=
|
||||
go.opentelemetry.io/otel/metric v0.33.0/go.mod h1:QlTYc+EnYNq/M2mNk1qDDMRLpqCOj2f/r5c7Fd5FYaI=
|
||||
go.opentelemetry.io/otel/trace v1.11.1 h1:ofxdnzsNrGBYXbP7t7zpUK281+go5rF7dvdIZXF8gdQ=
|
||||
go.opentelemetry.io/otel/trace v1.11.1/go.mod h1:f/Q9G7vzk5u91PhbmKbg1Qn0rzH1LJ4vbPHFGkTPtOk=
|
||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
@@ -292,21 +429,21 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -324,8 +461,8 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -333,15 +470,13 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -356,8 +491,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -386,27 +521,27 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
|
||||
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
|
||||
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -414,11 +549,10 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
@@ -438,18 +572,18 @@ gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
|
||||
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
|
||||
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -458,10 +592,9 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
|
||||
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
47
internal/cache/cache.go
vendored
47
internal/cache/cache.go
vendored
@@ -1,19 +1,25 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var ErrKeyNotFound = errors.New("key not found")
|
||||
|
||||
// Cache 缓存接口
|
||||
type Cache interface {
|
||||
// Set 设置缓存值,支持TTL
|
||||
Set(key string, value interface{}, ttl time.Duration)
|
||||
Set(key string, value any, ttl time.Duration)
|
||||
// Get 获取缓存值
|
||||
Get(key string) (interface{}, bool)
|
||||
Get(key string) (any, bool)
|
||||
// Delete 删除缓存
|
||||
Delete(key string)
|
||||
// DeleteByPrefix 根据前缀删除缓存
|
||||
@@ -26,20 +32,24 @@ type Cache interface {
|
||||
Increment(key string) int64
|
||||
// IncrementBy 增加指定值
|
||||
IncrementBy(key string, value int64) int64
|
||||
// DeleteBatch 批量删除多个 key(使用 Pipeline 减少网络往返)
|
||||
DeleteBatch(keys []string)
|
||||
|
||||
// ==================== Hash 操作 ====================
|
||||
// HSet 设置 Hash 字段
|
||||
HSet(ctx context.Context, key string, field string, value interface{}) error
|
||||
HSet(ctx context.Context, key string, field string, value any) error
|
||||
// HMSet 批量设置 Hash 字段
|
||||
HMSet(ctx context.Context, key string, values map[string]interface{}) error
|
||||
HMSet(ctx context.Context, key string, values map[string]any) error
|
||||
// HGet 获取 Hash 字段值
|
||||
HGet(ctx context.Context, key string, field string) (string, error)
|
||||
// HMGet 批量获取 Hash 字段值
|
||||
HMGet(ctx context.Context, key string, fields ...string) ([]interface{}, error)
|
||||
HMGet(ctx context.Context, key string, fields ...string) ([]any, error)
|
||||
// HGetAll 获取 Hash 所有字段
|
||||
HGetAll(ctx context.Context, key string) (map[string]string, error)
|
||||
// HDel 删除 Hash 字段
|
||||
HDel(ctx context.Context, key string, fields ...string) error
|
||||
// HIncrBy 原子递增 Hash 字段的值
|
||||
HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error)
|
||||
|
||||
// ==================== Sorted Set 操作 ====================
|
||||
// ZAdd 添加 Sorted Set 成员
|
||||
@@ -48,14 +58,20 @@ type Cache interface {
|
||||
ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error)
|
||||
// ZRevRangeByScore 按分数范围获取成员(降序)
|
||||
ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error)
|
||||
// ZRevRange 按排名倒序取成员(score 从高到低,start/stop 为排名下标,含端点)
|
||||
ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error)
|
||||
// ZReplaceSortedSet 原子替换整个 ZSET(先删后批量写入)
|
||||
ZReplaceSortedSet(ctx context.Context, key string, members []redis.Z) error
|
||||
// ZRem 删除 Sorted Set 成员
|
||||
ZRem(ctx context.Context, key string, members ...interface{}) error
|
||||
ZRem(ctx context.Context, key string, members ...any) error
|
||||
// ZCard 获取 Sorted Set 成员数量
|
||||
ZCard(ctx context.Context, key string) (int64, error)
|
||||
|
||||
// ==================== 计数器操作 ====================
|
||||
// Incr 原子递增(返回新值)
|
||||
Incr(ctx context.Context, key string) (int64, error)
|
||||
// IncrBySeq 原子递增指定值(用于 seq 对齐)
|
||||
IncrBySeq(ctx context.Context, key string, delta int64) (int64, error)
|
||||
// Expire 设置过期时间
|
||||
Expire(ctx context.Context, key string, ttl time.Duration) error
|
||||
}
|
||||
@@ -83,7 +99,7 @@ var settings = Settings{
|
||||
NullTTL: 5 * time.Second,
|
||||
JitterRatio: 0.1,
|
||||
PostListTTL: 30 * time.Second,
|
||||
ConversationTTL: 60 * time.Second,
|
||||
ConversationTTL: 120 * time.Second,
|
||||
UnreadCountTTL: 30 * time.Second,
|
||||
GroupMembersTTL: 120 * time.Second,
|
||||
DisableFlushDB: true,
|
||||
@@ -123,20 +139,19 @@ func GetSettings() Settings {
|
||||
}
|
||||
|
||||
func normalizeKey(key string) string {
|
||||
if settings.KeyPrefix == "" {
|
||||
return key
|
||||
}
|
||||
return settings.KeyPrefix + ":" + key
|
||||
return cmp.Or(settings.KeyPrefix, "") + ":" + key
|
||||
}
|
||||
|
||||
// ResolveKey 返回带全局前缀的实际 Redis 键(供需直连 Redis 的组件使用)
|
||||
func ResolveKey(key string) string {
|
||||
return normalizeKey(key)
|
||||
}
|
||||
|
||||
func normalizePrefix(prefix string) string {
|
||||
if settings.KeyPrefix == "" {
|
||||
return prefix
|
||||
}
|
||||
return settings.KeyPrefix + ":" + prefix
|
||||
return cmp.Or(settings.KeyPrefix, "") + ":" + prefix
|
||||
}
|
||||
|
||||
func SetWithJitter(c Cache, key string, value interface{}, ttl time.Duration, jitterRatio float64) {
|
||||
func SetWithJitter(c Cache, key string, value any, ttl time.Duration, jitterRatio float64) {
|
||||
if !settings.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
401
internal/cache/conversation_cache.go
vendored
401
internal/cache/conversation_cache.go
vendored
@@ -1,15 +1,45 @@
|
||||
package cache
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"with_you/internal/model"
|
||||
redisPkg "with_you/internal/pkg/redis"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// nextSeqLua 原子递增 seq:key 存在时 INCR 返回新值,不存在时返回 0 触发冷启动
|
||||
var nextSeqLua = redis.NewScript(`
|
||||
local seqKey = KEYS[1]
|
||||
if redis.call('EXISTS', seqKey) == 1 then
|
||||
return redis.call('INCR', seqKey)
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`)
|
||||
|
||||
// initSeqLua 冷启动原子初始化:SETNX 防止覆盖 + INCR 递增,返回第一个可用 seq
|
||||
var initSeqLua = redis.NewScript(`
|
||||
local seqKey = KEYS[1]
|
||||
local initVal = tonumber(ARGV[1])
|
||||
redis.call('SETNX', seqKey, initVal)
|
||||
return redis.call('INCR', seqKey)
|
||||
`)
|
||||
|
||||
// syncSeqLua 写透缓存:确保 Redis seq >= DB seq,防止冷启动后读到过期值
|
||||
var syncSeqLua = redis.NewScript(`
|
||||
local current = tonumber(redis.call('GET', KEYS[1]))
|
||||
if current == nil or current < tonumber(ARGV[1]) then
|
||||
redis.call('SET', KEYS[1], ARGV[1])
|
||||
end
|
||||
`)
|
||||
|
||||
// CachedConversation 带缓存元数据的会话
|
||||
type CachedConversation struct {
|
||||
Data *model.Conversation // 实际数据
|
||||
@@ -45,8 +75,6 @@ type MessageCacheData struct {
|
||||
Category string `json:"category"`
|
||||
SystemType string `json:"system_type,omitempty"`
|
||||
ExtraData json.RawMessage `json:"extra_data,omitempty"`
|
||||
MentionUsers string `json:"mention_users"`
|
||||
MentionAll bool `json:"mention_all"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -73,19 +101,23 @@ type ConversationCacheSettings struct {
|
||||
MessageListTTL time.Duration // 消息分页列表缓存 (5min)
|
||||
MessageIndexTTL time.Duration // 消息索引缓存 (30min)
|
||||
MessageCountTTL time.Duration // 消息计数缓存 (30min)
|
||||
|
||||
// Seq 缓存配置
|
||||
SeqTTL time.Duration // Seq 计数器缓存 (24h)
|
||||
}
|
||||
|
||||
// DefaultConversationCacheSettings 返回默认配置
|
||||
func DefaultConversationCacheSettings() *ConversationCacheSettings {
|
||||
return &ConversationCacheSettings{
|
||||
DetailTTL: 5 * time.Minute,
|
||||
ListTTL: 60 * time.Second,
|
||||
ParticipantTTL: 5 * time.Minute,
|
||||
DetailTTL: 15 * time.Minute,
|
||||
ListTTL: 120 * time.Second,
|
||||
ParticipantTTL: 15 * time.Minute,
|
||||
UnreadTTL: 30 * time.Second,
|
||||
MessageDetailTTL: 30 * time.Minute,
|
||||
MessageListTTL: 5 * time.Minute,
|
||||
MessageIndexTTL: 30 * time.Minute,
|
||||
MessageCountTTL: 30 * time.Minute,
|
||||
SeqTTL: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,8 +158,6 @@ func (m *MessageCacheData) ToModel() *model.Message {
|
||||
Category: model.MessageCategory(m.Category),
|
||||
SystemType: model.SystemMessageType(m.SystemType),
|
||||
ExtraData: parseExtraData(m.ExtraData),
|
||||
MentionUsers: m.MentionUsers,
|
||||
MentionAll: m.MentionAll,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
@@ -146,8 +176,6 @@ func MessageCacheDataFromModel(msg *model.Message) *MessageCacheData {
|
||||
Category: string(msg.Category),
|
||||
SystemType: string(msg.SystemType),
|
||||
ExtraData: serializeExtraData(msg.ExtraData),
|
||||
MentionUsers: msg.MentionUsers,
|
||||
MentionAll: msg.MentionAll,
|
||||
CreatedAt: msg.CreatedAt,
|
||||
UpdatedAt: msg.UpdatedAt,
|
||||
}
|
||||
@@ -220,6 +248,7 @@ type MessageRepository interface {
|
||||
GetMessages(convID string, page, pageSize int) ([]*model.Message, int64, error)
|
||||
GetMessagesAfterSeq(convID string, afterSeq int64, limit int) ([]*model.Message, error)
|
||||
GetMessagesBeforeSeq(convID string, beforeSeq int64, limit int) ([]*model.Message, error)
|
||||
GetNextSeq(convID string) (int64, error)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -228,22 +257,24 @@ type MessageRepository interface {
|
||||
|
||||
// ConversationCache 会话缓存管理器
|
||||
type ConversationCache struct {
|
||||
cache Cache // 底层缓存
|
||||
settings *ConversationCacheSettings // 配置
|
||||
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
||||
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
||||
cache Cache // 底层缓存
|
||||
settings *ConversationCacheSettings // 配置
|
||||
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
||||
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
||||
seqBufferMgr *SeqBufferManager // seq 预分配管理器(nil 则使用旧 INCR 逻辑)
|
||||
}
|
||||
|
||||
// NewConversationCache 创建会话缓存管理器
|
||||
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings) *ConversationCache {
|
||||
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings, seqBufferMgr *SeqBufferManager) *ConversationCache {
|
||||
if settings == nil {
|
||||
settings = DefaultConversationCacheSettings()
|
||||
}
|
||||
return &ConversationCache{
|
||||
cache: cache,
|
||||
settings: settings,
|
||||
repo: repo,
|
||||
msgRepo: msgRepo,
|
||||
cache: cache,
|
||||
settings: settings,
|
||||
repo: repo,
|
||||
msgRepo: msgRepo,
|
||||
seqBufferMgr: seqBufferMgr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +514,289 @@ func (c *ConversationCache) InvalidateUnreadCount(userID, convID string) {
|
||||
c.cache.Delete(UnreadDetailKey(userID, convID))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 已读位置缓存(OpenIM 风格 hasReadSeq)
|
||||
// ============================================================
|
||||
|
||||
// readSeqExpire 已读位置缓存 TTL
|
||||
const readSeqExpire = 30 * 24 * time.Hour
|
||||
|
||||
// SetUserReadSeq 设置用户在某会话的已读位置(防回退:仅当新值大于当前值时更新)
|
||||
func (c *ConversationCache) SetUserReadSeq(ctx context.Context, convID, userID string, seq int64) error {
|
||||
key := UserReadSeqKey(convID, userID)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
// 非 Redis 模式,直接设置
|
||||
c.cache.Set(key, seq, readSeqExpire)
|
||||
return nil
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
nKey := normalizeKey(key)
|
||||
// Lua 脚本:仅当新 seq > 当前值时才更新,防回退
|
||||
script := redis.NewScript(`
|
||||
local current = tonumber(redis.call('GET', KEYS[1]))
|
||||
if current and current >= tonumber(ARGV[1]) then
|
||||
return 0
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[1])
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
||||
return 1
|
||||
`)
|
||||
_, err := script.Run(ctx, rdb, []string{nKey}, seq, int64(readSeqExpire.Seconds())).Result()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserReadSeq 获取用户在某会话的已读位置
|
||||
func (c *ConversationCache) GetUserReadSeq(ctx context.Context, convID, userID string) (int64, error) {
|
||||
key := UserReadSeqKey(convID, userID)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
raw, ok := c.cache.Get(key)
|
||||
if !ok {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case int64:
|
||||
return v, nil
|
||||
case string:
|
||||
var n int64
|
||||
fmt.Sscanf(v, "%d", &n)
|
||||
return n, nil
|
||||
}
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
nKey := normalizeKey(key)
|
||||
val, err := rdb.Get(ctx, nKey).Int64()
|
||||
if err != nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetUserReadSeqs 批量获取用户在多个会话的已读位置
|
||||
func (c *ConversationCache) GetUserReadSeqs(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
|
||||
if len(convIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
result := make(map[string]int64, len(convIDs))
|
||||
for _, convID := range convIDs {
|
||||
if seq, err := c.GetUserReadSeq(ctx, convID, userID); err == nil {
|
||||
result[convID] = seq
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
keys := make([]string, len(convIDs))
|
||||
for i, convID := range convIDs {
|
||||
keys[i] = normalizeKey(UserReadSeqKey(convID, userID))
|
||||
}
|
||||
|
||||
vals, err := rdb.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]int64, len(convIDs))
|
||||
for i, val := range vals {
|
||||
if val == nil {
|
||||
continue
|
||||
}
|
||||
var seq int64
|
||||
fmt.Sscanf(fmt.Sprint(val), "%d", &seq)
|
||||
result[convIDs[i]] = seq
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetConvMaxSeq 获取会话的最大 seq(复用 msg_seq 计数器,读取当前值)
|
||||
func (c *ConversationCache) GetConvMaxSeq(ctx context.Context, convID string) (int64, error) {
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
nKey := normalizeKey(seqKey)
|
||||
val, err := rdb.Get(ctx, nKey).Int64()
|
||||
if err != nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetConvMaxSeqBatch 批量获取多个会话的 maxSeq(MGet 减少网络往返)
|
||||
func (c *ConversationCache) GetConvMaxSeqBatch(ctx context.Context, convIDs []string) (map[string]int64, error) {
|
||||
if len(convIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
result := make(map[string]int64, len(convIDs))
|
||||
for _, convID := range convIDs {
|
||||
if seq, err := c.GetConvMaxSeq(ctx, convID); err == nil {
|
||||
result[convID] = seq
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
keys := make([]string, len(convIDs))
|
||||
for i, convID := range convIDs {
|
||||
keys[i] = normalizeKey(MessageSeqKey(convID))
|
||||
}
|
||||
|
||||
vals, err := rdb.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]int64, len(convIDs))
|
||||
for i, val := range vals {
|
||||
if val == nil {
|
||||
continue
|
||||
}
|
||||
var seq int64
|
||||
fmt.Sscanf(fmt.Sprint(val), "%d", &seq)
|
||||
if seq > 0 {
|
||||
result[convIDs[i]] = seq
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ComputeUnreadCount 计算单个会话未读数:maxSeq - hasReadSeq
|
||||
func (c *ConversationCache) ComputeUnreadCount(ctx context.Context, convID, userID string) (int64, error) {
|
||||
maxSeq, err := c.GetConvMaxSeq(ctx, convID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
readSeq, err := c.GetUserReadSeq(ctx, convID, userID)
|
||||
if err != nil {
|
||||
// 未读到已读位置,视为 0,返回 maxSeq
|
||||
return maxSeq, nil
|
||||
}
|
||||
unread := maxSeq - readSeq
|
||||
if unread < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
// ComputeAllUnreadCount 计算所有会话未读总数:sum(maxSeq - hasReadSeq)
|
||||
func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID string, convIDs []string, maxSeqs map[string]int64) (int64, error) {
|
||||
readSeqs, err := c.GetUserReadSeqs(ctx, userID, convIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var total int64
|
||||
for _, convID := range convIDs {
|
||||
maxSeq, ok := maxSeqs[convID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
readSeq, hasRead := readSeqs[convID]
|
||||
if !hasRead {
|
||||
total += maxSeq
|
||||
continue
|
||||
}
|
||||
unread := maxSeq - readSeq
|
||||
if unread > 0 {
|
||||
total += unread
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// GetNextSeq 获取会话的下一个 seq 值(单次 Redis INCR,原子无竞态)
|
||||
func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int64, error) {
|
||||
// 优先使用 SeqBufferManager(已简化为单次 INCR)
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||
return c.seqBufferMgr.GetNextSeq(ctx, convID, false)
|
||||
}
|
||||
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
// 尝试 Redis INCR(key 存在时递增,不存在时返回 0 触发冷启动)
|
||||
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
|
||||
rdb := lc.GetRedisClient()
|
||||
if rdb != nil {
|
||||
result, err := nextSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}).Int64()
|
||||
if err == nil {
|
||||
if result > 0 {
|
||||
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||
return result, nil
|
||||
}
|
||||
// 冷启动:从 DB 加载 last_seq,SETNX+INCR 原子初始化
|
||||
return c.initSeqFromDB(ctx, convID, seqKey, rdb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redis 不可用,降级到 DB(SELECT FOR UPDATE 保证原子性)
|
||||
if c.msgRepo != nil {
|
||||
if dbSeq, dbErr := c.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||
return dbSeq, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("seq allocation failed for %s: no redis or db available", convID)
|
||||
}
|
||||
|
||||
// initSeqFromDB 冷启动:从 DB 加载 last_seq,用 SETNX+INCR 原子初始化
|
||||
func (c *ConversationCache) initSeqFromDB(ctx context.Context, convID, seqKey string, rdb *redisPkg.Client) (int64, error) {
|
||||
if c.repo == nil {
|
||||
return 0, fmt.Errorf("conversation repository not configured for seq init")
|
||||
}
|
||||
conv, dbErr := c.repo.GetConversationByID(convID)
|
||||
if dbErr != nil {
|
||||
return 0, fmt.Errorf("load conversation for seq init: %w", dbErr)
|
||||
}
|
||||
result, initErr := initSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, conv.LastSeq).Int64()
|
||||
if initErr != nil {
|
||||
return 0, fmt.Errorf("initSeqLua failed: %w", initErr)
|
||||
}
|
||||
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetNextSeqWithGroup 获取会话的下一个 seq 值(isGroup 参数保留兼容,简化后不区分步长)
|
||||
func (c *ConversationCache) GetNextSeqWithGroup(ctx context.Context, convID string, isGroup bool) (int64, error) {
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||
return c.seqBufferMgr.GetNextSeq(ctx, convID, isGroup)
|
||||
}
|
||||
return c.GetNextSeq(ctx, convID)
|
||||
}
|
||||
|
||||
// SyncConvSeq 写透缓存:将 DB 刚分配的 seq 同步到 Redis,确保 Redis seq >= DB seq
|
||||
func (c *ConversationCache) SyncConvSeq(ctx context.Context, convID string, seq int64) {
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
|
||||
rdb := lc.GetRedisClient()
|
||||
if rdb != nil {
|
||||
_ = syncSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, seq).Err()
|
||||
}
|
||||
}
|
||||
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.rdb != nil {
|
||||
_ = syncSeqLua.Run(ctx, c.seqBufferMgr.rdb, []string{seqKey}, seq).Err()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息缓存方法
|
||||
// ============================================================
|
||||
@@ -623,11 +937,11 @@ func (c *ConversationCache) GetMessagesBeforeSeq(ctx context.Context, convID str
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// CacheMessage 缓存单条消息(立即写入缓存)
|
||||
// 写入 Hash、Sorted Set、更新计数
|
||||
// CacheMessage 缓存单条消息(使用 Redis Pipeline 减少网络往返)
|
||||
func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg *model.Message) error {
|
||||
hashKey := MessageHashKey(convID)
|
||||
indexKey := MessageIndexKey(convID)
|
||||
countKey := MessageCountKey(convID)
|
||||
|
||||
msgData := MessageCacheDataFromModel(msg)
|
||||
data, err := json.Marshal(msgData)
|
||||
@@ -635,23 +949,34 @@ func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
// HSET 消息详情
|
||||
if err := c.cache.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
|
||||
hashKey = ResolveKey(hashKey)
|
||||
indexKey = ResolveKey(indexKey)
|
||||
countKey = ResolveKey(countKey)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if ok {
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
pipe := rdb.Pipeline()
|
||||
pipe.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data))
|
||||
pipe.ZAdd(ctx, indexKey, redis.Z{Score: float64(msg.Seq), Member: fmt.Sprintf("%d", msg.Seq)})
|
||||
pipe.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
|
||||
pipe.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
|
||||
pipe.Incr(ctx, countKey)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return fmt.Errorf("failed to pipeline cache message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.cache.HSet(ctx, MessageHashKey(convID), fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
|
||||
return fmt.Errorf("failed to set hash: %w", err)
|
||||
}
|
||||
|
||||
// ZADD 消息索引
|
||||
if err := c.cache.ZAdd(ctx, indexKey, float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
|
||||
if err := c.cache.ZAdd(ctx, MessageIndexKey(convID), float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
|
||||
return fmt.Errorf("failed to add to sorted set: %w", err)
|
||||
}
|
||||
|
||||
// 设置 TTL
|
||||
c.cache.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
|
||||
c.cache.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
|
||||
|
||||
// INCR 消息计数
|
||||
c.cache.Expire(ctx, MessageHashKey(convID), c.settings.MessageDetailTTL)
|
||||
c.cache.Expire(ctx, MessageIndexKey(convID), c.settings.MessageIndexTTL)
|
||||
c.cache.Incr(ctx, MessageCountKey(convID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -719,6 +1044,10 @@ func (c *ConversationCache) getMessagesBySeqs(ctx context.Context, convID string
|
||||
// asyncCacheMessage 异步缓存单条消息
|
||||
func (c *ConversationCache) asyncCacheMessage(ctx context.Context, convID string, msg *model.Message) {
|
||||
if err := c.CacheMessage(ctx, convID, msg); err != nil {
|
||||
log.Printf("[ConversationCache] async cache message failed, convID=%s, msgID=%s, err=%v", convID, msg.ID, err)
|
||||
zap.L().Error("async cache message failed",
|
||||
zap.String("convID", convID),
|
||||
zap.String("msgID", msg.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
122
internal/cache/idempotency.go
vendored
Normal file
122
internal/cache/idempotency.go
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
redislib "github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// IdempotencyResult 描述幂等检查的结果。
|
||||
type IdempotencyResult struct {
|
||||
// Acquired 为 true 表示当前请求成功抢占到幂等键,调用方应执行业务逻辑。
|
||||
// 为 false 表示检测到重复请求:ExistingValue 为先前写入的值(可能为空,
|
||||
// 表示前一个请求仍在进行中——即"进行中"占位)。
|
||||
Acquired bool
|
||||
// ExistingValue 命中已有幂等记录时的值(如已创建的 postID)。
|
||||
// Acquired=true 时为空字符串。
|
||||
ExistingValue string
|
||||
}
|
||||
|
||||
// TryAcquireIdempotency 原子抢占幂等键。
|
||||
//
|
||||
// 流程:
|
||||
// 1. 读取幂等键。若已有值,视为重复请求,返回 (Acquired=false, ExistingValue=...)。
|
||||
// 2. 若无值,用 SetNX 写入空占位(标记"进行中")。成功则获得执行权 (Acquired=true)。
|
||||
//
|
||||
// 降级:当 Redis 不可用(cache 非 *LayeredCache/*RedisCache,或底层 client 为 nil)时,
|
||||
// 返回 (Acquired=true),即不阻断业务(与现有 call_service 的降级策略一致),
|
||||
// 此时幂等性退化为依赖前端防抖。
|
||||
//
|
||||
// 调用方在业务成功后必须调用 CompleteIdempotency 写入真实结果,以便后续重试可返回同一资源。
|
||||
func TryAcquireIdempotency(ctx context.Context, c Cache, key string, ttl time.Duration) IdempotencyResult {
|
||||
if c == nil {
|
||||
return IdempotencyResult{Acquired: true}
|
||||
}
|
||||
|
||||
// 1) 先查命中:若已有"完成值",直接复用,避免重复创建。
|
||||
if existing, ok := GetTyped[string](c, key); ok && existing != "" {
|
||||
return IdempotencyResult{Acquired: false, ExistingValue: existing}
|
||||
}
|
||||
|
||||
// 2) 用 SetNX 写入空占位,原子抢占执行权。
|
||||
// 占位为空字符串:表示"进行中"。后续命中时若值为空,视为并发请求进行中,
|
||||
// 直接拒绝(Acquired=false 且 ExistingValue="")。
|
||||
rdb := resolveRedisClient(c)
|
||||
if rdb == nil {
|
||||
// Redis 不可用:降级放行,依赖前端防抖。
|
||||
return IdempotencyResult{Acquired: true}
|
||||
}
|
||||
|
||||
ok, err := rdb.SetNX(ctx, normalizeKey(key), "", ttl).Result()
|
||||
if err != nil {
|
||||
zap.L().Warn("idempotency SetNX failed, falling back to non-idempotent create",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return IdempotencyResult{Acquired: true}
|
||||
}
|
||||
if !ok {
|
||||
// 已被其他请求抢占(进行中或已完成但值尚未回填)。
|
||||
// 读取实际值以区分"进行中"与"已完成"。
|
||||
if existing, ok := GetTyped[string](c, key); ok && existing != "" {
|
||||
return IdempotencyResult{Acquired: false, ExistingValue: existing}
|
||||
}
|
||||
return IdempotencyResult{Acquired: false}
|
||||
}
|
||||
return IdempotencyResult{Acquired: true}
|
||||
}
|
||||
|
||||
// CompleteIdempotency 在业务成功后回填幂等键的真实结果值(如 postID),
|
||||
// 使后续重复请求能返回同一资源。失败时仅记录日志,不影响业务结果。
|
||||
func CompleteIdempotency(ctx context.Context, c Cache, key, value string, ttl time.Duration) {
|
||||
if c == nil || value == "" {
|
||||
return
|
||||
}
|
||||
// 直接覆盖占位为真实值,保留原 TTL。
|
||||
rdb := resolveRedisClient(c)
|
||||
if rdb == nil {
|
||||
// 降级:用通用 Set 写入(LayeredCache/RedisCache 都支持)。
|
||||
c.Set(key, value, ttl)
|
||||
return
|
||||
}
|
||||
if err := rdb.Set(ctx, normalizeKey(key), value, ttl).Err(); err != nil {
|
||||
zap.L().Warn("idempotency complete Set failed",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ReleaseIdempotency 在业务失败时清除占位,允许重试。
|
||||
func ReleaseIdempotency(ctx context.Context, c Cache, key string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
// 仅当当前值仍为空占位时才删除,避免误删已回填的真实值。
|
||||
if existing, ok := GetTyped[string](c, key); ok && existing != "" {
|
||||
return
|
||||
}
|
||||
c.Delete(key)
|
||||
}
|
||||
|
||||
// resolveRedisClient 从 Cache 实现中解析出底层 *redis.Client,用于 SetNX 原子操作。
|
||||
// 返回 nil 表示 Redis 不可用(如纯内存 LRU 模式)。
|
||||
func resolveRedisClient(c Cache) *redislib.Client {
|
||||
switch v := c.(type) {
|
||||
case *LayeredCache:
|
||||
client := v.GetRedisClient()
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
return client.GetClient()
|
||||
case *RedisCache:
|
||||
client := v.GetRedisClient()
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
return client.GetClient()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
99
internal/cache/keys.go
vendored
99
internal/cache/keys.go
vendored
@@ -9,6 +9,10 @@ const (
|
||||
// 帖子相关
|
||||
PrefixPostList = "posts:list"
|
||||
PrefixPost = "posts:detail"
|
||||
// HotRankZSet 热门榜(有序集合,member=post_id,score=归一化热度,仅存 TopN)
|
||||
HotRankZSet = "posts:hot:zset"
|
||||
// HotRankTopIDs 热门榜有序 post_id 列表(JSON []string,供分层缓存本地层命中)
|
||||
HotRankTopIDs = "posts:hot:top_ids"
|
||||
|
||||
// 会话相关
|
||||
PrefixConversationList = "conversations:list"
|
||||
@@ -22,24 +26,52 @@ const (
|
||||
PrefixUnreadSystem = "unread:system"
|
||||
PrefixUnreadConversation = "unread:conversation"
|
||||
PrefixUnreadDetail = "unread:detail"
|
||||
|
||||
// 已读位置相关(OpenIM 风格:缓存 hasReadSeq,用于 O(1) 未读数计算)
|
||||
PrefixUserReadSeq = "user_read_seq"
|
||||
|
||||
// 用户相关
|
||||
PrefixUserInfo = "users:info"
|
||||
PrefixUserMe = "users:me"
|
||||
|
||||
// 频道相关(全量列表一条缓存,供 /channels、帖子 channel 填充共用)
|
||||
PrefixChannelsAllList = "channels:all_list"
|
||||
|
||||
// 消息缓存相关
|
||||
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
|
||||
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
|
||||
keyPrefixMsgCount = "msg_count" // 消息计数
|
||||
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
||||
keyPrefixMsgPage = "msg_page" // 分页缓存
|
||||
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
|
||||
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
|
||||
keyPrefixMsgCount = "msg_count" // 消息计数
|
||||
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
||||
keyPrefixMsgPage = "msg_page" // 分页缓存
|
||||
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
|
||||
|
||||
// 帖子创建幂等键前缀(clientRequestID -> 已创建的 postID)
|
||||
keyPrefixPostIdempotent = "post_idem"
|
||||
)
|
||||
|
||||
// PostListKey 生成帖子列表缓存键
|
||||
// postType: 帖子类型 (recommend, hot, follow, latest)
|
||||
// postType: 帖子类型 (hot, follow, latest)
|
||||
// page: 页码
|
||||
// pageSize: 每页数量
|
||||
// userID: 用户维度(仅在个性化列表如 follow 场景使用)
|
||||
// HotRankZSetKey 热门榜 Redis ZSET 逻辑键(仅存 TopN,会经 normalizeKey 加前缀)
|
||||
func HotRankZSetKey() string {
|
||||
return HotRankZSet
|
||||
}
|
||||
|
||||
// HotRankTopIDsKey 热门榜 Top 顺序 ID 列表键(会经 normalizeKey 加前缀)
|
||||
func HotRankTopIDsKey() string {
|
||||
return HotRankTopIDs
|
||||
}
|
||||
|
||||
func HotRankZSetChannelKey(channelID string) string {
|
||||
return fmt.Sprintf("%s:ch:%s", HotRankZSet, channelID)
|
||||
}
|
||||
|
||||
func HotRankTopIDsChannelKey(channelID string) string {
|
||||
return fmt.Sprintf("%s:ch:%s", HotRankTopIDs, channelID)
|
||||
}
|
||||
|
||||
func PostListKey(postType string, userID string, page, pageSize int) string {
|
||||
if userID == "" {
|
||||
return fmt.Sprintf("%s:%s:%d:%d", PrefixPostList, postType, page, pageSize)
|
||||
@@ -57,25 +89,13 @@ func ConversationListKey(userID string, page, pageSize int) string {
|
||||
return fmt.Sprintf("%s:%s:%d:%d", PrefixConversationList, userID, page, pageSize)
|
||||
}
|
||||
|
||||
// ConversationDetailKey 生成会话详情缓存键
|
||||
func ConversationDetailKey(conversationID, userID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", PrefixConversationDetail, conversationID, userID)
|
||||
}
|
||||
|
||||
// GroupMembersKey 生成群组成员缓存键
|
||||
func GroupMembersKey(groupID string, page, pageSize int) string {
|
||||
return fmt.Sprintf("%s:%s:page:%d:size:%d", PrefixGroupMembers, groupID, page, pageSize)
|
||||
}
|
||||
|
||||
// GroupMembersAllKey 生成群组全量成员ID列表缓存键
|
||||
func GroupMembersAllKey(groupID string) string {
|
||||
return fmt.Sprintf("%s:all:%s", PrefixGroupMembers, groupID)
|
||||
}
|
||||
|
||||
// GroupInfoKey 生成群组信息缓存键
|
||||
func GroupInfoKey(groupID string) string {
|
||||
return fmt.Sprintf("%s:%s", PrefixGroupInfo, groupID)
|
||||
}
|
||||
|
||||
// UnreadSystemKey 生成系统消息未读数缓存键
|
||||
func UnreadSystemKey(userID string) string {
|
||||
@@ -92,14 +112,15 @@ func UnreadDetailKey(userID, conversationID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", PrefixUnreadDetail, userID, conversationID)
|
||||
}
|
||||
|
||||
// UserInfoKey 生成用户信息缓存键
|
||||
func UserInfoKey(userID string) string {
|
||||
return fmt.Sprintf("%s:%s", PrefixUserInfo, userID)
|
||||
|
||||
// ChannelsAllListKey 频道全量列表缓存键(含未启用,供 MapByIDs 解析历史帖子 channel)
|
||||
func ChannelsAllListKey() string {
|
||||
return PrefixChannelsAllList
|
||||
}
|
||||
|
||||
// UserMeKey 生成当前用户信息缓存键
|
||||
func UserMeKey(userID string) string {
|
||||
return fmt.Sprintf("%s:%s", PrefixUserMe, userID)
|
||||
// InvalidateChannelList 失效频道列表缓存(管理端增删改频道后调用)
|
||||
func InvalidateChannelList(c Cache) {
|
||||
c.Delete(ChannelsAllListKey())
|
||||
}
|
||||
|
||||
// InvalidatePostList 失效帖子列表缓存
|
||||
@@ -117,20 +138,12 @@ func InvalidateConversationList(cache Cache, userID string) {
|
||||
cache.DeleteByPrefix(PrefixConversationList + ":" + userID + ":")
|
||||
}
|
||||
|
||||
// InvalidateConversationDetail 失效会话详情缓存
|
||||
func InvalidateConversationDetail(cache Cache, conversationID, userID string) {
|
||||
cache.Delete(ConversationDetailKey(conversationID, userID))
|
||||
}
|
||||
|
||||
// InvalidateGroupMembers 失效群组成员缓存
|
||||
func InvalidateGroupMembers(cache Cache, groupID string) {
|
||||
cache.DeleteByPrefix(PrefixGroupMembers + ":" + groupID)
|
||||
}
|
||||
|
||||
// InvalidateGroupInfo 失效群组信息缓存
|
||||
func InvalidateGroupInfo(cache Cache, groupID string) {
|
||||
cache.Delete(GroupInfoKey(groupID))
|
||||
}
|
||||
|
||||
// InvalidateUnreadSystem 失效系统消息未读数缓存
|
||||
func InvalidateUnreadSystem(cache Cache, userID string) {
|
||||
@@ -147,11 +160,6 @@ func InvalidateUnreadDetail(cache Cache, userID, conversationID string) {
|
||||
cache.Delete(UnreadDetailKey(userID, conversationID))
|
||||
}
|
||||
|
||||
// InvalidateUserInfo 失效用户信息缓存
|
||||
func InvalidateUserInfo(cache Cache, userID string) {
|
||||
cache.Delete(UserInfoKey(userID))
|
||||
cache.Delete(UserMeKey(userID))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息缓存 Key 生成函数
|
||||
@@ -186,3 +194,20 @@ func MessagePageKey(convID string, page, pageSize int) string {
|
||||
func InvalidateMessagePages(cache Cache, conversationID string) {
|
||||
cache.DeleteByPrefix(fmt.Sprintf("%s:%s:", keyPrefixMsgPage, conversationID))
|
||||
}
|
||||
|
||||
// MessageIdempotentKey 消息幂等键 (clientMsgID -> server MsgID)
|
||||
func MessageIdempotentKey(senderID, clientMsgID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
|
||||
}
|
||||
|
||||
// PostCreateIdempotentKey 帖子创建幂等键 (userID + clientRequestID -> postID)
|
||||
// 用于在 SetNX 原子抢占期间识别同一客户端请求的重试,避免重复发帖。
|
||||
func PostCreateIdempotentKey(userID, clientRequestID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", keyPrefixPostIdempotent, userID, clientRequestID)
|
||||
}
|
||||
|
||||
// UserReadSeqKey 用户已读位置缓存键(OpenIM 风格 SEQ_USER_READ:{convID}:{userID})
|
||||
func UserReadSeqKey(convID, userID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", PrefixUserReadSeq, convID, userID)
|
||||
}
|
||||
|
||||
|
||||
461
internal/cache/layered_cache.go
vendored
Normal file
461
internal/cache/layered_cache.go
vendored
Normal file
@@ -0,0 +1,461 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
redislib "github.com/redis/go-redis/v9"
|
||||
|
||||
redisPkg "with_you/internal/pkg/redis"
|
||||
)
|
||||
|
||||
type LayeredCache struct {
|
||||
local *LRUStriped
|
||||
redis *RedisCache
|
||||
mu sync.RWMutex
|
||||
stats CacheStats
|
||||
enabled bool
|
||||
keyPrefix string
|
||||
}
|
||||
|
||||
type CacheStats struct {
|
||||
LocalHits int64
|
||||
RedisHits int64
|
||||
Misses int64
|
||||
Sets int64
|
||||
Invalidates int64
|
||||
}
|
||||
|
||||
type LayeredCacheOptions struct {
|
||||
LocalSize int
|
||||
LocalBuckets int
|
||||
LocalDefaultTTL time.Duration
|
||||
KeyPrefix string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func NewLayeredCache(redisClient *redisPkg.Client, opts *LayeredCacheOptions) *LayeredCache {
|
||||
if opts == nil {
|
||||
opts = &LayeredCacheOptions{
|
||||
LocalSize: 10000,
|
||||
LocalBuckets: 0,
|
||||
LocalDefaultTTL: 5 * time.Minute,
|
||||
KeyPrefix: "",
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
if opts.LocalSize <= 0 {
|
||||
opts.LocalSize = 10000
|
||||
}
|
||||
if opts.LocalDefaultTTL <= 0 {
|
||||
opts.LocalDefaultTTL = 5 * time.Minute
|
||||
}
|
||||
|
||||
localCache := NewLRUStriped(&LRUStripedOptions{
|
||||
Size: opts.LocalSize,
|
||||
DefaultExpiry: opts.LocalDefaultTTL,
|
||||
Buckets: opts.LocalBuckets,
|
||||
Name: "layered_local",
|
||||
})
|
||||
|
||||
var redisCache *RedisCache
|
||||
if redisClient != nil {
|
||||
redisCache = NewRedisCache(redisClient)
|
||||
}
|
||||
|
||||
return &LayeredCache{
|
||||
local: localCache,
|
||||
redis: redisCache,
|
||||
enabled: opts.Enabled,
|
||||
keyPrefix: opts.KeyPrefix,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LayeredCache) Set(key string, value any, ttl time.Duration) {
|
||||
if !c.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.stats.Sets++
|
||||
c.mu.Unlock()
|
||||
|
||||
c.local.Set(key, value, ttl)
|
||||
|
||||
if c.redis != nil {
|
||||
c.redis.Set(key, value, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LayeredCache) Get(key string) (any, bool) {
|
||||
if !c.enabled {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if val, ok := c.local.Get(key); ok {
|
||||
c.mu.Lock()
|
||||
c.stats.LocalHits++
|
||||
c.mu.Unlock()
|
||||
return val, true
|
||||
}
|
||||
|
||||
if c.redis != nil {
|
||||
if val, ok := c.redis.Get(key); ok {
|
||||
c.mu.Lock()
|
||||
c.stats.RedisHits++
|
||||
c.mu.Unlock()
|
||||
|
||||
if ttl := c.getLocalTTL(); ttl > 0 {
|
||||
if str, ok := val.(string); ok {
|
||||
c.local.SetRaw(key, []byte(str), ttl)
|
||||
} else {
|
||||
c.local.Set(key, val, ttl)
|
||||
}
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.stats.Misses++
|
||||
c.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (c *LayeredCache) getLocalTTL() time.Duration {
|
||||
return 30 * time.Second
|
||||
}
|
||||
|
||||
func (c *LayeredCache) Delete(key string) {
|
||||
if !c.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.stats.Invalidates++
|
||||
c.mu.Unlock()
|
||||
|
||||
c.local.Delete(key)
|
||||
if c.redis != nil {
|
||||
c.redis.Delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LayeredCache) DeleteByPrefix(prefix string) {
|
||||
if !c.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.stats.Invalidates++
|
||||
c.mu.Unlock()
|
||||
|
||||
c.local.DeleteByPrefix(prefix)
|
||||
if c.redis != nil {
|
||||
c.redis.DeleteByPrefix(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LayeredCache) DeleteBatch(keys []string) {
|
||||
if !c.enabled || len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.stats.Invalidates += int64(len(keys))
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, k := range keys {
|
||||
c.local.Delete(k)
|
||||
}
|
||||
if c.redis != nil {
|
||||
c.redis.DeleteBatch(keys)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LayeredCache) Clear() {
|
||||
c.local.Clear()
|
||||
if c.redis != nil {
|
||||
c.redis.Clear()
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.stats = CacheStats{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *LayeredCache) Exists(key string) bool {
|
||||
if !c.enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
if c.local.Exists(key) {
|
||||
return true
|
||||
}
|
||||
|
||||
if c.redis != nil {
|
||||
return c.redis.Exists(key)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *LayeredCache) Increment(key string) int64 {
|
||||
if !c.enabled {
|
||||
return 0
|
||||
}
|
||||
|
||||
if c.redis != nil {
|
||||
val := c.redis.Increment(key)
|
||||
c.local.Delete(key)
|
||||
return val
|
||||
}
|
||||
|
||||
return c.local.Increment(key)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) IncrementBy(key string, value int64) int64 {
|
||||
if !c.enabled {
|
||||
return 0
|
||||
}
|
||||
|
||||
if c.redis != nil {
|
||||
val := c.redis.IncrementBy(key, value)
|
||||
c.local.Delete(key)
|
||||
return val
|
||||
}
|
||||
|
||||
return c.local.IncrementBy(key, value)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) GetStats() CacheStats {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.stats
|
||||
}
|
||||
|
||||
func (c *LayeredCache) GetHitRate() float64 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
total := c.stats.LocalHits + c.stats.RedisHits + c.stats.Misses
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(c.stats.LocalHits+c.stats.RedisHits) / float64(total)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) HSet(ctx context.Context, key string, field string, value any) error {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.HSet(ctx, key, field, value)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) HMSet(ctx context.Context, key string, values map[string]any) error {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.HMSet(ctx, key, values)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) HGet(ctx context.Context, key string, field string) (string, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return "", ErrKeyNotFound
|
||||
}
|
||||
return c.redis.HGet(ctx, key, field)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) HMGet(ctx context.Context, key string, fields ...string) ([]any, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
return c.redis.HMGet(ctx, key, fields...)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) HGetAll(ctx context.Context, key string) (map[string]string, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
return c.redis.HGetAll(ctx, key)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) HDel(ctx context.Context, key string, fields ...string) error {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.HDel(ctx, key, fields...)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.HIncrBy(ctx, key, field, incr)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) ZAdd(ctx context.Context, key string, score float64, member string) error {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.ZAdd(ctx, key, score, member)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
return c.redis.ZRangeByScore(ctx, key, min, max, offset, count)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
return c.redis.ZRevRangeByScore(ctx, key, max, min, offset, count)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
return c.redis.ZRevRange(ctx, key, start, stop)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) ZReplaceSortedSet(ctx context.Context, key string, members []redislib.Z) error {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return ErrKeyNotFound
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.ZReplaceSortedSet(ctx, key, members)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) ZRem(ctx context.Context, key string, members ...any) error {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.ZRem(ctx, key, members...)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) ZCard(ctx context.Context, key string) (int64, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
return c.redis.ZCard(ctx, key)
|
||||
}
|
||||
|
||||
// IsEnabled 返回 Redis 是否可用
|
||||
func (c *LayeredCache) IsEnabled() bool {
|
||||
return c.enabled && c.redis != nil
|
||||
}
|
||||
|
||||
func (c *LayeredCache) Incr(ctx context.Context, key string) (int64, error) {
|
||||
if !c.IsEnabled() {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.Incr(ctx, key)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) IncrBySeq(ctx context.Context, key string, delta int64) (int64, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.IncrBySeq(ctx, key, delta)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil
|
||||
}
|
||||
return c.redis.Expire(ctx, key, ttl)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) SetLocalOnly(key string, value any, ttl time.Duration) {
|
||||
if !c.enabled {
|
||||
return
|
||||
}
|
||||
c.local.Set(key, value, ttl)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) GetLocalOnly(key string) (any, bool) {
|
||||
if !c.enabled {
|
||||
return nil, false
|
||||
}
|
||||
return c.local.Get(key)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) SetRedisOnly(key string, value any, ttl time.Duration) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return
|
||||
}
|
||||
c.redis.Set(key, value, ttl)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) GetRedisOnly(key string) (any, bool) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil, false
|
||||
}
|
||||
return c.redis.Get(key)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) GetRedisClient() *redisPkg.Client {
|
||||
if c.redis == nil {
|
||||
return nil
|
||||
}
|
||||
return c.redis.GetRedisClient()
|
||||
}
|
||||
|
||||
|
||||
func (c *LayeredCache) InvalidateUserCache(userID uint) {
|
||||
c.DeleteByPrefix(cacheKeyPrefixUser + ":")
|
||||
c.Delete(buildUserDetailKey(userID))
|
||||
}
|
||||
|
||||
func (c *LayeredCache) InvalidatePostCache(postID uint) {
|
||||
c.Delete(buildPostDetailKey(postID))
|
||||
c.DeleteByPrefix(cacheKeyPrefixPostList)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) InvalidateGroupCache(groupID uint) {
|
||||
c.Delete(buildGroupDetailKey(groupID))
|
||||
c.Delete(buildGroupMembersKey(groupID))
|
||||
}
|
||||
|
||||
func (c *LayeredCache) InvalidateAllLists() {
|
||||
c.DeleteByPrefix(cacheKeyPrefixPostList)
|
||||
c.DeleteByPrefix(cacheKeyPrefixUserActivity)
|
||||
}
|
||||
|
||||
var (
|
||||
cacheKeyPrefixUser = "user"
|
||||
cacheKeyPrefixPostList = "post_list"
|
||||
cacheKeyPrefixUserActivity = "user_activity"
|
||||
)
|
||||
|
||||
func buildUserDetailKey(userID uint) string {
|
||||
return "user:detail:" + strconv.FormatUint(uint64(userID), 10)
|
||||
}
|
||||
|
||||
func buildPostDetailKey(postID uint) string {
|
||||
return "post:detail:" + strconv.FormatUint(uint64(postID), 10)
|
||||
}
|
||||
|
||||
func buildGroupDetailKey(groupID uint) string {
|
||||
return "group:detail:" + strconv.FormatUint(uint64(groupID), 10)
|
||||
}
|
||||
|
||||
func buildGroupMembersKey(groupID uint) string {
|
||||
return "group:members:" + strconv.FormatUint(uint64(groupID), 10)
|
||||
}
|
||||
|
||||
var _ Cache = (*LayeredCache)(nil)
|
||||
238
internal/cache/lru.go
vendored
Normal file
238
internal/cache/lru.go
vendored
Normal file
@@ -0,0 +1,238 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LRUOptions struct {
|
||||
Size int
|
||||
DefaultExpiry time.Duration
|
||||
Name string
|
||||
}
|
||||
|
||||
type LRU struct {
|
||||
lock sync.RWMutex
|
||||
size int
|
||||
len int
|
||||
currentGeneration int64
|
||||
evictList *list.List
|
||||
items map[string]*list.Element
|
||||
defaultExpiry time.Duration
|
||||
name string
|
||||
}
|
||||
|
||||
type lruEntry struct {
|
||||
key string
|
||||
value []byte
|
||||
expires time.Time
|
||||
generation int64
|
||||
}
|
||||
|
||||
func NewLRU(opts *LRUOptions) *LRU {
|
||||
if opts.Size <= 0 {
|
||||
opts.Size = 1000
|
||||
}
|
||||
return &LRU{
|
||||
name: opts.Name,
|
||||
size: opts.Size,
|
||||
evictList: list.New(),
|
||||
items: make(map[string]*list.Element, opts.Size),
|
||||
defaultExpiry: opts.DefaultExpiry,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LRU) Set(key string, value any, ttl time.Duration) {
|
||||
if ttl <= 0 {
|
||||
ttl = l.defaultExpiry
|
||||
}
|
||||
l.set(key, value, ttl)
|
||||
}
|
||||
|
||||
func (l *LRU) set(key string, value any, ttl time.Duration) {
|
||||
var expires time.Time
|
||||
if ttl > 0 {
|
||||
expires = time.Now().Add(ttl)
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
if ent, ok := l.items[key]; ok {
|
||||
l.evictList.MoveToFront(ent)
|
||||
e := ent.Value.(*lruEntry)
|
||||
e.value = buf
|
||||
e.expires = expires
|
||||
if e.generation != l.currentGeneration {
|
||||
e.generation = l.currentGeneration
|
||||
l.len++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ent := &lruEntry{key, buf, expires, l.currentGeneration}
|
||||
entry := l.evictList.PushFront(ent)
|
||||
l.items[key] = entry
|
||||
l.len++
|
||||
|
||||
if l.evictList.Len() > l.size {
|
||||
l.removeElement(l.evictList.Back())
|
||||
}
|
||||
}
|
||||
|
||||
// SetRaw stores raw bytes directly without re-serializing.
|
||||
// This avoids double-encoding when promoting values from Redis to local cache.
|
||||
func (l *LRU) SetRaw(key string, raw []byte, ttl time.Duration) {
|
||||
var expires time.Time
|
||||
if ttl > 0 {
|
||||
expires = time.Now().Add(ttl)
|
||||
}
|
||||
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
if ent, ok := l.items[key]; ok {
|
||||
l.evictList.MoveToFront(ent)
|
||||
e := ent.Value.(*lruEntry)
|
||||
e.value = raw
|
||||
e.expires = expires
|
||||
if e.generation != l.currentGeneration {
|
||||
e.generation = l.currentGeneration
|
||||
l.len++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ent := &lruEntry{key, raw, expires, l.currentGeneration}
|
||||
entry := l.evictList.PushFront(ent)
|
||||
l.items[key] = entry
|
||||
l.len++
|
||||
|
||||
if l.evictList.Len() > l.size {
|
||||
l.removeElement(l.evictList.Back())
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LRU) Get(key string) (any, bool) {
|
||||
val, err := l.getItem(key)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
|
||||
func (l *LRU) getItem(key string) ([]byte, error) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
ent, ok := l.items[key]
|
||||
if !ok {
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
e := ent.Value.(*lruEntry)
|
||||
if e.generation != l.currentGeneration || (!e.expires.IsZero() && time.Now().After(e.expires)) {
|
||||
l.removeElement(ent)
|
||||
return nil, ErrKeyNotFound
|
||||
}
|
||||
l.evictList.MoveToFront(ent)
|
||||
return e.value, nil
|
||||
}
|
||||
|
||||
func (l *LRU) Delete(key string) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
if ent, ok := l.items[key]; ok {
|
||||
l.removeElement(ent)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LRU) DeleteByPrefix(prefix string) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
for key, ent := range l.items {
|
||||
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
|
||||
l.removeElement(ent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LRU) Clear() {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
l.len = 0
|
||||
l.currentGeneration++
|
||||
}
|
||||
|
||||
func (l *LRU) Exists(key string) bool {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
ent, ok := l.items[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e := ent.Value.(*lruEntry)
|
||||
return e.generation == l.currentGeneration && (e.expires.IsZero() || !time.Now().After(e.expires))
|
||||
}
|
||||
|
||||
func (l *LRU) Increment(key string) int64 {
|
||||
return l.IncrementBy(key, 1)
|
||||
}
|
||||
|
||||
func (l *LRU) IncrementBy(key string, value int64) int64 {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
var current int64
|
||||
if ent, ok := l.items[key]; ok {
|
||||
e := ent.Value.(*lruEntry)
|
||||
if e.generation == l.currentGeneration && (e.expires.IsZero() || !time.Now().After(e.expires)) {
|
||||
json.Unmarshal(e.value, ¤t)
|
||||
}
|
||||
}
|
||||
|
||||
current += value
|
||||
buf, _ := json.Marshal(current)
|
||||
|
||||
if ent, ok := l.items[key]; ok {
|
||||
e := ent.Value.(*lruEntry)
|
||||
e.value = buf
|
||||
l.evictList.MoveToFront(ent)
|
||||
} else {
|
||||
ent := &lruEntry{key, buf, time.Time{}, l.currentGeneration}
|
||||
entry := l.evictList.PushFront(ent)
|
||||
l.items[key] = entry
|
||||
l.len++
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
func (l *LRU) Len() int {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
return l.len
|
||||
}
|
||||
|
||||
func (l *LRU) Name() string {
|
||||
return l.name
|
||||
}
|
||||
|
||||
func (l *LRU) removeElement(e *list.Element) {
|
||||
l.evictList.Remove(e)
|
||||
kv := e.Value.(*lruEntry)
|
||||
if kv.generation == l.currentGeneration {
|
||||
l.len--
|
||||
}
|
||||
delete(l.items, kv.key)
|
||||
}
|
||||
176
internal/cache/lru_striped.go
vendored
Normal file
176
internal/cache/lru_striped.go
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"math"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/cespare/xxhash/v2"
|
||||
)
|
||||
|
||||
type LRUStriped struct {
|
||||
buckets []*LRU
|
||||
name string
|
||||
}
|
||||
|
||||
type LRUStripedOptions struct {
|
||||
Size int
|
||||
DefaultExpiry time.Duration
|
||||
Name string
|
||||
Buckets int
|
||||
}
|
||||
|
||||
func NewLRUStriped(opts *LRUStripedOptions) *LRUStriped {
|
||||
if opts.Buckets <= 0 {
|
||||
opts.Buckets = runtime.NumCPU()
|
||||
}
|
||||
if opts.Buckets < 4 {
|
||||
opts.Buckets = 4
|
||||
}
|
||||
if opts.Size < opts.Buckets {
|
||||
opts.Size = opts.Buckets * 100
|
||||
}
|
||||
|
||||
opts.Size += int(math.Ceil(float64(opts.Size) * 10.0 / 100.0))
|
||||
bucketSize := (opts.Size / opts.Buckets) + (opts.Size % opts.Buckets)
|
||||
|
||||
buckets := make([]*LRU, opts.Buckets)
|
||||
for i := 0; i < opts.Buckets; i++ {
|
||||
buckets[i] = NewLRU(&LRUOptions{
|
||||
Size: bucketSize,
|
||||
DefaultExpiry: opts.DefaultExpiry,
|
||||
Name: opts.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return &LRUStriped{
|
||||
buckets: buckets,
|
||||
name: opts.Name,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LRUStriped) hashKey(key string) uint64 {
|
||||
return xxhash.Sum64String(key)
|
||||
}
|
||||
|
||||
func (l *LRUStriped) keyBucket(key string) *LRU {
|
||||
return l.buckets[l.hashKey(key)%uint64(len(l.buckets))]
|
||||
}
|
||||
|
||||
func (l *LRUStriped) Set(key string, value any, ttl time.Duration) {
|
||||
l.keyBucket(key).Set(key, value, ttl)
|
||||
}
|
||||
|
||||
func (l *LRUStriped) Get(key string) (any, bool) {
|
||||
return l.keyBucket(key).Get(key)
|
||||
}
|
||||
|
||||
func (l *LRUStriped) Delete(key string) {
|
||||
l.keyBucket(key).Delete(key)
|
||||
}
|
||||
|
||||
func (l *LRUStriped) DeleteByPrefix(prefix string) {
|
||||
for _, bucket := range l.buckets {
|
||||
bucket.DeleteByPrefix(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LRUStriped) Clear() {
|
||||
for _, bucket := range l.buckets {
|
||||
bucket.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LRUStriped) Exists(key string) bool {
|
||||
return l.keyBucket(key).Exists(key)
|
||||
}
|
||||
|
||||
func (l *LRUStriped) Increment(key string) int64 {
|
||||
return l.keyBucket(key).Increment(key)
|
||||
}
|
||||
|
||||
func (l *LRUStriped) IncrementBy(key string, value int64) int64 {
|
||||
return l.keyBucket(key).IncrementBy(key, value)
|
||||
}
|
||||
|
||||
func (l *LRUStriped) Len() int {
|
||||
var total int
|
||||
for _, bucket := range l.buckets {
|
||||
total += bucket.Len()
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (l *LRUStriped) Name() string {
|
||||
return l.name
|
||||
}
|
||||
|
||||
func (l *LRUStriped) DeleteMulti(keys []string) error {
|
||||
for _, key := range keys {
|
||||
l.Delete(key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LRUStriped) DeleteBatch(keys []string) {
|
||||
for _, key := range keys {
|
||||
l.Delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LRUStriped) SetRaw(key string, raw []byte, ttl time.Duration) {
|
||||
l.keyBucket(key).SetRaw(key, raw, ttl)
|
||||
}
|
||||
|
||||
func (l *LRUStriped) GetMulti(keys []string, values []any) []error {
|
||||
errs := make([]error, len(values))
|
||||
for i, key := range keys {
|
||||
if val, ok := l.Get(key); ok {
|
||||
values[i] = val
|
||||
} else {
|
||||
errs[i] = ErrKeyNotFound
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func (l *LRUStriped) SetMulti(items map[string]any, ttl time.Duration) error {
|
||||
for key, value := range items {
|
||||
l.Set(key, value, ttl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LRUStriped) Keys() []string {
|
||||
keys := make([]string, 0)
|
||||
for _, bucket := range l.buckets {
|
||||
bucket.lock.RLock()
|
||||
for key, ent := range bucket.items {
|
||||
e := ent.Value.(*lruEntry)
|
||||
if e.generation == bucket.currentGeneration && (e.expires.IsZero() || !time.Now().After(e.expires)) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
bucket.lock.RUnlock()
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
type LRUStripedInterface interface {
|
||||
Set(key string, value any, ttl time.Duration)
|
||||
Get(key string) (any, bool)
|
||||
Delete(key string)
|
||||
DeleteByPrefix(prefix string)
|
||||
Clear()
|
||||
Exists(key string) bool
|
||||
Increment(key string) int64
|
||||
IncrementBy(key string, value int64) int64
|
||||
Len() int
|
||||
Name() string
|
||||
DeleteMulti(keys []string) error
|
||||
GetMulti(keys []string, values []any) []error
|
||||
SetMulti(items map[string]any, ttl time.Duration) error
|
||||
Keys() []string
|
||||
}
|
||||
|
||||
var _ LRUStripedInterface = (*LRUStriped)(nil)
|
||||
19
internal/cache/metrics.go
vendored
19
internal/cache/metrics.go
vendored
@@ -14,25 +14,6 @@ type cacheMetrics struct {
|
||||
// metrics 全局缓存指标实例
|
||||
var metrics cacheMetrics
|
||||
|
||||
// MetricsSnapshot 指标快照
|
||||
type MetricsSnapshot struct {
|
||||
Hit int64
|
||||
Miss int64
|
||||
DecodeError int64
|
||||
SetError int64
|
||||
Invalidate int64
|
||||
}
|
||||
|
||||
// GetMetricsSnapshot 获取当前指标快照
|
||||
func GetMetricsSnapshot() MetricsSnapshot {
|
||||
return MetricsSnapshot{
|
||||
Hit: metrics.hit.Load(),
|
||||
Miss: metrics.miss.Load(),
|
||||
DecodeError: metrics.decodeError.Load(),
|
||||
SetError: metrics.setError.Load(),
|
||||
Invalidate: metrics.invalidate.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// recordHit 记录缓存命中
|
||||
func recordHit() {
|
||||
|
||||
125
internal/cache/redis_cache.go
vendored
125
internal/cache/redis_cache.go
vendored
@@ -1,14 +1,14 @@
|
||||
package cache
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
redislib "github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
redisPkg "carrot_bbs/internal/pkg/redis"
|
||||
redisPkg "with_you/internal/pkg/redis"
|
||||
)
|
||||
|
||||
// RedisCache Redis缓存实现
|
||||
@@ -26,31 +26,40 @@ func NewRedisCache(client *redisPkg.Client) *RedisCache {
|
||||
}
|
||||
|
||||
// Set 设置缓存值
|
||||
func (c *RedisCache) Set(key string, value interface{}, ttl time.Duration) {
|
||||
func (c *RedisCache) Set(key string, value any, ttl time.Duration) {
|
||||
key = normalizeKey(key)
|
||||
// 将值序列化为JSON
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
recordSetError()
|
||||
log.Printf("[RedisCache] Failed to marshal value for key %s: %v", key, err)
|
||||
zap.L().Error("Failed to marshal value for key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.client.Set(c.ctx, key, data, ttl); err != nil {
|
||||
recordSetError()
|
||||
log.Printf("[RedisCache] Failed to set key %s: %v", key, err)
|
||||
zap.L().Error("Failed to set key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Get 获取缓存值
|
||||
func (c *RedisCache) Get(key string) (interface{}, bool) {
|
||||
func (c *RedisCache) Get(key string) (any, bool) {
|
||||
key = normalizeKey(key)
|
||||
data, err := c.client.Get(c.ctx, key)
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
if err == redislib.Nil {
|
||||
return nil, false
|
||||
}
|
||||
log.Printf("[RedisCache] Failed to get key %s: %v", key, err)
|
||||
zap.L().Error("Failed to get key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -63,27 +72,35 @@ func (c *RedisCache) Delete(key string) {
|
||||
key = normalizeKey(key)
|
||||
recordInvalidate()
|
||||
if err := c.client.Del(c.ctx, key); err != nil {
|
||||
log.Printf("[RedisCache] Failed to delete key %s: %v", key, err)
|
||||
zap.L().Error("Failed to delete key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteByPrefix 根据前缀删除缓存
|
||||
func (c *RedisCache) DeleteByPrefix(prefix string) {
|
||||
prefix = normalizePrefix(prefix)
|
||||
// 使用原生客户端执行SCAN命令
|
||||
rdb := c.client.GetClient()
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, nextCursor, err := rdb.Scan(c.ctx, cursor, prefix+"*", 100).Result()
|
||||
if err != nil {
|
||||
log.Printf("[RedisCache] Failed to scan keys with prefix %s: %v", prefix, err)
|
||||
zap.L().Error("Failed to scan keys with prefix",
|
||||
zap.String("prefix", prefix),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if len(keys) > 0 {
|
||||
recordInvalidateMultiple(int64(len(keys)))
|
||||
if err := c.client.Del(c.ctx, keys...); err != nil {
|
||||
log.Printf("[RedisCache] Failed to delete keys with prefix %s: %v", prefix, err)
|
||||
zap.L().Error("Failed to delete keys with prefix",
|
||||
zap.String("prefix", prefix),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,16 +111,42 @@ func (c *RedisCache) DeleteByPrefix(prefix string) {
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteBatch 批量删除多个 key(使用 Redis Pipeline 减少网络往返)
|
||||
func (c *RedisCache) DeleteBatch(keys []string) {
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
normalizedKeys := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
normalizedKeys[i] = normalizeKey(k)
|
||||
}
|
||||
|
||||
recordInvalidateMultiple(int64(len(normalizedKeys)))
|
||||
// 使用 pipeline 批量删除
|
||||
pipe := c.client.GetClient().Pipeline()
|
||||
for _, k := range normalizedKeys {
|
||||
pipe.Del(c.ctx, k)
|
||||
}
|
||||
if _, err := pipe.Exec(c.ctx); err != nil {
|
||||
zap.L().Error("Failed to batch delete keys",
|
||||
zap.Int("count", len(normalizedKeys)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear 清空所有缓存
|
||||
func (c *RedisCache) Clear() {
|
||||
if settings.DisableFlushDB {
|
||||
log.Printf("[RedisCache] Skip FlushDB because cache.disable_flushdb=true")
|
||||
zap.L().Debug("Skip FlushDB because cache.disable_flushdb=true")
|
||||
return
|
||||
}
|
||||
recordInvalidate()
|
||||
rdb := c.client.GetClient()
|
||||
if err := rdb.FlushDB(c.ctx).Err(); err != nil {
|
||||
log.Printf("[RedisCache] Failed to clear cache: %v", err)
|
||||
zap.L().Error("Failed to clear cache",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +155,10 @@ func (c *RedisCache) Exists(key string) bool {
|
||||
key = normalizeKey(key)
|
||||
n, err := c.client.Exists(c.ctx, key)
|
||||
if err != nil {
|
||||
log.Printf("[RedisCache] Failed to check existence of key %s: %v", key, err)
|
||||
zap.L().Error("Failed to check existence of key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
@@ -129,7 +175,10 @@ func (c *RedisCache) IncrementBy(key string, value int64) int64 {
|
||||
rdb := c.client.GetClient()
|
||||
result, err := rdb.IncrBy(c.ctx, key, value).Result()
|
||||
if err != nil {
|
||||
log.Printf("[RedisCache] Failed to increment key %s: %v", key, err)
|
||||
zap.L().Error("Failed to increment key",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
@@ -138,13 +187,13 @@ func (c *RedisCache) IncrementBy(key string, value int64) int64 {
|
||||
// ==================== RedisCache Hash 操作 ====================
|
||||
|
||||
// HSet 设置 Hash 字段
|
||||
func (c *RedisCache) HSet(ctx context.Context, key string, field string, value interface{}) error {
|
||||
func (c *RedisCache) HSet(ctx context.Context, key string, field string, value any) error {
|
||||
key = normalizeKey(key)
|
||||
return c.client.HSet(ctx, key, field, value)
|
||||
}
|
||||
|
||||
// HMSet 批量设置 Hash 字段
|
||||
func (c *RedisCache) HMSet(ctx context.Context, key string, values map[string]interface{}) error {
|
||||
func (c *RedisCache) HMSet(ctx context.Context, key string, values map[string]any) error {
|
||||
key = normalizeKey(key)
|
||||
return c.client.HMSet(ctx, key, values)
|
||||
}
|
||||
@@ -156,7 +205,7 @@ func (c *RedisCache) HGet(ctx context.Context, key string, field string) (string
|
||||
}
|
||||
|
||||
// HMGet 批量获取 Hash 字段值
|
||||
func (c *RedisCache) HMGet(ctx context.Context, key string, fields ...string) ([]interface{}, error) {
|
||||
func (c *RedisCache) HMGet(ctx context.Context, key string, fields ...string) ([]any, error) {
|
||||
key = normalizeKey(key)
|
||||
return c.client.HMGet(ctx, key, fields...)
|
||||
}
|
||||
@@ -173,6 +222,12 @@ func (c *RedisCache) HDel(ctx context.Context, key string, fields ...string) err
|
||||
return c.client.HDel(ctx, key, fields...)
|
||||
}
|
||||
|
||||
// HIncrBy 原子递增 Hash 字段的值
|
||||
func (c *RedisCache) HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error) {
|
||||
key = normalizeKey(key)
|
||||
return c.client.HIncrBy(ctx, key, field, incr)
|
||||
}
|
||||
|
||||
// ==================== RedisCache Sorted Set 操作 ====================
|
||||
|
||||
// ZAdd 添加 Sorted Set 成员
|
||||
@@ -193,8 +248,27 @@ func (c *RedisCache) ZRevRangeByScore(ctx context.Context, key string, max, min
|
||||
return c.client.ZRevRangeByScore(ctx, key, max, min, offset, count)
|
||||
}
|
||||
|
||||
// ZRevRange 按排名倒序获取成员
|
||||
func (c *RedisCache) ZRevRange(ctx context.Context, key string, start, stop int64) ([]string, error) {
|
||||
key = normalizeKey(key)
|
||||
return c.client.ZRevRange(ctx, key, start, stop)
|
||||
}
|
||||
|
||||
// ZReplaceSortedSet 删除后整表重写 ZSET
|
||||
func (c *RedisCache) ZReplaceSortedSet(ctx context.Context, key string, members []redislib.Z) error {
|
||||
key = normalizeKey(key)
|
||||
rdb := c.client.GetClient()
|
||||
if err := rdb.Del(ctx, key).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil
|
||||
}
|
||||
return c.client.ZAddArgs(ctx, key, members...)
|
||||
}
|
||||
|
||||
// ZRem 删除 Sorted Set 成员
|
||||
func (c *RedisCache) ZRem(ctx context.Context, key string, members ...interface{}) error {
|
||||
func (c *RedisCache) ZRem(ctx context.Context, key string, members ...any) error {
|
||||
key = normalizeKey(key)
|
||||
return c.client.ZRem(ctx, key, members...)
|
||||
}
|
||||
@@ -213,6 +287,13 @@ func (c *RedisCache) Incr(ctx context.Context, key string) (int64, error) {
|
||||
return c.client.Incr(ctx, key)
|
||||
}
|
||||
|
||||
// IncrBySeq 原子递增指定值(用于 seq 对齐)
|
||||
func (c *RedisCache) IncrBySeq(ctx context.Context, key string, delta int64) (int64, error) {
|
||||
key = normalizeKey(key)
|
||||
rdb := c.client.GetClient()
|
||||
return rdb.IncrBy(ctx, key, delta).Result()
|
||||
}
|
||||
|
||||
// Expire 设置过期时间
|
||||
func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
||||
key = normalizeKey(key)
|
||||
|
||||
19
internal/cache/repository_adapter.go
vendored
19
internal/cache/repository_adapter.go
vendored
@@ -1,17 +1,17 @@
|
||||
package cache
|
||||
package cache
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/repository"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/repository"
|
||||
)
|
||||
|
||||
// ConversationRepositoryAdapter 适配 MessageRepository 到 ConversationRepository 接口
|
||||
type ConversationRepositoryAdapter struct {
|
||||
repo *repository.MessageRepository
|
||||
repo repository.MessageRepository
|
||||
}
|
||||
|
||||
// NewConversationRepositoryAdapter 创建适配器
|
||||
func NewConversationRepositoryAdapter(repo *repository.MessageRepository) ConversationRepository {
|
||||
func NewConversationRepositoryAdapter(repo repository.MessageRepository) ConversationRepository {
|
||||
return &ConversationRepositoryAdapter{repo: repo}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ func (a *ConversationRepositoryAdapter) GetUnreadCount(userID, convID string) (i
|
||||
|
||||
// MessageRepositoryAdapter 适配 MessageRepository 到 MessageRepository 接口
|
||||
type MessageRepositoryAdapter struct {
|
||||
repo *repository.MessageRepository
|
||||
repo repository.MessageRepository
|
||||
}
|
||||
|
||||
// NewMessageRepositoryAdapter 创建适配器
|
||||
func NewMessageRepositoryAdapter(repo *repository.MessageRepository) MessageRepository {
|
||||
func NewMessageRepositoryAdapter(repo repository.MessageRepository) MessageRepository {
|
||||
return &MessageRepositoryAdapter{repo: repo}
|
||||
}
|
||||
|
||||
@@ -74,3 +74,8 @@ func (a *MessageRepositoryAdapter) CreateMessage(msg *model.Message) error {
|
||||
func (a *MessageRepositoryAdapter) UpdateConversationLastSeq(convID string, seq int64) error {
|
||||
return a.repo.UpdateConversationLastSeq(convID, seq)
|
||||
}
|
||||
|
||||
// GetNextSeq 实现 MessageRepository 接口
|
||||
func (a *MessageRepositoryAdapter) GetNextSeq(convID string) (int64, error) {
|
||||
return a.repo.GetNextSeq(convID)
|
||||
}
|
||||
|
||||
105
internal/cache/seq_buffer.go
vendored
Normal file
105
internal/cache/seq_buffer.go
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
redisPkg "with_you/internal/pkg/redis"
|
||||
)
|
||||
|
||||
const seqKeyTTL = 24 * time.Hour
|
||||
|
||||
// SeqBufferConfig seq 预分配配置(保留结构以兼容配置文件,实际不再使用缓冲)
|
||||
type SeqBufferConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
PrivateBufferSize int `mapstructure:"private_buffer_size"`
|
||||
GroupBufferSize int `mapstructure:"group_buffer_size"`
|
||||
LockTimeoutMs int `mapstructure:"lock_timeout_ms"`
|
||||
MaxRetries int `mapstructure:"max_retries"`
|
||||
}
|
||||
|
||||
// SeqBufferManager seq 分配管理器(简化版:每条消息一个 Redis INCR,无本地缓冲)
|
||||
type SeqBufferManager struct {
|
||||
rdb *redis.Client
|
||||
config SeqBufferConfig
|
||||
repo ConversationRepository
|
||||
msgRepo MessageRepository
|
||||
}
|
||||
|
||||
// NewSeqBufferManager 创建 seq 分配管理器
|
||||
func NewSeqBufferManager(rdb *redis.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||
return &SeqBufferManager{
|
||||
rdb: rdb,
|
||||
config: cfg,
|
||||
repo: repo,
|
||||
msgRepo: msgRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNextSeq 获取下一个 seq(单次 Redis INCR,原子且无竞态)
|
||||
func (m *SeqBufferManager) GetNextSeq(ctx context.Context, convID string, _ bool) (int64, error) {
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
// 1. 尝试 INCR(key 存在时直接递增,原子操作)
|
||||
result, err := nextSeqLua.Run(ctx, m.rdb, []string{seqKey}).Int64()
|
||||
if err == nil {
|
||||
if result > 0 {
|
||||
m.rdb.Expire(ctx, seqKey, seqKeyTTL)
|
||||
return result, nil
|
||||
}
|
||||
// result == 0: 冷启动,需从 DB 初始化
|
||||
return m.initAndIncr(ctx, convID, seqKey)
|
||||
}
|
||||
|
||||
// Redis 不可用,降级到 DB
|
||||
zap.L().Warn("seq INCR failed, falling back to DB", zap.String("convID", convID), zap.Error(err))
|
||||
if m.msgRepo != nil {
|
||||
if dbSeq, dbErr := m.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||
return dbSeq, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("seq allocation failed for %s: %w", convID, err)
|
||||
}
|
||||
|
||||
// initAndIncr 冷启动:从 DB 加载 last_seq,然后用 SETNX+INCR 原子初始化
|
||||
func (m *SeqBufferManager) initAndIncr(ctx context.Context, convID, seqKey string) (int64, error) {
|
||||
if m.repo == nil {
|
||||
return 0, fmt.Errorf("conversation repository not configured for seq init")
|
||||
}
|
||||
|
||||
conv, err := m.repo.GetConversationByID(convID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load conversation for seq init: %w", err)
|
||||
}
|
||||
|
||||
initVal := conv.LastSeq
|
||||
result, err := initSeqLua.Run(ctx, m.rdb, []string{seqKey}, initVal).Int64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("initSeqLua failed: %w", err)
|
||||
}
|
||||
|
||||
m.rdb.Expire(ctx, seqKey, seqKeyTTL)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// InvalidateBuffer no-op(保留接口兼容)
|
||||
func (m *SeqBufferManager) InvalidateBuffer(convID string) {}
|
||||
|
||||
// IsEnabled 是否启用
|
||||
func (m *SeqBufferManager) IsEnabled() bool {
|
||||
return m.config.Enabled && m.rdb != nil
|
||||
}
|
||||
|
||||
// NewSeqBufferManagerFromRedisPkg 从 pkg/redis.Client 创建 SeqBufferManager
|
||||
func NewSeqBufferManagerFromRedisPkg(redisPkgClient *redisPkg.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||
if redisPkgClient == nil {
|
||||
zap.L().Warn("SeqBuffer: redis client is nil, seq pre-allocation disabled")
|
||||
return &SeqBufferManager{config: cfg, repo: repo, msgRepo: msgRepo}
|
||||
}
|
||||
rdb := redisPkgClient.GetClient()
|
||||
return NewSeqBufferManager(rdb, cfg, repo, msgRepo)
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package config
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,12 +19,26 @@ type Config struct {
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
Upload UploadConfig `mapstructure:"upload"`
|
||||
Gorse GorseConfig `mapstructure:"gorse"`
|
||||
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
|
||||
DeviceCleanup DeviceCleanupConfig `mapstructure:"device_cleanup"`
|
||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
||||
Email EmailConfig `mapstructure:"email"`
|
||||
ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"`
|
||||
GRPC GRPCConfig `mapstructure:"grpc"`
|
||||
Casbin CasbinConfig `mapstructure:"casbin"`
|
||||
Encryption EncryptionConfig `mapstructure:"encryption"`
|
||||
HotRank HotRankConfig `mapstructure:"hot_rank"`
|
||||
LiveKit LiveKitConfig `mapstructure:"livekit"`
|
||||
Report ReportConfig `mapstructure:"report"`
|
||||
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
||||
JPush JPushConfig `mapstructure:"jpush"`
|
||||
SetupSecret string `mapstructure:"setup_secret"`
|
||||
WebSocket WSConfig `mapstructure:"websocket"`
|
||||
Runner RunnerConfig `mapstructure:"runner"`
|
||||
SeqBuffer SeqBufferConfig `mapstructure:"seq_buffer"`
|
||||
VersionLog VersionLogConfig `mapstructure:"version_log"`
|
||||
PushWorker PushWorkerConfig `mapstructure:"push_worker"`
|
||||
}
|
||||
|
||||
// Load 加载配置文件
|
||||
@@ -45,13 +57,28 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("server.mode", "debug")
|
||||
viper.SetDefault("server.host", "0.0.0.0")
|
||||
viper.SetDefault("database.type", "sqlite")
|
||||
viper.SetDefault("database.sqlite.path", "./data/carrot_bbs.db")
|
||||
viper.SetDefault("database.sqlite.path", "./data/with_you.db")
|
||||
viper.SetDefault("database.max_idle_conns", 10)
|
||||
viper.SetDefault("database.max_open_conns", 100)
|
||||
viper.SetDefault("database.log_level", "warn")
|
||||
viper.SetDefault("database.slow_threshold_ms", 200)
|
||||
viper.SetDefault("database.ignore_record_not_found", true)
|
||||
viper.SetDefault("database.parameterized_queries", true)
|
||||
viper.SetDefault("database.replica_policy", "random")
|
||||
|
||||
// 读写分离:单副本字段显式 BindEnv。
|
||||
// AutomaticEnv 对 YAML 中未出现的嵌套 key(replica 段在 config.yaml 中默认被注释)
|
||||
// 不会反向推导,必须逐字段 BindEnv 才能让 APP_DATABASE_REPLICA_HOST 等环境变量生效。
|
||||
// 与 jpush.channel.* 处理方式一致(见下方 BindEnv 块)。
|
||||
viper.BindEnv("database.replica.host", "APP_DATABASE_REPLICA_HOST")
|
||||
viper.BindEnv("database.replica.port", "APP_DATABASE_REPLICA_PORT")
|
||||
viper.BindEnv("database.replica.user", "APP_DATABASE_REPLICA_USER")
|
||||
viper.BindEnv("database.replica.password", "APP_DATABASE_REPLICA_PASSWORD")
|
||||
viper.BindEnv("database.replica.dbname", "APP_DATABASE_REPLICA_DBNAME")
|
||||
viper.BindEnv("database.replica.sslmode", "APP_DATABASE_REPLICA_SSLMODE")
|
||||
// 副本连接池参数(可选,留空回退到主库连接池配置,见 database.go)
|
||||
viper.BindEnv("database.replica_max_idle_conns", "APP_DATABASE_REPLICA_MAX_IDLE_CONNS")
|
||||
viper.BindEnv("database.replica_max_open_conns", "APP_DATABASE_REPLICA_MAX_OPEN_CONNS")
|
||||
viper.SetDefault("redis.type", "miniredis")
|
||||
viper.SetDefault("redis.redis.host", "localhost")
|
||||
viper.SetDefault("redis.redis.port", 6379)
|
||||
@@ -70,6 +97,10 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("cache.modules.conversation_ttl", 60)
|
||||
viper.SetDefault("cache.modules.unread_count_ttl", 30)
|
||||
viper.SetDefault("cache.modules.group_members_ttl", 120)
|
||||
viper.SetDefault("cache.local.enabled", true)
|
||||
viper.SetDefault("cache.local.size", 10000)
|
||||
viper.SetDefault("cache.local.buckets", 0)
|
||||
viper.SetDefault("cache.local.default_ttl", 300)
|
||||
viper.SetDefault("jwt.secret", "your-jwt-secret-key-change-in-production")
|
||||
viper.SetDefault("jwt.access_token_expire", 86400)
|
||||
viper.SetDefault("jwt.refresh_token_expire", 604800)
|
||||
@@ -80,6 +111,15 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("rate_limit.requests_per_minute", 60)
|
||||
viper.SetDefault("upload.max_file_size", 10485760)
|
||||
viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/gif", "image/webp"})
|
||||
// 文件过期清理默认值
|
||||
viper.SetDefault("file_cleanup.enabled", true)
|
||||
viper.SetDefault("file_cleanup.retention_days", 7)
|
||||
viper.SetDefault("file_cleanup.interval_minutes", 360)
|
||||
viper.SetDefault("file_cleanup.batch_size", 100)
|
||||
// 设备 registration_id 清理默认值(3 天不活跃即清理)
|
||||
viper.SetDefault("device_cleanup.enabled", true)
|
||||
viper.SetDefault("device_cleanup.retention_days", 3)
|
||||
viper.SetDefault("device_cleanup.interval_minutes", 360)
|
||||
viper.SetDefault("s3.endpoint", "")
|
||||
viper.SetDefault("s3.access_key", "")
|
||||
viper.SetDefault("s3.secret_key", "")
|
||||
@@ -89,16 +129,12 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("s3.domain", "")
|
||||
viper.SetDefault("sensitive.enabled", true)
|
||||
viper.SetDefault("sensitive.replace_str", "***")
|
||||
viper.SetDefault("sensitive.min_match_len", 2)
|
||||
viper.SetDefault("sensitive.load_from_db", true)
|
||||
viper.SetDefault("sensitive.load_from_redis", false)
|
||||
viper.SetDefault("sensitive.redis_key_prefix", "sensitive_words")
|
||||
viper.SetDefault("audit.enabled", false)
|
||||
viper.SetDefault("audit.provider", "local")
|
||||
viper.SetDefault("gorse.enabled", false)
|
||||
viper.SetDefault("gorse.address", "http://localhost:8087")
|
||||
viper.SetDefault("gorse.api_key", "")
|
||||
viper.SetDefault("gorse.dashboard", "http://localhost:8088")
|
||||
viper.SetDefault("gorse.import_password", "")
|
||||
viper.SetDefault("gorse.embedding_api_key", "")
|
||||
viper.SetDefault("gorse.embedding_url", "https://api.littlelan.cn/v1/embeddings")
|
||||
viper.SetDefault("gorse.embedding_model", "BAAI/bge-m3")
|
||||
viper.SetDefault("openai.enabled", true)
|
||||
viper.SetDefault("openai.base_url", "https://api.littlelan.cn/")
|
||||
viper.SetDefault("openai.api_key", "")
|
||||
@@ -106,13 +142,19 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("openai.moderation_max_images_per_request", 1)
|
||||
viper.SetDefault("openai.request_timeout", 30)
|
||||
viper.SetDefault("openai.strict_moderation", false)
|
||||
viper.SetDefault("tencent_tms.enabled", false)
|
||||
viper.SetDefault("tencent_tms.secret_id", "")
|
||||
viper.SetDefault("tencent_tms.secret_key", "")
|
||||
viper.SetDefault("tencent_tms.region", "ap-guangzhou")
|
||||
viper.SetDefault("tencent_tms.biz_type", "")
|
||||
viper.SetDefault("tencent_tms.timeout", 30)
|
||||
viper.SetDefault("email.enabled", false)
|
||||
viper.SetDefault("email.host", "")
|
||||
viper.SetDefault("email.port", 587)
|
||||
viper.SetDefault("email.username", "")
|
||||
viper.SetDefault("email.password", "")
|
||||
viper.SetDefault("email.from_address", "")
|
||||
viper.SetDefault("email.from_name", "Carrot BBS")
|
||||
viper.SetDefault("email.from_name", "WithYou")
|
||||
viper.SetDefault("email.use_tls", true)
|
||||
viper.SetDefault("email.insecure_skip_verify", false)
|
||||
viper.SetDefault("email.timeout", 15)
|
||||
@@ -135,13 +177,152 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("grpc.tls_enabled", false)
|
||||
viper.SetDefault("grpc.tls_cert_file", "")
|
||||
viper.SetDefault("grpc.tls_key_file", "")
|
||||
// Casbin 默认值
|
||||
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
|
||||
viper.SetDefault("casbin.auto_save", true)
|
||||
viper.SetDefault("casbin.auto_load", true)
|
||||
viper.SetDefault("casbin.enable_cache", true)
|
||||
viper.SetDefault("casbin.cache_ttl", 300)
|
||||
viper.SetDefault("casbin.skip_routes", []string{"/health", "/api/v1/auth/login", "/api/v1/auth/register"})
|
||||
// Encryption 默认值
|
||||
viper.SetDefault("encryption.enabled", false)
|
||||
viper.SetDefault("encryption.key", "")
|
||||
viper.SetDefault("encryption.key_version", 1)
|
||||
viper.SetDefault("hot_rank.enabled", true)
|
||||
viper.SetDefault("hot_rank.refresh_interval_seconds", 300)
|
||||
viper.SetDefault("hot_rank.top_n", 100)
|
||||
viper.SetDefault("hot_rank.recent_window_hours", 168)
|
||||
viper.SetDefault("hot_rank.recent_fetch_limit", 500)
|
||||
viper.SetDefault("hot_rank.candidate_cap", 800)
|
||||
// Report 默认值
|
||||
viper.SetDefault("report.auto_hide_threshold", 3)
|
||||
viper.SetDefault("jpush.enabled", false)
|
||||
viper.SetDefault("jpush.app_key", "")
|
||||
viper.SetDefault("jpush.master_secret", "")
|
||||
viper.SetDefault("jpush.production", false)
|
||||
// JPush 厂商通道 channel_id 默认值
|
||||
// 各厂商 channel_id 通过环境变量配置(见下方 BindEnv)
|
||||
viper.SetDefault("jpush.channel.system", "")
|
||||
viper.SetDefault("jpush.channel.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.system", "153609")
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.chat", "153608")
|
||||
viper.SetDefault("jpush.channel.vendor.huawei.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.huawei.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.vivo.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.vivo.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.meizu.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.meizu.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.honor.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.honor.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.fcm.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.fcm.chat", "")
|
||||
// 小米/OPPO 模板 id 默认值
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.mi_system_template_id", "P10761")
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.mi_chat_template_id", "M10289")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_system_private_template_id", "")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_chat_private_template_id", "")
|
||||
// OPPO 消息分类默认值(category / notify_level)
|
||||
// OPPO 消息分类默认值(2024.11.20 新规)
|
||||
// chat: category=IM(即时通讯), notify_level=16(通知栏+锁屏+横幅+震动+铃声,强提醒)
|
||||
// system: category=ACCOUNT(账号动态/互动通知), notify_level=2(通知栏+锁屏,避免打扰)
|
||||
// 注:使用 notify_level 时 category 必传,此处两者同时配置
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_system_category", "ACCOUNT")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_chat_category", "IM")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_system_notify_level", 2)
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_chat_notify_level", 16)
|
||||
// 荣耀 importance 默认值
|
||||
// chat: NORMAL(服务与通讯,及时送达), system: LOW(资讯营销,避免打扰)
|
||||
// 注:classification 优先级更高,会覆盖 importance
|
||||
viper.SetDefault("jpush.channel.vendor.honor.honor_system_importance", "LOW")
|
||||
viper.SetDefault("jpush.channel.vendor.honor.honor_chat_importance", "NORMAL")
|
||||
// vivo category 默认值(classification=1 时必须为系统消息类)
|
||||
// chat: IM(即时通讯), system: ACCOUNT(账号动态/互动通知)
|
||||
viper.SetDefault("jpush.channel.vendor.vivo.vivo_system_category", "ACCOUNT")
|
||||
viper.SetDefault("jpush.channel.vendor.vivo.vivo_chat_category", "IM")
|
||||
// iOS 通知分组 thread-id 默认值(留空,不分组)
|
||||
viper.SetDefault("jpush.channel.ios.chat_thread_id", "")
|
||||
viper.SetDefault("jpush.channel.ios.system_thread_id", "")
|
||||
// JPush 厂商 channel_id 环境变量显式绑定
|
||||
// AutomaticEnv 对深层嵌套 key 不可靠,故逐个 BindEnv
|
||||
// 命名规则:APP_JPUSH_CHANNEL_{VENDOR}_{SYSTEM|CHAT}
|
||||
viper.BindEnv("jpush.channel.system", "APP_JPUSH_CHANNEL_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.chat", "APP_JPUSH_CHANNEL_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.system", "APP_JPUSH_CHANNEL_XIAOMI_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.chat", "APP_JPUSH_CHANNEL_XIAOMI_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.huawei.system", "APP_JPUSH_CHANNEL_HUAWEI_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.huawei.chat", "APP_JPUSH_CHANNEL_HUAWEI_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.system", "APP_JPUSH_CHANNEL_OPPO_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.chat", "APP_JPUSH_CHANNEL_OPPO_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.vivo.system", "APP_JPUSH_CHANNEL_VIVO_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.vivo.chat", "APP_JPUSH_CHANNEL_VIVO_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.meizu.system", "APP_JPUSH_CHANNEL_MEIZU_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.meizu.chat", "APP_JPUSH_CHANNEL_MEIZU_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.honor.system", "APP_JPUSH_CHANNEL_HONOR_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.honor.chat", "APP_JPUSH_CHANNEL_HONOR_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.fcm.system", "APP_JPUSH_CHANNEL_FCM_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.fcm.chat", "APP_JPUSH_CHANNEL_FCM_CHAT")
|
||||
// 小米/OPPO 模板 id 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.mi_system_template_id", "APP_JPUSH_CHANNEL_XIAOMI_MI_SYSTEM_TEMPLATE_ID")
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.mi_chat_template_id", "APP_JPUSH_CHANNEL_XIAOMI_MI_CHAT_TEMPLATE_ID")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_system_private_template_id", "APP_JPUSH_CHANNEL_OPPO_SYSTEM_PRIVATE_TEMPLATE_ID")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_chat_private_template_id", "APP_JPUSH_CHANNEL_OPPO_CHAT_PRIVATE_TEMPLATE_ID")
|
||||
// OPPO category / notify_level 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_system_category", "APP_JPUSH_CHANNEL_OPPO_SYSTEM_CATEGORY")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_chat_category", "APP_JPUSH_CHANNEL_OPPO_CHAT_CATEGORY")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_system_notify_level", "APP_JPUSH_CHANNEL_OPPO_SYSTEM_NOTIFY_LEVEL")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_chat_notify_level", "APP_JPUSH_CHANNEL_OPPO_CHAT_NOTIFY_LEVEL")
|
||||
// 荣耀 importance 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.vendor.honor.honor_system_importance", "APP_JPUSH_CHANNEL_HONOR_SYSTEM_IMPORTANCE")
|
||||
viper.BindEnv("jpush.channel.vendor.honor.honor_chat_importance", "APP_JPUSH_CHANNEL_HONOR_CHAT_IMPORTANCE")
|
||||
// vivo category 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.vendor.vivo.vivo_system_category", "APP_JPUSH_CHANNEL_VIVO_SYSTEM_CATEGORY")
|
||||
viper.BindEnv("jpush.channel.vendor.vivo.vivo_chat_category", "APP_JPUSH_CHANNEL_VIVO_CHAT_CATEGORY")
|
||||
// iOS thread-id 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.ios.chat_thread_id", "APP_JPUSH_CHANNEL_IOS_CHAT_THREAD_ID")
|
||||
viper.BindEnv("jpush.channel.ios.system_thread_id", "APP_JPUSH_CHANNEL_IOS_SYSTEM_THREAD_ID")
|
||||
viper.SetDefault("setup_secret", "")
|
||||
// WebSocket 默认值
|
||||
viper.SetDefault("websocket.mode", "standalone")
|
||||
viper.SetDefault("websocket.cluster.instance_id", "")
|
||||
viper.SetDefault("websocket.cluster.msg_channel", "ws:msg")
|
||||
viper.SetDefault("websocket.cluster.online_ttl", 60)
|
||||
viper.SetDefault("websocket.cluster.heartbeat_interval", 20)
|
||||
// Runner 集群默认值(mode 和 instance_id 空则跟随 websocket)
|
||||
viper.SetDefault("runner.mode", "")
|
||||
viper.SetDefault("runner.cluster.instance_id", "")
|
||||
viper.SetDefault("runner.cluster.task_channel", "runner:task")
|
||||
viper.SetDefault("runner.cluster.result_channel", "runner:result")
|
||||
viper.SetDefault("runner.cluster.registry_prefix", "runner:reg:")
|
||||
viper.SetDefault("runner.cluster.cap_prefix", "runner:cap:")
|
||||
viper.SetDefault("runner.cluster.registry_ttl", 90)
|
||||
viper.SetDefault("runner.cluster.heartbeat_interval", 30)
|
||||
// SeqBuffer 默认值
|
||||
viper.SetDefault("seq_buffer.enabled", false)
|
||||
viper.SetDefault("seq_buffer.private_buffer_size", 50)
|
||||
viper.SetDefault("seq_buffer.group_buffer_size", 200)
|
||||
viper.SetDefault("seq_buffer.lock_timeout_ms", 5000)
|
||||
viper.SetDefault("seq_buffer.max_retries", 3)
|
||||
// VersionLog 默认值
|
||||
viper.SetDefault("version_log.enabled", false)
|
||||
viper.SetDefault("version_log.sync_limit", 100)
|
||||
viper.SetDefault("version_log.max_sync_gap", 1000)
|
||||
// PushWorker 默认值
|
||||
viper.SetDefault("push_worker.enabled", false)
|
||||
viper.SetDefault("push_worker.stream", "msg_push")
|
||||
viper.SetDefault("push_worker.group", "push_worker")
|
||||
viper.SetDefault("push_worker.batch_size", 50)
|
||||
viper.SetDefault("push_worker.poll_timeout_ms", 2000)
|
||||
viper.SetDefault("push_worker.max_retries", 3)
|
||||
viper.SetDefault("push_worker.max_stream_len", 100000)
|
||||
viper.SetDefault("push_worker.idle_timeout_ms", 30000)
|
||||
// LiveKit 默认值
|
||||
viper.SetDefault("livekit.enabled", false)
|
||||
viper.SetDefault("livekit.url", "http://localhost:7880")
|
||||
viper.SetDefault("livekit.api_key", "devkey")
|
||||
viper.SetDefault("livekit.api_secret", "")
|
||||
viper.SetDefault("livekit.token_ttl", 600)
|
||||
viper.SetDefault("livekit.webhook_secret", "")
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
@@ -152,91 +333,26 @@ func Load(configPath string) (*Config, error) {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Runner mode 和 instance_id 空则跟随 WebSocket 配置
|
||||
cfg.Runner.ApplyWSDefaults(cfg.WebSocket)
|
||||
|
||||
// Convert seconds to duration
|
||||
cfg.JWT.AccessTokenExpire = time.Duration(viper.GetInt("jwt.access_token_expire")) * time.Second
|
||||
cfg.JWT.RefreshTokenExpire = time.Duration(viper.GetInt("jwt.refresh_token_expire")) * time.Second
|
||||
|
||||
// 环境变量覆盖(显式处理敏感配置)
|
||||
cfg.JWT.Secret = getEnvOrDefault("APP_JWT_SECRET", cfg.JWT.Secret)
|
||||
cfg.Database.SQLite.Path = getEnvOrDefault("APP_DATABASE_SQLITE_PATH", cfg.Database.SQLite.Path)
|
||||
cfg.Database.Postgres.Host = getEnvOrDefault("APP_DATABASE_POSTGRES_HOST", cfg.Database.Postgres.Host)
|
||||
cfg.Database.Postgres.Port, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_POSTGRES_PORT", fmt.Sprintf("%d", cfg.Database.Postgres.Port)))
|
||||
cfg.Database.Postgres.User = getEnvOrDefault("APP_DATABASE_POSTGRES_USER", cfg.Database.Postgres.User)
|
||||
cfg.Database.Postgres.Password = getEnvOrDefault("APP_DATABASE_POSTGRES_PASSWORD", cfg.Database.Postgres.Password)
|
||||
cfg.Database.Postgres.DBName = getEnvOrDefault("APP_DATABASE_POSTGRES_DBNAME", cfg.Database.Postgres.DBName)
|
||||
cfg.Database.LogLevel = getEnvOrDefault("APP_DATABASE_LOG_LEVEL", cfg.Database.LogLevel)
|
||||
cfg.Database.SlowThresholdMs, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_SLOW_THRESHOLD_MS", fmt.Sprintf("%d", cfg.Database.SlowThresholdMs)))
|
||||
cfg.Database.IgnoreRecordNotFound, _ = strconv.ParseBool(getEnvOrDefault("APP_DATABASE_IGNORE_RECORD_NOT_FOUND", fmt.Sprintf("%t", cfg.Database.IgnoreRecordNotFound)))
|
||||
cfg.Database.ParameterizedQueries, _ = strconv.ParseBool(getEnvOrDefault("APP_DATABASE_PARAMETERIZED_QUERIES", fmt.Sprintf("%t", cfg.Database.ParameterizedQueries)))
|
||||
cfg.Redis.Redis.Host = getEnvOrDefault("APP_REDIS_REDIS_HOST", cfg.Redis.Redis.Host)
|
||||
cfg.Redis.Redis.Port, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_REDIS_PORT", fmt.Sprintf("%d", cfg.Redis.Redis.Port)))
|
||||
cfg.Redis.Redis.Password = getEnvOrDefault("APP_REDIS_REDIS_PASSWORD", cfg.Redis.Redis.Password)
|
||||
cfg.Redis.Redis.DB, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_REDIS_DB", fmt.Sprintf("%d", cfg.Redis.Redis.DB)))
|
||||
cfg.Redis.Miniredis.Host = getEnvOrDefault("APP_REDIS_MINIREDIS_HOST", cfg.Redis.Miniredis.Host)
|
||||
cfg.Redis.Miniredis.Port, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_MINIREDIS_PORT", fmt.Sprintf("%d", cfg.Redis.Miniredis.Port)))
|
||||
cfg.Redis.Type = getEnvOrDefault("APP_REDIS_TYPE", cfg.Redis.Type)
|
||||
cfg.Cache.KeyPrefix = getEnvOrDefault("APP_CACHE_KEY_PREFIX", cfg.Cache.KeyPrefix)
|
||||
cfg.Cache.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_CACHE_ENABLED", fmt.Sprintf("%t", cfg.Cache.Enabled)))
|
||||
cfg.Cache.DisableFlushDB, _ = strconv.ParseBool(getEnvOrDefault("APP_CACHE_DISABLE_FLUSHDB", fmt.Sprintf("%t", cfg.Cache.DisableFlushDB)))
|
||||
cfg.Cache.DefaultTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_DEFAULT_TTL", fmt.Sprintf("%d", cfg.Cache.DefaultTTL)))
|
||||
cfg.Cache.NullTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_NULL_TTL", fmt.Sprintf("%d", cfg.Cache.NullTTL)))
|
||||
cfg.Cache.JitterRatio, _ = strconv.ParseFloat(getEnvOrDefault("APP_CACHE_JITTER_RATIO", fmt.Sprintf("%.2f", cfg.Cache.JitterRatio)), 64)
|
||||
cfg.Cache.Modules.PostList, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_POST_LIST_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.PostList)))
|
||||
cfg.Cache.Modules.Conversation, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_CONVERSATION_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.Conversation)))
|
||||
cfg.Cache.Modules.UnreadCount, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_UNREAD_COUNT_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.UnreadCount)))
|
||||
cfg.Cache.Modules.GroupMembers, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_GROUP_MEMBERS_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.GroupMembers)))
|
||||
cfg.S3.Endpoint = getEnvOrDefault("APP_S3_ENDPOINT", cfg.S3.Endpoint)
|
||||
cfg.S3.AccessKey = getEnvOrDefault("APP_S3_ACCESS_KEY", cfg.S3.AccessKey)
|
||||
cfg.S3.SecretKey = getEnvOrDefault("APP_S3_SECRET_KEY", cfg.S3.SecretKey)
|
||||
cfg.S3.Bucket = getEnvOrDefault("APP_S3_BUCKET", cfg.S3.Bucket)
|
||||
cfg.S3.Domain = getEnvOrDefault("APP_S3_DOMAIN", cfg.S3.Domain)
|
||||
cfg.Server.Host = getEnvOrDefault("APP_SERVER_HOST", cfg.Server.Host)
|
||||
cfg.Server.Port, _ = strconv.Atoi(getEnvOrDefault("APP_SERVER_PORT", fmt.Sprintf("%d", cfg.Server.Port)))
|
||||
cfg.Server.Mode = getEnvOrDefault("APP_SERVER_MODE", cfg.Server.Mode)
|
||||
cfg.Gorse.Address = getEnvOrDefault("APP_GORSE_ADDRESS", cfg.Gorse.Address)
|
||||
cfg.Gorse.APIKey = getEnvOrDefault("APP_GORSE_API_KEY", cfg.Gorse.APIKey)
|
||||
cfg.Gorse.Dashboard = getEnvOrDefault("APP_GORSE_DASHBOARD", cfg.Gorse.Dashboard)
|
||||
cfg.Gorse.ImportPassword = getEnvOrDefault("APP_GORSE_IMPORT_PASSWORD", cfg.Gorse.ImportPassword)
|
||||
cfg.Gorse.EmbeddingAPIKey = getEnvOrDefault("APP_GORSE_EMBEDDING_API_KEY", cfg.Gorse.EmbeddingAPIKey)
|
||||
cfg.Gorse.EmbeddingURL = getEnvOrDefault("APP_GORSE_EMBEDDING_URL", cfg.Gorse.EmbeddingURL)
|
||||
cfg.Gorse.EmbeddingModel = getEnvOrDefault("APP_GORSE_EMBEDDING_MODEL", cfg.Gorse.EmbeddingModel)
|
||||
cfg.OpenAI.BaseURL = getEnvOrDefault("APP_OPENAI_BASE_URL", cfg.OpenAI.BaseURL)
|
||||
cfg.OpenAI.APIKey = getEnvOrDefault("APP_OPENAI_API_KEY", cfg.OpenAI.APIKey)
|
||||
cfg.OpenAI.ModerationModel = getEnvOrDefault("APP_OPENAI_MODERATION_MODEL", cfg.OpenAI.ModerationModel)
|
||||
cfg.OpenAI.ModerationMaxImagesPerRequest, _ = strconv.Atoi(getEnvOrDefault("APP_OPENAI_MODERATION_MAX_IMAGES_PER_REQUEST", fmt.Sprintf("%d", cfg.OpenAI.ModerationMaxImagesPerRequest)))
|
||||
cfg.OpenAI.RequestTimeout, _ = strconv.Atoi(getEnvOrDefault("APP_OPENAI_REQUEST_TIMEOUT", fmt.Sprintf("%d", cfg.OpenAI.RequestTimeout)))
|
||||
cfg.OpenAI.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_ENABLED", fmt.Sprintf("%t", cfg.OpenAI.Enabled)))
|
||||
cfg.OpenAI.StrictModeration, _ = strconv.ParseBool(getEnvOrDefault("APP_OPENAI_STRICT_MODERATION", fmt.Sprintf("%t", cfg.OpenAI.StrictModeration)))
|
||||
cfg.Email.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_ENABLED", fmt.Sprintf("%t", cfg.Email.Enabled)))
|
||||
cfg.Email.Host = getEnvOrDefault("APP_EMAIL_HOST", cfg.Email.Host)
|
||||
cfg.Email.Port, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_PORT", fmt.Sprintf("%d", cfg.Email.Port)))
|
||||
cfg.Email.Username = getEnvOrDefault("APP_EMAIL_USERNAME", cfg.Email.Username)
|
||||
cfg.Email.Password = getEnvOrDefault("APP_EMAIL_PASSWORD", cfg.Email.Password)
|
||||
cfg.Email.FromAddress = getEnvOrDefault("APP_EMAIL_FROM_ADDRESS", cfg.Email.FromAddress)
|
||||
cfg.Email.FromName = getEnvOrDefault("APP_EMAIL_FROM_NAME", cfg.Email.FromName)
|
||||
cfg.Email.UseTLS, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_USE_TLS", fmt.Sprintf("%t", cfg.Email.UseTLS)))
|
||||
cfg.Email.InsecureSkipVerify, _ = strconv.ParseBool(getEnvOrDefault("APP_EMAIL_INSECURE_SKIP_VERIFY", fmt.Sprintf("%t", cfg.Email.InsecureSkipVerify)))
|
||||
cfg.Email.Timeout, _ = strconv.Atoi(getEnvOrDefault("APP_EMAIL_TIMEOUT", fmt.Sprintf("%d", cfg.Email.Timeout)))
|
||||
// GRPC 环境变量覆盖
|
||||
cfg.GRPC.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_GRPC_ENABLED", fmt.Sprintf("%t", cfg.GRPC.Enabled)))
|
||||
cfg.GRPC.Port, _ = strconv.Atoi(getEnvOrDefault("APP_GRPC_PORT", fmt.Sprintf("%d", cfg.GRPC.Port)))
|
||||
cfg.GRPC.TLSEnabled, _ = strconv.ParseBool(getEnvOrDefault("APP_GRPC_TLS_ENABLED", fmt.Sprintf("%t", cfg.GRPC.TLSEnabled)))
|
||||
cfg.GRPC.TLSCertFile = getEnvOrDefault("APP_GRPC_TLS_CERT_FILE", cfg.GRPC.TLSCertFile)
|
||||
cfg.GRPC.TLSKeyFile = getEnvOrDefault("APP_GRPC_TLS_KEY_FILE", cfg.GRPC.TLSKeyFile)
|
||||
// Casbin 环境变量覆盖
|
||||
cfg.Casbin.ModelPath = getEnvOrDefault("APP_CASBIN_MODEL_PATH", cfg.Casbin.ModelPath)
|
||||
cfg.Casbin.AutoSave, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_AUTO_SAVE", fmt.Sprintf("%t", cfg.Casbin.AutoSave)))
|
||||
cfg.Casbin.AutoLoad, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_AUTO_LOAD", fmt.Sprintf("%t", cfg.Casbin.AutoLoad)))
|
||||
cfg.Casbin.EnableCache, _ = strconv.ParseBool(getEnvOrDefault("APP_CASBIN_ENABLE_CACHE", fmt.Sprintf("%t", cfg.Casbin.EnableCache)))
|
||||
cfg.Casbin.CacheTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CASBIN_CACHE_TTL", fmt.Sprintf("%d", cfg.Casbin.CacheTTL)))
|
||||
// Viper AutomaticEnv 已自动处理所有 APP_ 前缀的环境变量
|
||||
|
||||
// 安全检查:生产模式必须设置JWT密钥
|
||||
if cfg.Server.Mode != "debug" {
|
||||
if cfg.JWT.Secret == "" || cfg.JWT.Secret == "your-jwt-secret-key-change-in-production" {
|
||||
return nil, fmt.Errorf("SECURITY ERROR: JWT secret must be set via APP_JWT_SECRET environment variable in production mode")
|
||||
}
|
||||
if len(cfg.JWT.Secret) < 32 {
|
||||
return nil, fmt.Errorf("SECURITY ERROR: JWT secret must be at least 32 characters long in production mode")
|
||||
}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// getEnvOrDefault 获取环境变量值,如果未设置则返回默认值
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,22 @@ import "fmt"
|
||||
|
||||
// DatabaseConfig 数据库配置
|
||||
type DatabaseConfig struct {
|
||||
Type string `mapstructure:"type"`
|
||||
SQLite SQLiteConfig `mapstructure:"sqlite"`
|
||||
Postgres PostgresConfig `mapstructure:"postgres"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
LogLevel string `mapstructure:"log_level"`
|
||||
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
|
||||
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
|
||||
ParameterizedQueries bool `mapstructure:"parameterized_queries"`
|
||||
Type string `mapstructure:"type"`
|
||||
SQLite SQLiteConfig `mapstructure:"sqlite"`
|
||||
Postgres PostgresConfig `mapstructure:"postgres"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
LogLevel string `mapstructure:"log_level"`
|
||||
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
|
||||
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
|
||||
ParameterizedQueries bool `mapstructure:"parameterized_queries"`
|
||||
// 读写分离:单副本配置(支持环境变量,如 APP_DATABASE_REPLICA_HOST)
|
||||
Replica PostgresConfig `mapstructure:"replica"`
|
||||
// 读写分离:多副本配置(仅 YAML 支持,环境变量无法覆盖切片结构体)
|
||||
Replicas []PostgresConfig `mapstructure:"replicas"`
|
||||
ReplicaPolicy string `mapstructure:"replica_policy"`
|
||||
ReplicaMaxIdle int `mapstructure:"replica_max_idle_conns"`
|
||||
ReplicaMaxOpen int `mapstructure:"replica_max_open_conns"`
|
||||
}
|
||||
|
||||
// SQLiteConfig SQLite 配置
|
||||
@@ -20,7 +27,7 @@ type SQLiteConfig struct {
|
||||
Path string `mapstructure:"path"`
|
||||
}
|
||||
|
||||
// PostgresConfig PostgreSQL 配置
|
||||
// PostgresConfig PostgreSQL 配置(主库和副本共用)
|
||||
type PostgresConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
@@ -37,3 +44,18 @@ func (d PostgresConfig) DSN() string {
|
||||
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
|
||||
)
|
||||
}
|
||||
|
||||
// HasReplica 返回是否配置了读副本(单副本或多副本)
|
||||
func (d DatabaseConfig) HasReplica() bool {
|
||||
return d.Replica.Host != "" || len(d.Replicas) > 0
|
||||
}
|
||||
|
||||
// AllReplicas 返回所有读副本配置(合并单副本 + 多副本)
|
||||
func (d DatabaseConfig) AllReplicas() []PostgresConfig {
|
||||
var result []PostgresConfig
|
||||
if d.Replica.Host != "" {
|
||||
result = append(result, d.Replica)
|
||||
}
|
||||
result = append(result, d.Replicas...)
|
||||
return result
|
||||
}
|
||||
14
internal/config/encryption.go
Normal file
14
internal/config/encryption.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package config
|
||||
|
||||
// EncryptionConfig 消息加密配置
|
||||
type EncryptionConfig struct {
|
||||
// Enabled 是否启用消息加密
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
|
||||
// Key 加密密钥(32字节,AES-256)
|
||||
// 生产环境应通过环境变量 APP_ENCRYPTION_KEY 设置
|
||||
Key string `mapstructure:"key"`
|
||||
|
||||
// KeyVersion 密钥版本,用于密钥轮换
|
||||
KeyVersion int `mapstructure:"key_version"`
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package config
|
||||
|
||||
// GorseConfig Gorse 推荐系统配置
|
||||
type GorseConfig struct {
|
||||
Address string `mapstructure:"address"`
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Dashboard string `mapstructure:"dashboard"`
|
||||
ImportPassword string `mapstructure:"import_password"`
|
||||
EmbeddingAPIKey string `mapstructure:"embedding_api_key"`
|
||||
EmbeddingURL string `mapstructure:"embedding_url"`
|
||||
EmbeddingModel string `mapstructure:"embedding_model"`
|
||||
// HotRankConfig 热门榜定时重算(仅 Redis ZSET + 分层缓存中的 Top ID 列表,不写库)
|
||||
type HotRankConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
RefreshIntervalSeconds int `mapstructure:"refresh_interval_seconds"`
|
||||
// TopN 写入缓存的热门条数(ZSET 与 top_ids 列表长度上限)
|
||||
TopN int `mapstructure:"top_n"`
|
||||
// RecentWindowHours 候选池:该时间窗内新发的帖子 ID 会参与本轮重算
|
||||
RecentWindowHours int `mapstructure:"recent_window_hours"`
|
||||
// RecentFetchLimit 时间窗内最多拉取的帖子 ID 数(按 created_at 倒序)
|
||||
RecentFetchLimit int `mapstructure:"recent_fetch_limit"`
|
||||
// CandidateCap 候选 ID 合并后的上限(上届 Top + 新帖去重后截断)
|
||||
CandidateCap int `mapstructure:"candidate_cap"`
|
||||
}
|
||||
|
||||
// OpenAIConfig OpenAI 配置
|
||||
@@ -22,3 +24,13 @@ type OpenAIConfig struct {
|
||||
RequestTimeout int `mapstructure:"request_timeout"`
|
||||
StrictModeration bool `mapstructure:"strict_moderation"`
|
||||
}
|
||||
|
||||
// TencentTMSConfig 腾讯云文本内容安全配置(作为 OpenAI 审核的备用)
|
||||
type TencentTMSConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
SecretID string `mapstructure:"secret_id"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Region string `mapstructure:"region"`
|
||||
BizType string `mapstructure:"biz_type"`
|
||||
Timeout int `mapstructure:"timeout"`
|
||||
}
|
||||
|
||||
83
internal/config/jpush.go
Normal file
83
internal/config/jpush.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
type JPushConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
AppKey string `mapstructure:"app_key"`
|
||||
MasterSecret string `mapstructure:"master_secret"`
|
||||
Production bool `mapstructure:"production"`
|
||||
// Channel 厂商通道 channel_id 配置
|
||||
// 通过 options.third_party_channel 下发到各厂商(小米/华为/OPPO 等)
|
||||
Channel ChannelConfig `mapstructure:"channel"`
|
||||
}
|
||||
|
||||
// ChannelConfig 厂商通道 channel_id 配置
|
||||
// 区分「系统消息」与「私聊/群聊消息」两类 channel
|
||||
type ChannelConfig struct {
|
||||
// System 系统消息/通知默认 channel_id,未单独配置 Vendor 时各厂商共用
|
||||
System string `mapstructure:"system"`
|
||||
// Chat 私聊/群聊消息默认 channel_id,未单独配置 Vendor 时各厂商共用
|
||||
Chat string `mapstructure:"chat"`
|
||||
// Vendor 各厂商 channel_id 覆盖配置(留空则回退到 System/Chat)
|
||||
Vendor VendorChannel `mapstructure:"vendor"`
|
||||
// iOS APNs 通知场景区分配置(按 thread-id 分组)
|
||||
IOS IOSChannelConfig `mapstructure:"ios"`
|
||||
}
|
||||
|
||||
// IOSChannelConfig iOS APNs 通知场景区分配置
|
||||
// iOS 走 APNs 无 channel_id 概念,通过 thread-id 实现通知分组
|
||||
type IOSChannelConfig struct {
|
||||
// ChatThreadID 私聊/群聊消息通知分组 thread-id
|
||||
ChatThreadID string `mapstructure:"chat_thread_id"`
|
||||
// SystemThreadID 系统消息/通知分组 thread-id
|
||||
SystemThreadID string `mapstructure:"system_thread_id"`
|
||||
}
|
||||
|
||||
// VendorChannel 各厂商通道 channel_id 覆盖
|
||||
// 每个厂商区分 System(系统消息)与 Chat(私聊消息)两个 channel_id
|
||||
type VendorChannel struct {
|
||||
Xiaomi VendorChannelID `mapstructure:"xiaomi"`
|
||||
Huawei VendorChannelID `mapstructure:"huawei"`
|
||||
OPPO VendorChannelID `mapstructure:"oppo"`
|
||||
VIVO VendorChannelID `mapstructure:"vivo"`
|
||||
Meizu VendorChannelID `mapstructure:"meizu"`
|
||||
Honor VendorChannelID `mapstructure:"honor"`
|
||||
FCM VendorChannelID `mapstructure:"fcm"`
|
||||
}
|
||||
|
||||
// VendorChannelID 单个厂商的两类 channel 配置
|
||||
// 每个 channel 可携带厂商私有模板配置(如小米 mi_template_id、OPPO private_msg_template_id)
|
||||
type VendorChannelID struct {
|
||||
// channel_id
|
||||
System string `mapstructure:"system"`
|
||||
Chat string `mapstructure:"chat"`
|
||||
|
||||
// 小米消息模板(可选,配置后私信消息下发时携带 channel_id 及 mi_template_id)
|
||||
// 见:小米关于消息模板推送新规的更新通知
|
||||
MiSystemTemplateID string `mapstructure:"mi_system_template_id"` // 系统消息模板 id
|
||||
MiChatTemplateID string `mapstructure:"mi_chat_template_id"` // 私聊消息模板 id
|
||||
|
||||
// OPPO 私信模板(可选,仅 OPPO 厂商,配置后下发私信时携带)
|
||||
// 见:OPUSH 私信模版校验能力接入说明
|
||||
OppoSystemPrivateTemplateID string `mapstructure:"oppo_system_private_template_id"` // 系统消息私信模板 id
|
||||
OppoChatPrivateTemplateID string `mapstructure:"oppo_chat_private_template_id"` // 私聊消息私信模板 id
|
||||
|
||||
// OPPO 消息分类(可选,2024.11.20 新规)
|
||||
// category 消息场景标识,使用 notify_level 时必传;notify_level 提醒等级
|
||||
// 取值:category 如 IM/ACCOUNT;notify_level 1=通知栏, 2=通知栏+锁屏, 16=通知栏+锁屏+横幅+震动+铃声
|
||||
OppoSystemCategory string `mapstructure:"oppo_system_category"` // 系统消息 category(如 ACCOUNT)
|
||||
OppoChatCategory string `mapstructure:"oppo_chat_category"` // 私聊消息 category(如 IM)
|
||||
OppoSystemNotifyLevel int `mapstructure:"oppo_system_notify_level"` // 系统消息提醒等级(如 2)
|
||||
OppoChatNotifyLevel int `mapstructure:"oppo_chat_notify_level"` // 私聊消息提醒等级(如 16)
|
||||
|
||||
// 荣耀通知栏消息智能分类(可选,importance 字段)
|
||||
// 取值:"NORMAL"=服务与通讯,"LOW"=资讯营销
|
||||
// 注:classification 优先级更高,会覆盖 importance 设置的值
|
||||
HonorSystemImportance string `mapstructure:"honor_system_importance"` // 系统消息 importance(如 LOW)
|
||||
HonorChatImportance string `mapstructure:"honor_chat_importance"` // 私聊消息 importance(如 NORMAL)
|
||||
|
||||
// vivo 厂商消息场景标识(可选,category 字段)
|
||||
// 用于标识消息类型,确定提醒方式;classification=1 时 category 必须为系统消息类
|
||||
// 不携带 category 会默认按运营消息下发,受频控限制
|
||||
VivoSystemCategory string `mapstructure:"vivo_system_category"` // 系统消息 category(如 ACCOUNT 账号动态)
|
||||
VivoChatCategory string `mapstructure:"vivo_chat_category"` // 私聊消息 category(如 IM 即时通讯)
|
||||
}
|
||||
82
internal/config/jpush_default_test.go
Normal file
82
internal/config/jpush_default_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestXiaomiDefaultValuesFromViper 验证:config.yaml 中小米字段留空时,
|
||||
// viper.SetDefault 设置的默认值(153608/153609 + M10289/P10761)能正确填充到 Config 结构体。
|
||||
// 这证明:不配置环境变量/yaml 值时,代码内置默认值生效。
|
||||
func TestXiaomiDefaultValuesFromViper(t *testing.T) {
|
||||
// 加载仓库内的 config.yaml(小米字段已留空,依赖代码默认值)
|
||||
yamlPath := filepath.Join("..", "..", "configs", "config.yaml")
|
||||
cfg, err := Load(yamlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load config failed: %v", err)
|
||||
}
|
||||
|
||||
x := cfg.JPush.Channel.Vendor.Xiaomi
|
||||
if x.System != "153609" {
|
||||
t.Errorf("xiaomi.system default = %q, want 153609", x.System)
|
||||
}
|
||||
if x.Chat != "153608" {
|
||||
t.Errorf("xiaomi.chat default = %q, want 153608", x.Chat)
|
||||
}
|
||||
if x.MiSystemTemplateID != "P10761" {
|
||||
t.Errorf("xiaomi.mi_system_template_id default = %q, want P10761", x.MiSystemTemplateID)
|
||||
}
|
||||
if x.MiChatTemplateID != "M10289" {
|
||||
t.Errorf("xiaomi.mi_chat_template_id default = %q, want M10289", x.MiChatTemplateID)
|
||||
}
|
||||
t.Logf("小米默认值生效: system=%s chat=%s mi_sys=%s mi_chat=%s",
|
||||
x.System, x.Chat, x.MiSystemTemplateID, x.MiChatTemplateID)
|
||||
}
|
||||
|
||||
// TestVendorDefaultValuesFromViper 验证:OPPO/vivo/荣耀的分类字段在 yaml 留空时,
|
||||
// viper.SetDefault 设置的默认值能正确填充到 Config 结构体。
|
||||
// channel_id 类字段需厂商后台注册,不在默认值范围(留空)。
|
||||
func TestVendorDefaultValuesFromViper(t *testing.T) {
|
||||
yamlPath := filepath.Join("..", "..", "configs", "config.yaml")
|
||||
cfg, err := Load(yamlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load config failed: %v", err)
|
||||
}
|
||||
|
||||
// OPPO: chat=IM/16, system=ACCOUNT/2
|
||||
oppo := cfg.JPush.Channel.Vendor.OPPO
|
||||
if oppo.OppoChatCategory != "IM" {
|
||||
t.Errorf("oppo.oppo_chat_category default = %q, want IM", oppo.OppoChatCategory)
|
||||
}
|
||||
if oppo.OppoSystemCategory != "ACCOUNT" {
|
||||
t.Errorf("oppo.oppo_system_category default = %q, want ACCOUNT", oppo.OppoSystemCategory)
|
||||
}
|
||||
if oppo.OppoChatNotifyLevel != 16 {
|
||||
t.Errorf("oppo.oppo_chat_notify_level default = %d, want 16", oppo.OppoChatNotifyLevel)
|
||||
}
|
||||
if oppo.OppoSystemNotifyLevel != 2 {
|
||||
t.Errorf("oppo.oppo_system_notify_level default = %d, want 2", oppo.OppoSystemNotifyLevel)
|
||||
}
|
||||
|
||||
// vivo: chat=IM, system=ACCOUNT
|
||||
vivo := cfg.JPush.Channel.Vendor.VIVO
|
||||
if vivo.VivoChatCategory != "IM" {
|
||||
t.Errorf("vivo.vivo_chat_category default = %q, want IM", vivo.VivoChatCategory)
|
||||
}
|
||||
if vivo.VivoSystemCategory != "ACCOUNT" {
|
||||
t.Errorf("vivo.vivo_system_category default = %q, want ACCOUNT", vivo.VivoSystemCategory)
|
||||
}
|
||||
|
||||
// 荣耀: chat=NORMAL, system=LOW
|
||||
honor := cfg.JPush.Channel.Vendor.Honor
|
||||
if honor.HonorChatImportance != "NORMAL" {
|
||||
t.Errorf("honor.honor_chat_importance default = %q, want NORMAL", honor.HonorChatImportance)
|
||||
}
|
||||
if honor.HonorSystemImportance != "LOW" {
|
||||
t.Errorf("honor.honor_system_importance default = %q, want LOW", honor.HonorSystemImportance)
|
||||
}
|
||||
|
||||
t.Logf("OPPO: chat=%s/%d system=%s/%d", oppo.OppoChatCategory, oppo.OppoChatNotifyLevel, oppo.OppoSystemCategory, oppo.OppoSystemNotifyLevel)
|
||||
t.Logf("vivo: chat=%s system=%s", vivo.VivoChatCategory, vivo.VivoSystemCategory)
|
||||
t.Logf("荣耀: chat=%s system=%s", honor.HonorChatImportance, honor.HonorSystemImportance)
|
||||
}
|
||||
11
internal/config/livekit.go
Normal file
11
internal/config/livekit.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
// LiveKitConfig LiveKit SFU 配置
|
||||
type LiveKitConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
URL string `mapstructure:"url"`
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
APISecret string `mapstructure:"api_secret"`
|
||||
TokenTTL int `mapstructure:"token_ttl"`
|
||||
WebhookSecret string `mapstructure:"webhook_secret"`
|
||||
}
|
||||
13
internal/config/push_worker.go
Normal file
13
internal/config/push_worker.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
// PushWorkerConfig 推送 Worker 配置(Redis Stream 异步推送)
|
||||
type PushWorkerConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Stream string `mapstructure:"stream"` // Redis Stream 名称,默认 "msg_push"
|
||||
Group string `mapstructure:"group"` // Consumer Group 名称,默认 "push_worker"
|
||||
BatchSize int `mapstructure:"batch_size"` // XREADGROUP 每次读取条数,默认 50
|
||||
PollTimeoutMs int `mapstructure:"poll_timeout_ms"` // XREADGROUP BLOCK 超时 ms,默认 2000
|
||||
MaxRetries int `mapstructure:"max_retries"` // 消息最大重试次数,默认 3
|
||||
MaxStreamLen int64 `mapstructure:"max_stream_len"` // Stream MAXLEN,默认 100000
|
||||
IdleTimeoutMs int64 `mapstructure:"idle_timeout_ms"` // XPENDING 空闲超时 ms,默认 30000
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisConfig Redis 配置
|
||||
@@ -17,13 +14,22 @@ type RedisConfig struct {
|
||||
|
||||
// CacheConfig 缓存配置
|
||||
type CacheConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
KeyPrefix string `mapstructure:"key_prefix"`
|
||||
DefaultTTL int `mapstructure:"default_ttl"`
|
||||
NullTTL int `mapstructure:"null_ttl"`
|
||||
JitterRatio float64 `mapstructure:"jitter_ratio"`
|
||||
DisableFlushDB bool `mapstructure:"disable_flushdb"`
|
||||
Modules CacheModuleTTL `mapstructure:"modules"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
KeyPrefix string `mapstructure:"key_prefix"`
|
||||
DefaultTTL int `mapstructure:"default_ttl"`
|
||||
NullTTL int `mapstructure:"null_ttl"`
|
||||
JitterRatio float64 `mapstructure:"jitter_ratio"`
|
||||
DisableFlushDB bool `mapstructure:"disable_flushdb"`
|
||||
Modules CacheModuleTTL `mapstructure:"modules"`
|
||||
Local LocalCacheConfig `mapstructure:"local"`
|
||||
}
|
||||
|
||||
// LocalCacheConfig 本地缓存配置
|
||||
type LocalCacheConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Size int `mapstructure:"size"`
|
||||
Buckets int `mapstructure:"buckets"`
|
||||
DefaultTTL int `mapstructure:"default_ttl"`
|
||||
}
|
||||
|
||||
// CacheModuleTTL 缓存模块 TTL 配置
|
||||
@@ -53,19 +59,3 @@ type MiniredisConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
}
|
||||
|
||||
// NewRedis 创建 Redis 客户端(真实 Redis)
|
||||
func NewRedis(cfg *RedisConfig) (*redis.Client, error) {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr(),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
PoolSize: cfg.PoolSize,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to redis: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
7
internal/config/report.go
Normal file
7
internal/config/report.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
// ReportConfig 举报配置
|
||||
type ReportConfig struct {
|
||||
// AutoHideThreshold 自动隐藏阈值(举报次数达到此数值时自动隐藏内容)
|
||||
AutoHideThreshold int `mapstructure:"auto_hide_threshold"`
|
||||
}
|
||||
28
internal/config/runner.go
Normal file
28
internal/config/runner.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
// RunnerConfig Runner 集群配置
|
||||
type RunnerConfig struct {
|
||||
Mode string `mapstructure:"mode"` // "standalone" | "cluster",空则跟随 websocket.mode
|
||||
Cluster RunnerClusterConfig `mapstructure:"cluster"`
|
||||
}
|
||||
|
||||
// RunnerClusterConfig Runner 集群模式配置
|
||||
type RunnerClusterConfig struct {
|
||||
InstanceID string `mapstructure:"instance_id"` // 空则跟随 websocket.cluster.instance_id
|
||||
TaskChannel string `mapstructure:"task_channel"`
|
||||
ResultChannel string `mapstructure:"result_channel"`
|
||||
RegistryPrefix string `mapstructure:"registry_prefix"`
|
||||
CapPrefix string `mapstructure:"cap_prefix"`
|
||||
RegistryTTL int `mapstructure:"registry_ttl"`
|
||||
HeartbeatInterval int `mapstructure:"heartbeat_interval"`
|
||||
}
|
||||
|
||||
// ApplyWSDefaults 用 WebSocket 配置填充 Runner 中未设置的共享字段
|
||||
func (r *RunnerConfig) ApplyWSDefaults(ws WSConfig) {
|
||||
if r.Mode == "" {
|
||||
r.Mode = ws.Mode
|
||||
}
|
||||
if r.Cluster.InstanceID == "" {
|
||||
r.Cluster.InstanceID = ws.Cluster.InstanceID
|
||||
}
|
||||
}
|
||||
10
internal/config/sensitive.go
Normal file
10
internal/config/sensitive.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package config
|
||||
|
||||
type SensitiveConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ReplaceStr string `mapstructure:"replace_str"`
|
||||
MinMatchLen int `mapstructure:"min_match_len"`
|
||||
LoadFromDB bool `mapstructure:"load_from_db"`
|
||||
LoadFromRedis bool `mapstructure:"load_from_redis"`
|
||||
RedisKeyPrefix string `mapstructure:"redis_key_prefix"`
|
||||
}
|
||||
10
internal/config/seq_buffer.go
Normal file
10
internal/config/seq_buffer.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package config
|
||||
|
||||
// SeqBufferConfig seq 预分配配置
|
||||
type SeqBufferConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
PrivateBufferSize int `mapstructure:"private_buffer_size"` // 私聊预分配步长(默认 50)
|
||||
GroupBufferSize int `mapstructure:"group_buffer_size"` // 群聊预分配步长(默认 200)
|
||||
LockTimeoutMs int `mapstructure:"lock_timeout_ms"` // 分布式锁超时毫秒(默认 5000)
|
||||
MaxRetries int `mapstructure:"max_retries"` // 冷启动重试次数(默认 3)
|
||||
}
|
||||
@@ -9,9 +9,45 @@ type ServerConfig struct {
|
||||
|
||||
// LogConfig 日志配置
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
Encoding string `mapstructure:"encoding"`
|
||||
OutputPaths []string `mapstructure:"output_paths"`
|
||||
Level string `mapstructure:"level"`
|
||||
Encoding string `mapstructure:"encoding"`
|
||||
OutputPaths []string `mapstructure:"output_paths"`
|
||||
Retention LogRetentionConfig `mapstructure:"retention"`
|
||||
Async AsyncLogConfig `mapstructure:"async"`
|
||||
Sensitive SensitiveLogConfig `mapstructure:"sensitive"`
|
||||
Rotation LogRotationConfig `mapstructure:"rotation"`
|
||||
}
|
||||
|
||||
// LogRetentionConfig 日志保留配置
|
||||
type LogRetentionConfig struct {
|
||||
AccessLog int `mapstructure:"access_log"` // HTTP访问日志(天)
|
||||
OperationLog int `mapstructure:"operation_log"` // 操作日志(天)
|
||||
LoginLog int `mapstructure:"login_log"` // 登录日志(天)
|
||||
DataChange int `mapstructure:"data_change"` // 数据变更日志(天)
|
||||
}
|
||||
|
||||
// AsyncLogConfig 异步日志配置
|
||||
type AsyncLogConfig struct {
|
||||
BufferSize int `mapstructure:"buffer_size"` // 通道缓冲大小
|
||||
BatchSize int `mapstructure:"batch_size"` // 批量写入大小
|
||||
FlushInterval string `mapstructure:"flush_interval"` // 刷新间隔
|
||||
WorkerCount int `mapstructure:"worker_count"` // worker数量
|
||||
EnableFallback bool `mapstructure:"enable_fallback"` // 启用降级
|
||||
FallbackPath string `mapstructure:"fallback_path"` // 降级文件路径
|
||||
}
|
||||
|
||||
// SensitiveLogConfig 敏感日志配置
|
||||
type SensitiveLogConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // 启用脱敏
|
||||
MaskFields []string `mapstructure:"mask_fields"` // 需脱敏字段
|
||||
}
|
||||
|
||||
// LogRotationConfig 日志轮转配置
|
||||
type LogRotationConfig struct {
|
||||
MaxSize int `mapstructure:"max_size"` // 单个文件最大大小(MB)
|
||||
MaxAge int `mapstructure:"max_age"` // 最大保留天数
|
||||
MaxBackups int `mapstructure:"max_backups"` // 最大备份数
|
||||
Compress bool `mapstructure:"compress"` // 压缩
|
||||
}
|
||||
|
||||
// RateLimitConfig 限流配置
|
||||
@@ -25,3 +61,19 @@ type UploadConfig struct {
|
||||
MaxFileSize int64 `mapstructure:"max_file_size"`
|
||||
AllowedTypes []string `mapstructure:"allowed_types"`
|
||||
}
|
||||
|
||||
// FileCleanupConfig 聊天文件 TTL 过期清理配置
|
||||
type FileCleanupConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // 是否启用清理 worker
|
||||
RetentionDays int `mapstructure:"retention_days"` // 保留天数,默认 7
|
||||
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||
BatchSize int `mapstructure:"batch_size"` // 单次扫描处理上限,默认 100
|
||||
}
|
||||
|
||||
// DeviceCleanupConfig 设备 registration_id 清理配置
|
||||
// 周期性清理超过保留期未使用的设备 token,避免无效 registration_id 累积
|
||||
type DeviceCleanupConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // 是否启用清理 worker,默认 true
|
||||
RetentionDays int `mapstructure:"retention_days"` // 保留天数,默认 3(3 天不活跃即清理)
|
||||
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// S3Config S3 存储配置
|
||||
type S3Config struct {
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
@@ -19,32 +10,3 @@ type S3Config struct {
|
||||
Region string `mapstructure:"region"`
|
||||
Domain string `mapstructure:"domain"` // 自定义域名,如 s3.carrot.skin
|
||||
}
|
||||
|
||||
// NewS3 创建 S3 客户端
|
||||
func NewS3(cfg *S3Config) (*minio.Client, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create S3 client: %w", err)
|
||||
}
|
||||
|
||||
exists, err := client.BucketExists(ctx, cfg.Bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check bucket: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{
|
||||
Region: cfg.Region,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to create bucket: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
8
internal/config/version_log.go
Normal file
8
internal/config/version_log.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package config
|
||||
|
||||
// VersionLogConfig 版本日志增量同步配置
|
||||
type VersionLogConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
SyncLimit int `mapstructure:"sync_limit"` // 单次同步最大返回条数
|
||||
MaxSyncGap int64 `mapstructure:"max_sync_gap"` // 超过此版本差距建议全量同步
|
||||
}
|
||||
15
internal/config/websocket.go
Normal file
15
internal/config/websocket.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package config
|
||||
|
||||
// WSConfig WebSocket 配置
|
||||
type WSConfig struct {
|
||||
Mode string `mapstructure:"mode"` // standalone | cluster
|
||||
Cluster WSClusterConfig `mapstructure:"cluster"`
|
||||
}
|
||||
|
||||
// WSClusterConfig WebSocket 集群配置
|
||||
type WSClusterConfig struct {
|
||||
InstanceID string `mapstructure:"instance_id"`
|
||||
MsgChannel string `mapstructure:"msg_channel"`
|
||||
OnlineTTL int `mapstructure:"online_ttl"`
|
||||
HeartbeatInterval int `mapstructure:"heartbeat_interval"`
|
||||
}
|
||||
535
internal/database/database.go
Normal file
535
internal/database/database.go
Normal file
@@ -0,0 +1,535 @@
|
||||
// Package database 负责数据库连接初始化、自动迁移与种子数据。
|
||||
//
|
||||
// 这些引导职责从 model 包拆分而来:model 包应只包含纯数据结构定义与
|
||||
// GORM 钩子,不应依赖 config 或持有启动逻辑。本包依赖 config + model,
|
||||
// 方向合法(引导层 → 模型层 / 配置层)。
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/plugin/dbresolver"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// NewDB 创建数据库连接(用于 Wire 依赖注入)
|
||||
func NewDB(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||
var err error
|
||||
var db *gorm.DB
|
||||
gormLogger := logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Duration(cfg.SlowThresholdMs) * time.Millisecond,
|
||||
LogLevel: parseGormLogLevel(cfg.LogLevel),
|
||||
IgnoreRecordNotFoundError: cfg.IgnoreRecordNotFound,
|
||||
ParameterizedQueries: cfg.ParameterizedQueries,
|
||||
Colorful: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 根据数据库类型选择驱动
|
||||
switch cfg.Type {
|
||||
case "sqlite":
|
||||
db, err = gorm.Open(sqlite.Open(cfg.SQLite.Path), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
case "postgres", "postgresql":
|
||||
dsn := cfg.Postgres.DSN()
|
||||
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
default:
|
||||
// 默认使用PostgreSQL
|
||||
dsn := cfg.Postgres.DSN()
|
||||
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// 配置连接池(SQLite不支持连接池配置,跳过)
|
||||
if cfg.Type != "sqlite" {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database instance: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
}
|
||||
|
||||
// 配置读写分离(仅 PostgreSQL 且配置了读副本时生效)
|
||||
if (cfg.Type == "postgres" || cfg.Type == "postgresql") && cfg.HasReplica() {
|
||||
allReplicas := cfg.AllReplicas()
|
||||
replicaDialectors := make([]gorm.Dialector, len(allReplicas))
|
||||
for i, replica := range allReplicas {
|
||||
replicaDialectors[i] = postgres.Open(replica.DSN())
|
||||
}
|
||||
|
||||
replicaMaxIdle := cfg.ReplicaMaxIdle
|
||||
if replicaMaxIdle == 0 {
|
||||
replicaMaxIdle = cfg.MaxIdleConns
|
||||
}
|
||||
replicaMaxOpen := cfg.ReplicaMaxOpen
|
||||
if replicaMaxOpen == 0 {
|
||||
replicaMaxOpen = cfg.MaxOpenConns
|
||||
}
|
||||
|
||||
resolver := dbresolver.Register(dbresolver.Config{
|
||||
Replicas: replicaDialectors,
|
||||
}).
|
||||
SetMaxIdleConns(replicaMaxIdle).
|
||||
SetMaxOpenConns(replicaMaxOpen).
|
||||
SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := db.Use(resolver); err != nil {
|
||||
return nil, fmt.Errorf("failed to register dbresolver plugin: %w", err)
|
||||
}
|
||||
|
||||
zap.L().Info("Database read/write splitting configured",
|
||||
zap.String("type", cfg.Type),
|
||||
zap.Int("replica_count", len(allReplicas)),
|
||||
)
|
||||
}
|
||||
|
||||
// 自动迁移
|
||||
if err := autoMigrate(db); err != nil {
|
||||
return nil, fmt.Errorf("failed to auto migrate: %w", err)
|
||||
}
|
||||
|
||||
zap.L().Info("Database connected and migrated successfully",
|
||||
zap.String("type", cfg.Type),
|
||||
)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func parseGormLogLevel(level string) logger.LogLevel {
|
||||
switch level {
|
||||
case "silent":
|
||||
return logger.Silent
|
||||
case "error":
|
||||
return logger.Error
|
||||
case "warn":
|
||||
return logger.Warn
|
||||
case "info":
|
||||
return logger.Info
|
||||
default:
|
||||
return logger.Warn
|
||||
}
|
||||
}
|
||||
|
||||
// autoMigrate 自动迁移数据库表
|
||||
func autoMigrate(db *gorm.DB) error {
|
||||
err := db.AutoMigrate(
|
||||
// 用户相关
|
||||
&model.User{},
|
||||
|
||||
// 帖子相关
|
||||
&model.Post{},
|
||||
&model.PostImage{},
|
||||
&model.Channel{},
|
||||
|
||||
// 评论相关
|
||||
&model.Comment{},
|
||||
&model.CommentLike{},
|
||||
|
||||
// 消息相关(使用雪花算法ID和seq机制)
|
||||
// 已读位置存储在 ConversationParticipant.LastReadSeq 中
|
||||
&model.Conversation{},
|
||||
&model.ConversationParticipant{},
|
||||
&model.Message{},
|
||||
|
||||
// 系统通知(独立表,每个用户只能看到自己的通知)
|
||||
&model.SystemNotification{},
|
||||
|
||||
// 通知
|
||||
&model.Notification{},
|
||||
|
||||
// 推送中心相关
|
||||
&model.PushRecord{}, // 推送记录
|
||||
&model.DeviceToken{}, // 设备Token
|
||||
|
||||
// 社交
|
||||
&model.Follow{},
|
||||
&model.UserBlock{},
|
||||
&model.PostLike{},
|
||||
&model.Favorite{},
|
||||
|
||||
// 投票
|
||||
&model.VoteOption{},
|
||||
&model.UserVote{},
|
||||
|
||||
// 敏感词和审核
|
||||
&model.SensitiveWord{},
|
||||
// &model.AuditLog{}, // TODO: define AuditLog model
|
||||
|
||||
// 举报
|
||||
&model.Report{},
|
||||
|
||||
// 日志相关
|
||||
&model.OperationLog{}, // 操作日志
|
||||
&model.LoginLog{}, // 登录日志
|
||||
&model.DataChangeLog{}, // 数据变更日志
|
||||
|
||||
// 群组相关
|
||||
&model.Group{},
|
||||
&model.GroupMember{},
|
||||
&model.GroupAnnouncement{},
|
||||
&model.GroupJoinRequest{},
|
||||
|
||||
// 自定义表情
|
||||
&model.UserSticker{},
|
||||
|
||||
// 课表
|
||||
&model.ScheduleCourse{},
|
||||
|
||||
// 成绩与考试
|
||||
&model.Grade{},
|
||||
&model.GpaSummary{},
|
||||
&model.Exam{},
|
||||
&model.EmptyClassroom{},
|
||||
|
||||
// 用户活跃相关
|
||||
&model.UserActiveLog{},
|
||||
&model.UserActivityStat{},
|
||||
|
||||
// 角色和权限相关
|
||||
&model.Role{},
|
||||
&model.UserRole{},
|
||||
&model.CasbinRule{},
|
||||
|
||||
// 学习资料相关
|
||||
&model.MaterialSubject{},
|
||||
&model.MaterialFile{},
|
||||
|
||||
// 通话相关
|
||||
&model.CallSession{},
|
||||
&model.CallParticipant{},
|
||||
|
||||
// 身份认证相关
|
||||
&model.VerificationRecord{},
|
||||
|
||||
// 用户资料审核相关
|
||||
&model.UserProfileAudit{},
|
||||
|
||||
// 帖子内链引用关系
|
||||
&model.PostReference{},
|
||||
|
||||
// 二手交易相关
|
||||
&model.TradeItem{},
|
||||
&model.TradeImage{},
|
||||
&model.TradeFavorite{},
|
||||
|
||||
// 文件上传记录(用于聊天文件 TTL 过期清理)
|
||||
&model.UploadedFile{},
|
||||
|
||||
// 会话(令牌撤销支持)
|
||||
&model.Session{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// AutoMigrate 不会删除列:清理已废弃的 posts.hot_score
|
||||
if err := dropPostsHotScoreColumnIfExists(db); err != nil {
|
||||
return fmt.Errorf("drop legacy posts.hot_score: %w", err)
|
||||
}
|
||||
|
||||
// 初始化角色种子数据
|
||||
if err := seedRoles(db); err != nil {
|
||||
return fmt.Errorf("failed to seed roles: %w", err)
|
||||
}
|
||||
|
||||
// 初始化权限策略种子数据
|
||||
if err := seedPermissions(db); err != nil {
|
||||
return fmt.Errorf("failed to seed permissions: %w", err)
|
||||
}
|
||||
|
||||
// 清理历史 Casbin g 分组规则(真源已统一为 user_roles 表,Casbin 仅保留 p 策略)。
|
||||
// 幂等:仅在 casbin_rule 中存在 ptype='g' 记录时删除。
|
||||
if err := cleanupLegacyCasbinGRules(db); err != nil {
|
||||
return fmt.Errorf("failed to cleanup legacy casbin g rules: %w", err)
|
||||
}
|
||||
|
||||
// 初始化频道配置种子数据
|
||||
if err := seedChannels(db); err != nil {
|
||||
return fmt.Errorf("failed to seed channels: %w", err)
|
||||
}
|
||||
|
||||
// 初始化学习资料学科种子数据
|
||||
if err := seedMaterialSubjects(db); err != nil {
|
||||
return fmt.Errorf("failed to seed material subjects: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// postWithHotScore 仅用于 Migrator 检测/删除旧列(Post 模型已移除 HotScore)
|
||||
type postWithHotScore struct {
|
||||
HotScore float64 `gorm:"column:hot_score"`
|
||||
}
|
||||
|
||||
func (postWithHotScore) TableName() string { return "posts" }
|
||||
|
||||
func dropPostsHotScoreColumnIfExists(db *gorm.DB) error {
|
||||
m := db.Migrator()
|
||||
if !m.HasTable(&model.Post{}) {
|
||||
return nil
|
||||
}
|
||||
if !m.HasColumn(&postWithHotScore{}, "HotScore") {
|
||||
return nil
|
||||
}
|
||||
return m.DropColumn(&postWithHotScore{}, "HotScore")
|
||||
}
|
||||
|
||||
// cleanupLegacyCasbinGRules 清理历史 Casbin g 分组规则。
|
||||
//
|
||||
// 真源策略变更后:user_roles 表为用户-角色唯一真源,Casbin 仅保留 p, role, resource, action 策略。
|
||||
// 旧的 g, user, role 记录不再被 matcher 使用(model.conf 已移除 g 依赖),保留只会造成管理后台误解。
|
||||
// 幂等:仅删除 ptype='g' 的记录;若不存在则无操作。
|
||||
func cleanupLegacyCasbinGRules(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&model.CasbinRule{}) {
|
||||
return nil
|
||||
}
|
||||
result := db.Where("ptype = ?", "g").Delete(&model.CasbinRule{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
zap.L().Info("Cleaned up legacy casbin g rules",
|
||||
zap.Int64("count", result.RowsAffected),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedRoles 初始化角色种子数据
|
||||
func seedRoles(db *gorm.DB) error {
|
||||
// 检查是否已有角色数据
|
||||
var count int64
|
||||
if err := db.Model(&model.Role{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果已有数据,跳过种子初始化
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 预定义角色数据
|
||||
roles := []model.Role{
|
||||
{
|
||||
Name: model.RoleSuperAdmin,
|
||||
DisplayName: "超级管理员",
|
||||
Description: "拥有系统最高权限,可以管理所有用户和内容",
|
||||
Priority: 100,
|
||||
},
|
||||
{
|
||||
Name: model.RoleAdmin,
|
||||
DisplayName: "管理员",
|
||||
Description: "系统管理员,可以管理用户和内容",
|
||||
Priority: 80,
|
||||
},
|
||||
{
|
||||
Name: model.RoleModerator,
|
||||
DisplayName: "版主",
|
||||
Description: "内容审核员,可以审核和管理内容",
|
||||
Priority: 60,
|
||||
},
|
||||
{
|
||||
Name: model.RoleUser,
|
||||
DisplayName: "普通用户",
|
||||
Description: "注册用户,拥有基本权限",
|
||||
Priority: 40,
|
||||
},
|
||||
{
|
||||
Name: model.RoleBanned,
|
||||
DisplayName: "被封禁用户",
|
||||
Description: "被禁止访问的用户",
|
||||
Priority: 0,
|
||||
},
|
||||
}
|
||||
|
||||
// 批量插入角色
|
||||
if err := db.Create(&roles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zap.L().Info("Seeded roles successfully",
|
||||
zap.Int("count", len(roles)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedPermissions 初始化权限策略种子数据
|
||||
//
|
||||
// 真源策略:user_roles 表为用户-角色真源,Casbin 仅维护 p, role, resource, action 策略。
|
||||
// 资源命名约定:admin/<domain>[/<sub>](路径式,配合 globMatch 中 * 不跨 /、** 跨 / 的语义)。
|
||||
//
|
||||
// 升级兼容:旧版本以 URL 路径式资源(如 /api/v1/admin/*、/*)作为 Casbin 策略,
|
||||
// 新版本改为 admin/<domain> 资源命名后,存量部署需要迁移。本函数检测到任何 v1 以 '/' 开头的
|
||||
// 旧策略时,将其视为旧版本部署:清空所有 p 策略并按新约定重新 seed。
|
||||
// 新部署无此条件不触发,幂等(admin/<domain> 不以 '/' 开头,不会误判为新旧迁移)。
|
||||
func seedPermissions(db *gorm.DB) error {
|
||||
// 检查是否已有权限策略数据
|
||||
var count int64
|
||||
if err := db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// 检测是否存在旧版本路径式策略(v1 以 '/' 开头)。
|
||||
// 旧策略例如:{p, super_admin, /*, *} / {p, admin, /api/v1/admin/*, *} 等。
|
||||
// 新约定资源形如 admin/users(不以 '/' 开头),不会被本条件误命中。
|
||||
// 旧策略在新 model.conf 的 admin/<domain> 命名下永远不会匹配,保留只会让管理后台权限全断。
|
||||
var legacyCount int64
|
||||
if err := db.Model(&model.CasbinRule{}).
|
||||
Where("ptype = ? AND v1 LIKE '/%'", "p").
|
||||
Count(&legacyCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if legacyCount == 0 {
|
||||
// 已是新版本策略,无需重复 seed。
|
||||
return nil
|
||||
}
|
||||
|
||||
// 命中旧策略:迁移。先 dump 待删除策略到日志便于回查,再删除全部 p 策略。
|
||||
zap.L().Info("Migrating legacy path-based casbin p rules to admin/<domain> resource naming",
|
||||
zap.Int64("legacy_count", legacyCount),
|
||||
zap.Int64("total_p_count", count),
|
||||
)
|
||||
var legacyRules []model.CasbinRule
|
||||
if err := db.Where("ptype = ?", "p").Find(&legacyRules).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range legacyRules {
|
||||
zap.L().Debug("legacy casbin p rule will be removed",
|
||||
zap.String("v0", r.V0),
|
||||
zap.String("v1", r.V1),
|
||||
zap.String("v2", r.V2),
|
||||
)
|
||||
}
|
||||
if err := db.Where("ptype = ?", "p").Delete(&model.CasbinRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 注意:count > 0 分支到此已清空所有 p 策略,下面统一重新 seed。
|
||||
}
|
||||
|
||||
// 预定义权限策略数据
|
||||
// p = sub, obj, act (角色, 资源, 操作)
|
||||
//
|
||||
// 资源命名约定:admin/<domain>[/<sub>](路径式),动作:read / write / *。
|
||||
// super_admin 通过 admin/** 通配获得所有管理能力(globMatch 中 ** 跨 '/');
|
||||
// admin 仅获得业务管理能力,角色与权限管理(admin/roles/*、admin/users/roles/*)
|
||||
// 与日志导出(admin/logs/export)仅 super_admin 拥有(admin/logs 单层匹配不跨 /)。
|
||||
permissions := []model.CasbinRule{
|
||||
// 超级管理员 - 通配所有 admin 资源(globMatch:** 跨 '/' 分隔的多级资源)
|
||||
{Ptype: "p", V0: model.RoleSuperAdmin, V1: "admin/**", V2: "*"},
|
||||
|
||||
// 管理员 - 业务管理能力(不含角色/权限管理与日志导出)
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/status", V2: "write"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/devices", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/posts", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/comments", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/groups", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/channels", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/dashboard", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/reports", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/verifications", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/profile_audits", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/materials", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/logs", V2: "read"},
|
||||
|
||||
// 版主 - 内容审核
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "write"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "write"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "write"},
|
||||
|
||||
// 普通用户与被封禁用户:admin 路由外的资源由路由层中间件保障,Casbin 不维护。
|
||||
}
|
||||
|
||||
// 批量插入权限策略
|
||||
if err := db.Create(&permissions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zap.L().Info("Seeded permissions successfully",
|
||||
zap.Int("count", len(permissions)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedChannels 初始化频道种子数据(仅在表为空时)
|
||||
func seedChannels(db *gorm.DB) error {
|
||||
// 检查是否已有频道数据
|
||||
var count int64
|
||||
if err := db.Model(&model.Channel{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
zap.L().Info("Channels already exist, skipping seed", zap.Int64("count", count))
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultChannels := []model.Channel{
|
||||
{Name: "跳蚤市场", Slug: "market", Description: "二手交易与闲置发布", SortOrder: 10, IsActive: true},
|
||||
{Name: "课程交流", Slug: "courses", Description: "课程资料、选课与学习讨论", SortOrder: 20, IsActive: true},
|
||||
{Name: "工作实习", Slug: "jobs", Description: "实习、内推与求职信息", SortOrder: 30, IsActive: true},
|
||||
{Name: "考研考公", Slug: "exam", Description: "备考经验、资料与互助", SortOrder: 40, IsActive: true},
|
||||
{Name: "保研出国", Slug: "graduate-abroad", Description: "保研、留学申请与经验分享", SortOrder: 50, IsActive: true},
|
||||
}
|
||||
|
||||
if err := db.Create(&defaultChannels).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zap.L().Info("Seeded channels successfully", zap.Int("count", len(defaultChannels)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedMaterialSubjects 初始化学习资料学科种子数据(仅在表为空时)
|
||||
func seedMaterialSubjects(db *gorm.DB) error {
|
||||
// 检查是否已有数据
|
||||
var count int64
|
||||
if err := db.Model(&model.MaterialSubject{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
zap.L().Info("Material subjects already exist, skipping seed", zap.Int64("count", count))
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultSubjects := []model.MaterialSubject{
|
||||
{Name: "数学", Icon: "calculator-variant", Color: "#FF6B6B", Description: "高等数学、线性代数、概率论等", SortOrder: 10, IsActive: true},
|
||||
{Name: "英语", Icon: "translate", Color: "#4ECDC4", Description: "四六级、考研英语、托福雅思等", SortOrder: 20, IsActive: true},
|
||||
{Name: "物理", Icon: "atom", Color: "#45B7D1", Description: "大学物理、电磁学、力学等", SortOrder: 30, IsActive: true},
|
||||
{Name: "化学", Icon: "flask", Color: "#96CEB4", Description: "有机化学、无机化学、分析化学等", SortOrder: 40, IsActive: true},
|
||||
{Name: "计算机", Icon: "laptop", Color: "#FFEAA7", Description: "数据结构、算法、操作系统等", SortOrder: 50, IsActive: true},
|
||||
{Name: "经济管理", Icon: "chart-line", Color: "#DDA0DD", Description: "微观经济学、宏观经济学、管理学等", SortOrder: 60, IsActive: true},
|
||||
{Name: "法学", Icon: "scale-balance", Color: "#F39C12", Description: "民法、刑法、宪法等", SortOrder: 70, IsActive: true},
|
||||
{Name: "语文文学", Icon: "book-open-page-variant", Color: "#3498DB", Description: "古代文学、现代文学、写作等", SortOrder: 80, IsActive: true},
|
||||
}
|
||||
|
||||
if err := db.Create(&defaultSubjects).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zap.L().Info("Seeded material subjects successfully", zap.Int("count", len(defaultSubjects)))
|
||||
return nil
|
||||
}
|
||||
222
internal/database/database_test.go
Normal file
222
internal/database/database_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// 注意:完整的 NewDB→autoMigrate 在 SQLite 下会因 model 包多个表共用
|
||||
// 同名索引 idx_created(data_change_log / login_log / operation_log)而冲突。
|
||||
// 这是 model 包的既有约束(PostgreSQL 经 CREATE INDEX IF NOT EXISTS 容忍),
|
||||
// 与本次分层拆分无关。因此种子逻辑测试在隔离的 SQLite 库上验证:
|
||||
// 仅迁移被种子函数读写的表,避开跨表索引冲突,专注验证种子正确性与幂等性。
|
||||
|
||||
// newSeedTestDB 创建一个仅迁移种子相关表的 SQLite 测试库。
|
||||
func newSeedTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "seed_test.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite failed: %v", err)
|
||||
}
|
||||
|
||||
// 仅迁移种子函数涉及的表(无 idx_created 冲突)
|
||||
if err := db.AutoMigrate(
|
||||
&model.Role{},
|
||||
&model.CasbinRule{},
|
||||
&model.Channel{},
|
||||
&model.MaterialSubject{},
|
||||
&model.Post{}, // dropPostsHotScoreColumnIfExists 依赖 posts 表存在
|
||||
); err != nil {
|
||||
t.Fatalf("autoMigrate seed tables failed: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
// TestSeedRoles 验证角色种子写入 5 个预定义角色。
|
||||
func TestSeedRoles(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedRoles(db); err != nil {
|
||||
t.Fatalf("seedRoles failed: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.Role{}).Count(&count)
|
||||
if count != 5 {
|
||||
t.Errorf("expected 5 seeded roles, got %d", count)
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
model.RoleSuperAdmin, model.RoleAdmin, model.RoleModerator,
|
||||
model.RoleUser, model.RoleBanned,
|
||||
}
|
||||
for _, name := range expected {
|
||||
var role model.Role
|
||||
if err := db.Where("name = ?", name).First(&role).Error; err != nil {
|
||||
t.Errorf("expected role %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedRoles_Idempotent 验证角色种子幂等:表非空时跳过。
|
||||
func TestSeedRoles_Idempotent(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedRoles(db); err != nil {
|
||||
t.Fatalf("first seedRoles: %v", err)
|
||||
}
|
||||
// 第二次调用应跳过(表已有数据)
|
||||
if err := seedRoles(db); err != nil {
|
||||
t.Fatalf("second seedRoles: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.Role{}).Count(&count)
|
||||
if count != 5 {
|
||||
t.Errorf("idempotent seed failed: expected 5 roles, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedPermissions 验证权限策略种子写入,含超级管理员通配规则。
|
||||
func TestSeedPermissions(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedPermissions(db); err != nil {
|
||||
t.Fatalf("seedPermissions failed: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count)
|
||||
if count == 0 {
|
||||
t.Fatal("expected seeded permissions, got 0")
|
||||
}
|
||||
|
||||
// 超级管理员 admin/** 通配(globMatch 中 ** 跨 '/')
|
||||
var rule model.CasbinRule
|
||||
if err := db.Where("ptype=? AND v0=? AND v1=?", "p", model.RoleSuperAdmin, "admin/**").First(&rule).Error; err != nil {
|
||||
t.Errorf("expected superadmin admin/** wildcard permission: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedPermissions_Idempotent 验证权限种子幂等。
|
||||
func TestSeedPermissions_Idempotent(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedPermissions(db); err != nil {
|
||||
t.Fatalf("first seedPermissions: %v", err)
|
||||
}
|
||||
var count1 int64
|
||||
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count1)
|
||||
|
||||
if err := seedPermissions(db); err != nil {
|
||||
t.Fatalf("second seedPermissions: %v", err)
|
||||
}
|
||||
var count2 int64
|
||||
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count2)
|
||||
|
||||
if count2 != count1 {
|
||||
t.Errorf("seed not idempotent: first=%d, second=%d", count1, count2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedChannels 验证频道种子写入 5 个默认频道。
|
||||
func TestSeedChannels(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedChannels(db); err != nil {
|
||||
t.Fatalf("seedChannels failed: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.Channel{}).Count(&count)
|
||||
if count != 5 {
|
||||
t.Errorf("expected 5 channels, got %d", count)
|
||||
}
|
||||
|
||||
// 抽查一个频道
|
||||
var ch model.Channel
|
||||
if err := db.Where("slug = ?", "market").First(&ch).Error; err != nil {
|
||||
t.Errorf("expected market channel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMaterialSubjects 验证学科种子写入 8 个默认学科。
|
||||
func TestSeedMaterialSubjects(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedMaterialSubjects(db); err != nil {
|
||||
t.Fatalf("seedMaterialSubjects failed: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.MaterialSubject{}).Count(&count)
|
||||
if count != 8 {
|
||||
t.Errorf("expected 8 material subjects, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseGormLogLevel 验证日志级别解析映射。
|
||||
func TestParseGormLogLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want logger.LogLevel
|
||||
}{
|
||||
{"silent", logger.Silent},
|
||||
{"error", logger.Error},
|
||||
{"warn", logger.Warn},
|
||||
{"info", logger.Info},
|
||||
{"", logger.Warn}, // 默认
|
||||
{"bogus", logger.Warn}, // 未知值降级为 warn
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseGormLogLevel(c.in); got != c.want {
|
||||
t.Errorf("parseGormLogLevel(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropPostsHotScoreColumnIfExists_NoTable 验证当 posts 表不存在时安全跳过。
|
||||
func TestDropPostsHotScoreColumnIfExists_NoTable(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "empty.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
// 未迁移 posts 表 → 应安全返回 nil
|
||||
if err := dropPostsHotScoreColumnIfExists(db); err != nil {
|
||||
t.Errorf("expected nil when posts table absent, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 编译期断言:确保 NewDB 签名符合 (*config.DatabaseConfig) -> (*gorm.DB, error)
|
||||
var _ = func() (*gorm.DB, error) {
|
||||
return NewDB(&config.DatabaseConfig{})
|
||||
}
|
||||
|
||||
var _ = os.RemoveAll // 保持 os 引用可用(供未来临时文件场景扩展)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
package dto
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ==================== Group 转换 ====================
|
||||
@@ -50,26 +50,6 @@ func GroupMemberToResponse(member *model.GroupMember) *GroupMemberResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// GroupMemberToResponseWithUser 将GroupMember转换为GroupMemberResponse(包含用户信息)
|
||||
func GroupMemberToResponseWithUser(member *model.GroupMember, user *model.User) *GroupMemberResponse {
|
||||
if member == nil {
|
||||
return nil
|
||||
}
|
||||
resp := GroupMemberToResponse(member)
|
||||
if user != nil {
|
||||
resp.User = ConvertUserToResponse(user)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// GroupMembersToResponse 将GroupMember列表转换为GroupMemberResponse列表
|
||||
func GroupMembersToResponse(members []model.GroupMember) []*GroupMemberResponse {
|
||||
result := make([]*GroupMemberResponse, 0, len(members))
|
||||
for i := range members {
|
||||
result = append(result, GroupMemberToResponse(&members[i]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GroupAnnouncementToResponse 将GroupAnnouncement转换为GroupAnnouncementResponse
|
||||
func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse {
|
||||
@@ -94,3 +74,30 @@ func GroupAnnouncementsToResponse(announcements []model.GroupAnnouncement) []*Gr
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GroupsToResponseFromPtr 将Group指针切片转换为GroupResponse列表
|
||||
func GroupsToResponseFromPtr(groups []*model.Group) []*GroupResponse {
|
||||
result := make([]*GroupResponse, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
result = append(result, GroupToResponse(group))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GroupMembersToResponseFromPtr 将GroupMember指针切片转换为GroupMemberResponse列表
|
||||
func GroupMembersToResponseFromPtr(members []*model.GroupMember) []*GroupMemberResponse {
|
||||
result := make([]*GroupMemberResponse, 0, len(members))
|
||||
for _, member := range members {
|
||||
result = append(result, GroupMemberToResponse(member))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GroupAnnouncementsToResponseFromPtr 将GroupAnnouncement指针切片转换为GroupAnnouncementResponse列表
|
||||
func GroupAnnouncementsToResponseFromPtr(announcements []*model.GroupAnnouncement) []*GroupAnnouncementResponse {
|
||||
result := make([]*GroupAnnouncementResponse, 0, len(announcements))
|
||||
for _, announcement := range announcements {
|
||||
result = append(result, GroupAnnouncementToResponse(announcement))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
120
internal/dto/group_dto.go
Normal file
120
internal/dto/group_dto.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package dto
|
||||
|
||||
// ==================== Group DTOs ====================
|
||||
// 从 dto.go 拆分而来:群组相关的请求与响应 DTO。
|
||||
// 与 group_converter.go 同属群组域。
|
||||
|
||||
// CreateGroupRequest 创建群组请求
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
MemberIDs []string `json:"member_ids"`
|
||||
}
|
||||
|
||||
// UpdateGroupRequest 更新群组请求
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,max=50"`
|
||||
Description string `json:"description" binding:"omitempty,max=500"`
|
||||
Avatar string `json:"avatar" binding:"omitempty,url"`
|
||||
}
|
||||
|
||||
// InviteMembersRequest 邀请成员请求
|
||||
type InviteMembersRequest struct {
|
||||
MemberIDs []string `json:"member_ids" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// TransferOwnerRequest 转让群主请求
|
||||
type TransferOwnerRequest struct {
|
||||
NewOwnerID string `json:"new_owner_id" binding:"required"`
|
||||
}
|
||||
|
||||
// SetRoleRequest 设置角色请求
|
||||
type SetRoleRequest struct {
|
||||
Role string `json:"role" binding:"required,oneof=admin member"`
|
||||
}
|
||||
|
||||
// SetNicknameRequest 设置昵称请求
|
||||
type SetNicknameRequest struct {
|
||||
Nickname string `json:"nickname" binding:"max=50"`
|
||||
}
|
||||
|
||||
// MuteMemberRequest 禁言成员请求
|
||||
type MuteMemberRequest struct {
|
||||
Muted bool `json:"muted"`
|
||||
}
|
||||
|
||||
// SetMuteAllRequest 设置全员禁言请求
|
||||
type SetMuteAllRequest struct {
|
||||
MuteAll bool `json:"mute_all"`
|
||||
}
|
||||
|
||||
// SetJoinTypeRequest 设置加群方式请求
|
||||
type SetJoinTypeRequest struct {
|
||||
JoinType int `json:"join_type" binding:"min=0,max=2"`
|
||||
}
|
||||
|
||||
// CreateAnnouncementRequest 创建群公告请求
|
||||
type CreateAnnouncementRequest struct {
|
||||
Content string `json:"content" binding:"required,max=2000"`
|
||||
}
|
||||
|
||||
// GroupResponse 群组响应
|
||||
type GroupResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
Description string `json:"description"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
MemberCount int `json:"member_count"`
|
||||
MaxMembers int `json:"max_members"`
|
||||
JoinType int `json:"join_type"`
|
||||
MuteAll bool `json:"mute_all"`
|
||||
IsMember bool `json:"is_member"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// GroupMemberResponse 群成员响应
|
||||
type GroupMemberResponse struct {
|
||||
ID string `json:"id"`
|
||||
GroupID string `json:"group_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
Nickname string `json:"nickname"`
|
||||
Muted bool `json:"muted"`
|
||||
JoinTime string `json:"join_time"`
|
||||
User *UserResponse `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// GroupAnnouncementResponse 群公告响应
|
||||
type GroupAnnouncementResponse struct {
|
||||
ID string `json:"id"`
|
||||
GroupID string `json:"group_id"`
|
||||
Content string `json:"content"`
|
||||
AuthorID string `json:"author_id"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// GroupListResponse 群组列表响应
|
||||
type GroupListResponse struct {
|
||||
List []*GroupResponse `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// GroupMemberListResponse 群成员列表响应
|
||||
type GroupMemberListResponse struct {
|
||||
List []*GroupMemberResponse `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// GroupAnnouncementListResponse 群公告列表响应
|
||||
type GroupAnnouncementListResponse struct {
|
||||
List []*GroupAnnouncementResponse `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
package dto
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ==================== Message 转换 ====================
|
||||
|
||||
// ConvertMessageToResponse 将Message转换为MessageResponse
|
||||
func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
||||
return ConvertMessageToResponseWithExpiry(message, nil)
|
||||
}
|
||||
|
||||
// ConvertMessageToResponseWithExpiry 将Message转换为MessageResponse,并对已过期的 file segment 注入 expired 标记。
|
||||
// expiredURLSet 为已从 S3 清理的文件 URL 集合(可为 nil,表示不标记)。
|
||||
// 注意:注入时对 file segment 的 Data 做深拷贝,避免污染消息缓存。
|
||||
func ConvertMessageToResponseWithExpiry(message *model.Message, expiredURLSet map[string]struct{}) *MessageResponse {
|
||||
if message == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -15,6 +22,19 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
||||
// 直接使用 segments,不需要解析
|
||||
segments := make(model.MessageSegments, len(message.Segments))
|
||||
for i, seg := range message.Segments {
|
||||
// 对 file 类型且 URL 已过期的 segment,深拷贝 Data 并注入 expired 标记
|
||||
if seg.Type == string(model.ContentTypeFile) && len(expiredURLSet) > 0 {
|
||||
url, _ := seg.Data["url"].(string)
|
||||
if _, expired := expiredURLSet[url]; expired {
|
||||
newData := make(map[string]any, len(seg.Data)+1)
|
||||
for k, v := range seg.Data {
|
||||
newData[k] = v
|
||||
}
|
||||
newData["expired"] = true
|
||||
segments[i] = model.MessageSegment{Type: seg.Type, Data: newData}
|
||||
continue
|
||||
}
|
||||
}
|
||||
segments[i] = model.MessageSegment{
|
||||
Type: seg.Type,
|
||||
Data: seg.Data,
|
||||
@@ -38,7 +58,7 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
||||
// participants: 会话参与者列表(用户信息)
|
||||
// unreadCount: 当前用户的未读消息数
|
||||
// lastMessage: 最后一条消息
|
||||
func ConvertConversationToResponse(conv *model.Conversation, participants []*model.User, unreadCount int, lastMessage *model.Message, isPinned bool) *ConversationResponse {
|
||||
func ConvertConversationToResponse(conv *model.Conversation, participants []*model.User, unreadCount int, lastMessage *model.Message, isPinned bool, notificationMuted bool) *ConversationResponse {
|
||||
if conv == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -55,22 +75,23 @@ func ConvertConversationToResponse(conv *model.Conversation, participants []*mod
|
||||
}
|
||||
|
||||
return &ConversationResponse{
|
||||
ID: conv.ID,
|
||||
Type: string(conv.Type),
|
||||
IsPinned: isPinned,
|
||||
Group: groupResponse,
|
||||
LastSeq: conv.LastSeq,
|
||||
LastMessage: ConvertMessageToResponse(lastMessage),
|
||||
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
||||
UnreadCount: unreadCount,
|
||||
Participants: participantResponses,
|
||||
CreatedAt: FormatTime(conv.CreatedAt),
|
||||
UpdatedAt: FormatTime(conv.UpdatedAt),
|
||||
ID: conv.ID,
|
||||
Type: string(conv.Type),
|
||||
IsPinned: isPinned,
|
||||
NotificationMuted: notificationMuted,
|
||||
Group: groupResponse,
|
||||
LastSeq: conv.LastSeq,
|
||||
LastMessage: ConvertMessageToResponse(lastMessage),
|
||||
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
||||
UnreadCount: unreadCount,
|
||||
Participants: participantResponses,
|
||||
CreatedAt: FormatTime(conv.CreatedAt),
|
||||
UpdatedAt: FormatTime(conv.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertConversationToDetailResponse 将Conversation转换为ConversationDetailResponse
|
||||
func ConvertConversationToDetailResponse(conv *model.Conversation, participants []*model.User, unreadCount int64, lastMessage *model.Message, myLastReadSeq int64, otherLastReadSeq int64, isPinned bool) *ConversationDetailResponse {
|
||||
func ConvertConversationToDetailResponse(conv *model.Conversation, participants []*model.User, unreadCount int64, lastMessage *model.Message, myLastReadSeq int64, otherLastReadSeq int64, isPinned bool, notificationMuted bool) *ConversationDetailResponse {
|
||||
if conv == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -81,18 +102,19 @@ func ConvertConversationToDetailResponse(conv *model.Conversation, participants
|
||||
}
|
||||
|
||||
return &ConversationDetailResponse{
|
||||
ID: conv.ID,
|
||||
Type: string(conv.Type),
|
||||
IsPinned: isPinned,
|
||||
LastSeq: conv.LastSeq,
|
||||
LastMessage: ConvertMessageToResponse(lastMessage),
|
||||
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
||||
UnreadCount: unreadCount,
|
||||
Participants: participantResponses,
|
||||
MyLastReadSeq: myLastReadSeq,
|
||||
OtherLastReadSeq: otherLastReadSeq,
|
||||
CreatedAt: FormatTime(conv.CreatedAt),
|
||||
UpdatedAt: FormatTime(conv.UpdatedAt),
|
||||
ID: conv.ID,
|
||||
Type: string(conv.Type),
|
||||
IsPinned: isPinned,
|
||||
NotificationMuted: notificationMuted,
|
||||
LastSeq: conv.LastSeq,
|
||||
LastMessage: ConvertMessageToResponse(lastMessage),
|
||||
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
||||
UnreadCount: unreadCount,
|
||||
Participants: participantResponses,
|
||||
MyLastReadSeq: myLastReadSeq,
|
||||
OtherLastReadSeq: otherLastReadSeq,
|
||||
CreatedAt: FormatTime(conv.CreatedAt),
|
||||
UpdatedAt: FormatTime(conv.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,15 +127,16 @@ func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse {
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertConversationsToResponse 将Conversation列表转换为响应列表
|
||||
func ConvertConversationsToResponse(convs []*model.Conversation) []*ConversationResponse {
|
||||
result := make([]*ConversationResponse, 0, len(convs))
|
||||
for _, conv := range convs {
|
||||
result = append(result, ConvertConversationToResponse(conv, nil, 0, nil, false))
|
||||
// ConvertMessagesToResponseWithExpiry 将Message列表转换为响应列表,并标记过期文件
|
||||
func ConvertMessagesToResponseWithExpiry(messages []*model.Message, expiredURLSet map[string]struct{}) []*MessageResponse {
|
||||
result := make([]*MessageResponse, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
result = append(result, ConvertMessageToResponseWithExpiry(msg, expiredURLSet))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// ==================== PushRecord 转换 ====================
|
||||
|
||||
// PushRecordToResponse 将PushRecord转换为PushRecordResponse
|
||||
@@ -176,3 +199,34 @@ func DeviceTokensToResponse(tokens []*model.DeviceToken) []*DeviceTokenResponse
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// DeviceTokenToAdminResponse 将DeviceToken转换为管理端响应(含 registration_id)
|
||||
func DeviceTokenToAdminResponse(token *model.DeviceToken) *AdminDeviceTokenResponse {
|
||||
if token == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &AdminDeviceTokenResponse{
|
||||
ID: token.ID,
|
||||
UserID: token.UserID,
|
||||
DeviceID: token.DeviceID,
|
||||
DeviceType: string(token.DeviceType),
|
||||
PushToken: token.PushToken,
|
||||
IsActive: token.IsActive,
|
||||
DeviceName: token.DeviceName,
|
||||
CreatedAt: token.CreatedAt,
|
||||
UpdatedAt: token.UpdatedAt,
|
||||
}
|
||||
if token.LastUsedAt != nil {
|
||||
resp.LastUsedAt = *token.LastUsedAt
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// DeviceTokensToAdminResponse 将DeviceToken列表转换为管理端响应列表
|
||||
func DeviceTokensToAdminResponse(tokens []*model.DeviceToken) []*AdminDeviceTokenResponse {
|
||||
result := make([]*AdminDeviceTokenResponse, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
result = append(result, DeviceTokenToAdminResponse(token))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dto
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"with_you/internal/model"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
@@ -33,49 +33,6 @@ func ConvertNotificationsToResponse(notifications []*model.Notification) []*Noti
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== SystemMessage 转换 ====================
|
||||
|
||||
// SystemMessageToResponse 将Message转换为SystemMessageResponse
|
||||
func SystemMessageToResponse(msg *model.Message) *SystemMessageResponse {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 从 segments 中提取文本内容
|
||||
content := ExtractTextContentFromModel(msg.Segments)
|
||||
|
||||
resp := &SystemMessageResponse{
|
||||
ID: msg.ID,
|
||||
SenderID: msg.SenderID,
|
||||
ReceiverID: "", // 系统消息的接收者需要从上下文获取
|
||||
Content: content,
|
||||
Category: string(msg.Category),
|
||||
SystemType: string(msg.SystemType),
|
||||
CreatedAt: msg.CreatedAt,
|
||||
}
|
||||
if msg.ExtraData != nil {
|
||||
resp.ExtraData = map[string]interface{}{
|
||||
"actor_id": msg.ExtraData.ActorID,
|
||||
"actor_name": msg.ExtraData.ActorName,
|
||||
"avatar_url": msg.ExtraData.AvatarURL,
|
||||
"target_id": msg.ExtraData.TargetID,
|
||||
"target_type": msg.ExtraData.TargetType,
|
||||
"action_url": msg.ExtraData.ActionURL,
|
||||
"action_time": msg.ExtraData.ActionTime,
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// SystemMessagesToResponse 将Message列表转换为SystemMessageResponse列表
|
||||
func SystemMessagesToResponse(messages []*model.Message) []*SystemMessageResponse {
|
||||
result := make([]*SystemMessageResponse, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
result = append(result, SystemMessageToResponse(msg))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SystemNotificationToResponse 将SystemNotification转换为SystemMessageResponse
|
||||
func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse {
|
||||
if n == nil {
|
||||
@@ -92,7 +49,7 @@ func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageRes
|
||||
CreatedAt: n.CreatedAt,
|
||||
}
|
||||
if n.ExtraData != nil {
|
||||
resp.ExtraData = map[string]interface{}{
|
||||
resp.ExtraData = map[string]any{
|
||||
"actor_id": n.ExtraData.ActorID,
|
||||
"actor_id_str": n.ExtraData.ActorIDStr,
|
||||
"actor_name": n.ExtraData.ActorName,
|
||||
@@ -118,11 +75,3 @@ func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageRes
|
||||
return resp
|
||||
}
|
||||
|
||||
// SystemNotificationsToResponse 将SystemNotification列表转换为SystemMessageResponse列表
|
||||
func SystemNotificationsToResponse(notifications []*model.SystemNotification) []*SystemMessageResponse {
|
||||
result := make([]*SystemMessageResponse, 0, len(notifications))
|
||||
for _, n := range notifications {
|
||||
result = append(result, SystemNotificationToResponse(n))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ==================== Post 转换 ====================
|
||||
@@ -14,11 +14,13 @@ func ConvertPostImageToResponse(img *model.PostImage) PostImageResponse {
|
||||
return PostImageResponse{}
|
||||
}
|
||||
return PostImageResponse{
|
||||
ID: img.ID,
|
||||
URL: img.URL,
|
||||
ThumbnailURL: img.ThumbnailURL,
|
||||
Width: img.Width,
|
||||
Height: img.Height,
|
||||
ID: img.ID,
|
||||
URL: img.URL,
|
||||
ThumbnailURL: img.ThumbnailURL,
|
||||
PreviewURL: img.PreviewURL,
|
||||
PreviewURLLarge: img.PreviewURLLarge,
|
||||
Width: img.Width,
|
||||
Height: img.Height,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +33,56 @@ func ConvertPostImagesToResponse(images []model.PostImage) []PostImageResponse {
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertPostToResponse 将Post转换为PostResponse(列表用)
|
||||
func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse {
|
||||
func postChannelBrief(post *model.Post, channelByID map[string]*model.Channel) *PostChannelBrief {
|
||||
if post == nil || post.ChannelID == nil || *post.ChannelID == "" || channelByID == nil {
|
||||
return nil
|
||||
}
|
||||
ch := channelByID[*post.ChannelID]
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
return &PostChannelBrief{ID: ch.ID, Name: ch.Name}
|
||||
}
|
||||
|
||||
// CollectPostChannelIDs 从帖子列表收集去重后的频道 ID(用于批量查库填充 channel 名称)
|
||||
func CollectPostChannelIDs(posts []*model.Post) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0)
|
||||
for _, p := range posts {
|
||||
if p == nil || p.ChannelID == nil || *p.ChannelID == "" {
|
||||
continue
|
||||
}
|
||||
id := *p.ChannelID
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PostChannelBriefForPost 根据帖子与频道映射生成频道摘要(供 Handler 手动组装响应)
|
||||
func PostChannelBriefForPost(post *model.Post, channelByID map[string]*model.Channel) *PostChannelBrief {
|
||||
return postChannelBrief(post, channelByID)
|
||||
}
|
||||
|
||||
// SegmentsOrDefault 如果 segments 为空但 content 非空,
|
||||
// 返回降级的单 text segment;否则原样返回。
|
||||
func SegmentsOrDefault(segments model.MessageSegments, content string) model.MessageSegments {
|
||||
if len(segments) > 0 {
|
||||
return segments
|
||||
}
|
||||
if content != "" {
|
||||
return model.MessageSegments{
|
||||
{Type: string(SegmentTypeText), Data: map[string]any{"text": content}},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertPostToResponse 将Post转换为PostResponse(列表用);channelByID 可为 nil
|
||||
func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostResponse {
|
||||
if post == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -48,69 +98,34 @@ func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostRes
|
||||
}
|
||||
|
||||
return &PostResponse{
|
||||
ID: post.ID,
|
||||
UserID: post.UserID,
|
||||
Title: post.Title,
|
||||
Content: post.Content,
|
||||
Images: images,
|
||||
Status: string(post.Status),
|
||||
LikesCount: post.LikesCount,
|
||||
CommentsCount: post.CommentsCount,
|
||||
FavoritesCount: post.FavoritesCount,
|
||||
SharesCount: post.SharesCount,
|
||||
ViewsCount: post.ViewsCount,
|
||||
IsPinned: post.IsPinned,
|
||||
IsLocked: post.IsLocked,
|
||||
IsVote: post.IsVote,
|
||||
CreatedAt: FormatTime(post.CreatedAt),
|
||||
UpdatedAt: FormatTime(post.UpdatedAt),
|
||||
Author: author,
|
||||
IsLiked: isLiked,
|
||||
IsFavorited: isFavorited,
|
||||
ID: post.ID,
|
||||
UserID: post.UserID,
|
||||
ChannelID: post.ChannelID,
|
||||
Title: post.Title,
|
||||
Content: post.Content,
|
||||
Segments: SegmentsOrDefault(post.Segments, post.Content),
|
||||
Images: images,
|
||||
Status: string(post.Status),
|
||||
LikesCount: post.LikesCount,
|
||||
CommentsCount: post.CommentsCount,
|
||||
FavoritesCount: post.FavoritesCount,
|
||||
SharesCount: post.SharesCount,
|
||||
ViewsCount: post.ViewsCount,
|
||||
IsPinned: post.IsPinned,
|
||||
IsLocked: post.IsLocked,
|
||||
IsVote: post.IsVote,
|
||||
CreatedAt: FormatTime(post.CreatedAt),
|
||||
UpdatedAt: FormatTime(post.UpdatedAt),
|
||||
ContentEditedAt: FormatTimePointer(post.ContentEditedAt),
|
||||
Author: author,
|
||||
IsLiked: isLiked,
|
||||
IsFavorited: isFavorited,
|
||||
Channel: postChannelBrief(post, channelByID),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertPostToDetailResponse 将Post转换为PostDetailResponse
|
||||
func ConvertPostToDetailResponse(post *model.Post, isLiked, isFavorited bool) *PostDetailResponse {
|
||||
if post == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
images := make([]PostImageResponse, 0)
|
||||
for _, img := range post.Images {
|
||||
images = append(images, ConvertPostImageToResponse(&img))
|
||||
}
|
||||
|
||||
var author *UserResponse
|
||||
if post.User != nil {
|
||||
author = ConvertUserToResponse(post.User)
|
||||
}
|
||||
|
||||
return &PostDetailResponse{
|
||||
ID: post.ID,
|
||||
UserID: post.UserID,
|
||||
Title: post.Title,
|
||||
Content: post.Content,
|
||||
Images: images,
|
||||
Status: string(post.Status),
|
||||
LikesCount: post.LikesCount,
|
||||
CommentsCount: post.CommentsCount,
|
||||
FavoritesCount: post.FavoritesCount,
|
||||
SharesCount: post.SharesCount,
|
||||
ViewsCount: post.ViewsCount,
|
||||
IsPinned: post.IsPinned,
|
||||
IsLocked: post.IsLocked,
|
||||
IsVote: post.IsVote,
|
||||
CreatedAt: FormatTime(post.CreatedAt),
|
||||
UpdatedAt: FormatTime(post.UpdatedAt),
|
||||
Author: author,
|
||||
IsLiked: isLiked,
|
||||
IsFavorited: isFavorited,
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertPostsToResponse 将Post列表转换为响应列表(每个帖子独立检查点赞/收藏状态)
|
||||
func ConvertPostsToResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||
// ConvertPostsToResponse 将Post列表转换为响应列表;channelByID 可为 nil
|
||||
func ConvertPostsToResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||
result := make([]*PostResponse, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
isLiked := false
|
||||
@@ -121,21 +136,21 @@ func ConvertPostsToResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[
|
||||
if isFavoritedMap != nil {
|
||||
isFavorited = isFavoritedMap[post.ID]
|
||||
}
|
||||
result = append(result, ConvertPostToResponse(post, isLiked, isFavorited))
|
||||
result = append(result, ConvertPostToResponse(post, channelByID, isLiked, isFavorited))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// BuildPostResponse 构建单个帖子响应(包含交互状态)
|
||||
// 这是一个语义化的辅助函数,便于 Handler 层调用
|
||||
func BuildPostResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse {
|
||||
return ConvertPostToResponse(post, isLiked, isFavorited)
|
||||
func BuildPostResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostResponse {
|
||||
return ConvertPostToResponse(post, channelByID, isLiked, isFavorited)
|
||||
}
|
||||
|
||||
// BuildPostsWithInteractionResponse 批量构建帖子响应(包含交互状态)
|
||||
// 这是一个语义化的辅助函数,便于 Handler 层调用
|
||||
func BuildPostsWithInteractionResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||
return ConvertPostsToResponse(posts, isLikedMap, isFavoritedMap)
|
||||
func BuildPostsWithInteractionResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||
return ConvertPostsToResponse(posts, channelByID, isLikedMap, isFavoritedMap)
|
||||
}
|
||||
|
||||
// ==================== Comment 转换 ====================
|
||||
@@ -185,6 +200,7 @@ func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResp
|
||||
ParentID: comment.ParentID,
|
||||
RootID: comment.RootID,
|
||||
Content: comment.Content,
|
||||
Segments: SegmentsOrDefault(comment.Segments, comment.Content),
|
||||
Images: images,
|
||||
LikesCount: comment.LikesCount,
|
||||
RepliesCount: comment.RepliesCount,
|
||||
@@ -196,14 +212,6 @@ func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResp
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertCommentsToResponse 将Comment列表转换为响应列表
|
||||
func ConvertCommentsToResponse(comments []*model.Comment, isLiked bool) []*CommentResponse {
|
||||
result := make([]*CommentResponse, 0, len(comments))
|
||||
for _, comment := range comments {
|
||||
result = append(result, ConvertCommentToResponse(comment, isLiked))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsLikedChecker 点赞状态检查器接口
|
||||
type IsLikedChecker interface {
|
||||
@@ -261,6 +269,7 @@ func ConvertCommentToResponseWithUser(comment *model.Comment, userID string, che
|
||||
ParentID: comment.ParentID,
|
||||
RootID: comment.RootID,
|
||||
Content: comment.Content,
|
||||
Segments: SegmentsOrDefault(comment.Segments, comment.Content),
|
||||
Images: images,
|
||||
LikesCount: comment.LikesCount,
|
||||
RepliesCount: comment.RepliesCount,
|
||||
|
||||
92
internal/dto/post_dto.go
Normal file
92
internal/dto/post_dto.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package dto
|
||||
|
||||
import "with_you/internal/model"
|
||||
|
||||
// ==================== Post DTOs ====================
|
||||
// 从 dto.go 拆分而来:帖子与投票相关的请求/响应 DTO。
|
||||
// 与 post_converter.go 同属帖子域。
|
||||
|
||||
// PostChannelBrief 帖子所属频道(列表/详情卡片展示)
|
||||
type PostChannelBrief struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// PostImageResponse 帖子图片响应
|
||||
type PostImageResponse struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
PreviewURL string `json:"preview_url"` // 列表/网格预览图
|
||||
PreviewURLLarge string `json:"preview_url_large"` // 详情页预览图
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// PostResponse 帖子响应(列表用)
|
||||
type PostResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ChannelID *string `json:"channel_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments,omitempty"`
|
||||
Images []PostImageResponse `json:"images"`
|
||||
Status string `json:"status,omitempty"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
FavoritesCount int `json:"favorites_count"`
|
||||
SharesCount int `json:"shares_count"`
|
||||
ViewsCount int `json:"views_count"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsLocked bool `json:"is_locked"`
|
||||
IsVote bool `json:"is_vote"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ContentEditedAt string `json:"content_edited_at,omitempty"`
|
||||
Author *UserResponse `json:"author"`
|
||||
IsLiked bool `json:"is_liked"`
|
||||
IsFavorited bool `json:"is_favorited"`
|
||||
Channel *PostChannelBrief `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Vote DTOs ====================
|
||||
|
||||
// CreateVotePostRequest 创建投票帖子请求
|
||||
type CreateVotePostRequest struct {
|
||||
Title string `json:"title" binding:"required,max=200"`
|
||||
Content string `json:"content" binding:"max=2000"`
|
||||
ChannelID *string `json:"channel_id,omitempty"`
|
||||
Images []string `json:"images"`
|
||||
VoteOptions []string `json:"vote_options" binding:"required,min=2,max=10"`
|
||||
// ClientRequestID 客户端为本次发帖生成的幂等键(UUID)。
|
||||
// 同一 userID + ClientRequestID 在 TTL 内重复提交时,后端直接返回首次创建的帖子,避免重复发帖。
|
||||
ClientRequestID string `json:"client_request_id,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePostWithSegmentsRequest 创建帖子请求(含 segments)
|
||||
type CreatePostWithSegmentsRequest struct {
|
||||
Title string `json:"title" binding:"required,max=200"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments"`
|
||||
Images []string `json:"images"`
|
||||
ChannelID *string `json:"channel_id,omitempty"`
|
||||
// ClientRequestID 客户端为本次发帖生成的幂等键(UUID)。
|
||||
// 同一 userID + ClientRequestID 在 TTL 内重复提交时,后端直接返回首次创建的帖子,避免重复发帖。
|
||||
ClientRequestID string `json:"client_request_id,omitempty"`
|
||||
}
|
||||
|
||||
// VoteOptionDTO 投票选项DTO
|
||||
type VoteOptionDTO struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
VotesCount int `json:"votes_count"`
|
||||
}
|
||||
|
||||
// VoteResultDTO 投票结果DTO
|
||||
type VoteResultDTO struct {
|
||||
Options []VoteOptionDTO `json:"options"`
|
||||
TotalVotes int `json:"total_votes"`
|
||||
HasVoted bool `json:"has_voted"`
|
||||
VotedOptionID string `json:"voted_option_id,omitzero"`
|
||||
}
|
||||
171
internal/dto/report.go
Normal file
171
internal/dto/report.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ==================== 举报 DTOs ====================
|
||||
|
||||
// CreateReportRequest 创建举报请求
|
||||
type CreateReportRequest struct {
|
||||
TargetType string `json:"target_type" binding:"required,oneof=post comment message"`
|
||||
TargetID string `json:"target_id" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required,oneof=spam inappropriate harassment misinformation other"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
}
|
||||
|
||||
// ReportResponse 举报响应
|
||||
type ReportResponse struct {
|
||||
ID string `json:"id"`
|
||||
ReporterID string `json:"reporter_id"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Reason string `json:"reason"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminReportListResponse 管理端举报列表响应
|
||||
type AdminReportListResponse struct {
|
||||
ID string `json:"id"`
|
||||
ReporterID string `json:"reporter_id"`
|
||||
Reporter *UserResponse `json:"reporter,omitempty"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Reason string `json:"reason"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
HandledBy *string `json:"handled_by,omitempty"`
|
||||
Handler *UserResponse `json:"handler,omitempty"`
|
||||
HandledAt *string `json:"handled_at,omitempty"`
|
||||
Result *string `json:"result,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminReportDetailResponse 管理端举报详情响应
|
||||
type AdminReportDetailResponse struct {
|
||||
ID string `json:"id"`
|
||||
ReporterID string `json:"reporter_id"`
|
||||
Reporter *UserResponse `json:"reporter"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Reason string `json:"reason"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
HandledBy *string `json:"handled_by,omitempty"`
|
||||
Handler *UserResponse `json:"handler,omitempty"`
|
||||
HandledAt *string `json:"handled_at,omitempty"`
|
||||
Result *string `json:"result,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
|
||||
// 被举报内容详情
|
||||
TargetContent *ReportTargetContent `json:"target_content,omitempty"`
|
||||
}
|
||||
|
||||
// ReportTargetContent 被举报内容详情
|
||||
type ReportTargetContent struct {
|
||||
Type string `json:"type"` // post, comment, message
|
||||
ID string `json:"id"`
|
||||
Author *UserResponse `json:"author,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Title string `json:"title,omitempty"` // 帖子标题
|
||||
Images []string `json:"images,omitempty"` // 图片列表
|
||||
Status string `json:"status,omitempty"` // 内容状态
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// HandleReportRequest 处理举报请求
|
||||
type HandleReportRequest struct {
|
||||
Action string `json:"action" binding:"required,oneof=approve reject"` // approve: 确认违规, reject: 驳回
|
||||
Result string `json:"result" binding:"max=500"` // 处理结果说明
|
||||
}
|
||||
|
||||
// BatchHandleReportRequest 批量处理举报请求
|
||||
type BatchHandleReportRequest struct {
|
||||
IDs []string `json:"ids" binding:"required,min=1,max=100"`
|
||||
Action string `json:"action" binding:"required,oneof=approve reject"`
|
||||
Result string `json:"result" binding:"max=500"`
|
||||
}
|
||||
|
||||
// AdminBatchHandleResponse 批量处理响应
|
||||
type AdminBatchHandleResponse struct {
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
FailedIDs []string `json:"failed_ids,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== 转换函数 ====================
|
||||
|
||||
// ConvertReportToResponse 将 Report 模型转换为响应
|
||||
func ConvertReportToResponse(report *model.Report) *ReportResponse {
|
||||
return &ReportResponse{
|
||||
ID: report.ID,
|
||||
ReporterID: report.ReporterID,
|
||||
TargetType: string(report.TargetType),
|
||||
TargetID: report.TargetID,
|
||||
Reason: string(report.Reason),
|
||||
Description: report.Description,
|
||||
Status: string(report.Status),
|
||||
CreatedAt: FormatTime(report.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func fillAdminReportCommon(resp *AdminReportListResponse, report *model.Report, reporter *model.User, handler *model.User) {
|
||||
if reporter != nil {
|
||||
resp.Reporter = ConvertUserToResponse(reporter)
|
||||
}
|
||||
if handler != nil {
|
||||
resp.Handler = ConvertUserToResponse(handler)
|
||||
}
|
||||
if report.HandledAt != nil {
|
||||
handledAt := FormatTime(*report.HandledAt)
|
||||
resp.HandledAt = &handledAt
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertReportToAdminListResponse 将 Report 转换为管理端列表响应
|
||||
func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User, handler *model.User) *AdminReportListResponse {
|
||||
resp := &AdminReportListResponse{
|
||||
ID: report.ID,
|
||||
ReporterID: report.ReporterID,
|
||||
TargetType: string(report.TargetType),
|
||||
TargetID: report.TargetID,
|
||||
Reason: string(report.Reason),
|
||||
Description: report.Description,
|
||||
Status: string(report.Status),
|
||||
HandledBy: report.HandledBy,
|
||||
Result: report.Result,
|
||||
CreatedAt: FormatTime(report.CreatedAt),
|
||||
}
|
||||
fillAdminReportCommon(resp, report, reporter, handler)
|
||||
return resp
|
||||
}
|
||||
|
||||
// ConvertReportToAdminDetailResponse 将 Report 转换为管理端详情响应
|
||||
func ConvertReportToAdminDetailResponse(report *model.Report, reporter *model.User, handler *model.User, targetContent *ReportTargetContent) *AdminReportDetailResponse {
|
||||
resp := &AdminReportDetailResponse{
|
||||
ID: report.ID,
|
||||
ReporterID: report.ReporterID,
|
||||
TargetType: string(report.TargetType),
|
||||
TargetID: report.TargetID,
|
||||
Reason: string(report.Reason),
|
||||
Description: report.Description,
|
||||
Status: string(report.Status),
|
||||
HandledBy: report.HandledBy,
|
||||
Result: report.Result,
|
||||
CreatedAt: FormatTime(report.CreatedAt),
|
||||
TargetContent: targetContent,
|
||||
}
|
||||
if reporter != nil {
|
||||
resp.Reporter = ConvertUserToResponse(reporter)
|
||||
}
|
||||
if handler != nil {
|
||||
resp.Handler = ConvertUserToResponse(handler)
|
||||
}
|
||||
if report.HandledAt != nil {
|
||||
handledAt := FormatTime(*report.HandledAt)
|
||||
resp.HandledAt = &handledAt
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package dto
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
func ConvertScheduleCourseToResponse(course *model.ScheduleCourse, weeks []int) *ScheduleCourseResponse {
|
||||
|
||||
@@ -6,8 +6,8 @@ type ScheduleCourseResponse struct {
|
||||
Teacher string `json:"teacher,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
StartSection int `json:"start_section"`
|
||||
EndSection int `json:"end_section"`
|
||||
StartSection int `json:"start_section,omitzero"`
|
||||
EndSection int `json:"end_section,omitzero"`
|
||||
Weeks []int `json:"weeks"`
|
||||
Color string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
@@ -3,161 +3,40 @@ package dto
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ParseSegmentData 解析Segment数据到目标结构体
|
||||
func ParseSegmentData(segment model.MessageSegment, target interface{}) error {
|
||||
dataBytes, err := json.Marshal(segment.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(dataBytes, target)
|
||||
}
|
||||
|
||||
// NewTextSegment 创建文本Segment
|
||||
func NewTextSegment(content string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeText),
|
||||
Data: map[string]interface{}{"text": content},
|
||||
Data: map[string]any{"text": content},
|
||||
}
|
||||
}
|
||||
|
||||
// NewImageSegment 创建图片Segment
|
||||
func NewImageSegment(url string, width, height int, thumbnailURL string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeImage),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"thumbnail_url": thumbnailURL,
|
||||
},
|
||||
// ExtractVoteSegmentData 从消息链中提取投票Segment数据
|
||||
func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData {
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(SegmentTypeVote) {
|
||||
dataBytes, err := json.Marshal(segment.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var voteData VoteSegmentData
|
||||
if err := json.Unmarshal(dataBytes, &voteData); err != nil {
|
||||
continue
|
||||
}
|
||||
return &voteData
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewImageSegmentWithSize 创建带文件大小的图片Segment
|
||||
func NewImageSegmentWithSize(url string, width, height int, thumbnailURL string, fileSize int64) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeImage),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"thumbnail_url": thumbnailURL,
|
||||
"file_size": fileSize,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewVoiceSegment 创建语音Segment
|
||||
func NewVoiceSegment(url string, duration int, fileSize int64) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeVoice),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"duration": duration,
|
||||
"file_size": fileSize,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewVideoSegment 创建视频Segment
|
||||
func NewVideoSegment(url string, width, height, duration int, thumbnailURL string, fileSize int64) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeVideo),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"duration": duration,
|
||||
"thumbnail_url": thumbnailURL,
|
||||
"file_size": fileSize,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewFileSegment 创建文件Segment
|
||||
func NewFileSegment(url, name string, size int64, mimeType string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeFile),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"name": name,
|
||||
"size": size,
|
||||
"mime_type": mimeType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewAtSegment 创建@Segment,只存储 user_id,昵称由前端根据群成员列表实时解析
|
||||
func NewAtSegment(userID string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeAt),
|
||||
Data: map[string]interface{}{
|
||||
"user_id": userID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewAtAllSegment 创建@所有人Segment
|
||||
func NewAtAllSegment() model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeAt),
|
||||
Data: map[string]interface{}{
|
||||
"user_id": "all",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewReplySegment 创建回复Segment
|
||||
func NewReplySegment(messageID string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeReply),
|
||||
Data: map[string]interface{}{"id": messageID},
|
||||
}
|
||||
}
|
||||
|
||||
// NewFaceSegment 创建表情Segment
|
||||
func NewFaceSegment(id int, name, url string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeFace),
|
||||
Data: map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"url": url,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewLinkSegment 创建链接Segment
|
||||
func NewLinkSegment(url, title, description, image string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeLink),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"image": image,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractTextContentFromJSON 从JSON格式的segments中提取纯文本内容
|
||||
// 用于从数据库读取的 []byte 格式的 segments
|
||||
// 已废弃:现在数据库直接存储 model.MessageSegments 类型
|
||||
func ExtractTextContentFromJSON(segmentsJSON []byte) string {
|
||||
if len(segmentsJSON) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var segments model.MessageSegments
|
||||
if err := json.Unmarshal(segmentsJSON, &segments); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ExtractTextContentFromModel(segments)
|
||||
// HasVoteSegment 检查消息链中是否包含投票Segment
|
||||
func HasVoteSegment(segments model.MessageSegments) bool {
|
||||
return HasSegmentType(segments, SegmentTypeVote)
|
||||
}
|
||||
|
||||
// ExtractTextContentFromModel 从 model.MessageSegments 中提取纯文本内容
|
||||
@@ -201,6 +80,14 @@ func ExtractTextContentFromModel(segments model.MessageSegments) string {
|
||||
} else {
|
||||
result += "[链接]"
|
||||
}
|
||||
case "vote":
|
||||
result += "[投票]"
|
||||
case "post_ref":
|
||||
if title, ok := segment.Data["title"].(string); ok && title != "" {
|
||||
result += fmt.Sprintf("[帖子:%s]", title)
|
||||
} else {
|
||||
result += "[帖子引用]"
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -230,133 +117,27 @@ func ExtractMentionedUsers(segments model.MessageSegments) []string {
|
||||
return userIDs
|
||||
}
|
||||
|
||||
// IsAtAll 检查消息是否@了所有人
|
||||
func IsAtAll(segments model.MessageSegments) bool {
|
||||
// ExtractReferencedPosts 从消息链中提取所有被引用的帖子ID
|
||||
func ExtractReferencedPosts(segments model.MessageSegments) []string {
|
||||
var postIDs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(SegmentTypeAt) {
|
||||
if userID, ok := segment.Data["user_id"].(string); ok && userID == "all" {
|
||||
return true
|
||||
if segment.Type == string(SegmentTypePostRef) {
|
||||
postID, _ := segment.Data["post_id"].(string)
|
||||
if postID != "" && !seen[postID] {
|
||||
postIDs = append(postIDs, postID)
|
||||
seen[postID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetReplyMessageID 从消息链中获取被回复的消息ID
|
||||
// 如果没有回复segment,返回空字符串
|
||||
func GetReplyMessageID(segments model.MessageSegments) string {
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(SegmentTypeReply) {
|
||||
if id, ok := segment.Data["id"].(string); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildSegmentsFromContent 从旧版content构建segments
|
||||
// 用于兼容旧版本消息
|
||||
func BuildSegmentsFromContent(contentType, content string, mediaURL *string) model.MessageSegments {
|
||||
var segments model.MessageSegments
|
||||
|
||||
switch contentType {
|
||||
case "text":
|
||||
segments = append(segments, NewTextSegment(content))
|
||||
case "image":
|
||||
if mediaURL != nil {
|
||||
segments = append(segments, NewImageSegment(*mediaURL, 0, 0, ""))
|
||||
}
|
||||
case "voice":
|
||||
if mediaURL != nil {
|
||||
segments = append(segments, NewVoiceSegment(*mediaURL, 0, 0))
|
||||
}
|
||||
case "video":
|
||||
if mediaURL != nil {
|
||||
segments = append(segments, NewVideoSegment(*mediaURL, 0, 0, 0, "", 0))
|
||||
}
|
||||
case "file":
|
||||
if mediaURL != nil {
|
||||
segments = append(segments, NewFileSegment(*mediaURL, content, 0, ""))
|
||||
}
|
||||
default:
|
||||
// 默认当作文本处理
|
||||
if content != "" {
|
||||
segments = append(segments, NewTextSegment(content))
|
||||
}
|
||||
}
|
||||
|
||||
return segments
|
||||
return postIDs
|
||||
}
|
||||
|
||||
// HasSegmentType 检查消息链中是否包含指定类型的segment
|
||||
func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool {
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(segmentType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
||||
return segment.Type == string(segmentType)
|
||||
})
|
||||
}
|
||||
|
||||
// GetSegmentsByType 获取消息链中所有指定类型的segment
|
||||
func GetSegmentsByType(segments model.MessageSegments, segmentType SegmentType) []model.MessageSegment {
|
||||
var result []model.MessageSegment
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(segmentType) {
|
||||
result = append(result, segment)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetFirstImageURL 获取消息链中第一张图片的URL
|
||||
// 如果没有图片,返回空字符串
|
||||
func GetFirstImageURL(segments model.MessageSegments) string {
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(SegmentTypeImage) {
|
||||
if url, ok := segment.Data["url"].(string); ok {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetFirstMediaURL 获取消息链中第一个媒体文件的URL(图片/视频/语音/文件)
|
||||
// 用于兼容旧版本API
|
||||
func GetFirstMediaURL(segments model.MessageSegments) string {
|
||||
for _, segment := range segments {
|
||||
switch segment.Type {
|
||||
case string(SegmentTypeImage), string(SegmentTypeVideo), string(SegmentTypeVoice), string(SegmentTypeFile):
|
||||
if url, ok := segment.Data["url"].(string); ok {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DetermineContentType 从消息链推断消息类型(用于兼容旧版本)
|
||||
func DetermineContentType(segments model.MessageSegments) string {
|
||||
if len(segments) == 0 {
|
||||
return "text"
|
||||
}
|
||||
|
||||
// 优先检查媒体类型
|
||||
for _, segment := range segments {
|
||||
switch segment.Type {
|
||||
case string(SegmentTypeImage):
|
||||
return "image"
|
||||
case string(SegmentTypeVideo):
|
||||
return "video"
|
||||
case string(SegmentTypeVoice):
|
||||
return "voice"
|
||||
case string(SegmentTypeFile):
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回text
|
||||
return "text"
|
||||
}
|
||||
|
||||
23
internal/dto/sync_dto.go
Normal file
23
internal/dto/sync_dto.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package dto
|
||||
|
||||
// SyncVersionRequest 增量同步请求
|
||||
type SyncVersionRequest struct {
|
||||
Version int64 `form:"version" binding:"min=0"` // 客户端上次同步版本号,0 表示全量同步
|
||||
}
|
||||
|
||||
// SyncVersionResponse 增量同步响应
|
||||
type SyncVersionResponse struct {
|
||||
CurrentVersion int64 `json:"current_version"` // 服务端当前最大版本号
|
||||
Changes []SyncChangeItem `json:"changes"` // 变更列表
|
||||
FullSync bool `json:"full_sync"` // 是否需要全量同步(版本差距过大时)
|
||||
HasMore bool `json:"has_more"` // 是否还有更多变更
|
||||
}
|
||||
|
||||
// SyncChangeItem 单条变更项
|
||||
type SyncChangeItem struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
ChangeType string `json:"change_type"` // new_msg, read, pin, unpin, mute, unmute, hide, group_update
|
||||
MaxSeq int64 `json:"max_seq,omitempty"`
|
||||
LastMessageAt int64 `json:"last_message_at,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
68
internal/dto/trade_converter.go
Normal file
68
internal/dto/trade_converter.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
func ConvertTradeImageToResponse(img *model.TradeImage) TradeImageResponse {
|
||||
if img == nil {
|
||||
return TradeImageResponse{}
|
||||
}
|
||||
return TradeImageResponse{
|
||||
ID: img.ID,
|
||||
URL: img.URL,
|
||||
ThumbnailURL: img.ThumbnailURL,
|
||||
PreviewURL: img.PreviewURL,
|
||||
PreviewURLLarge: img.PreviewURLLarge,
|
||||
Width: img.Width,
|
||||
Height: img.Height,
|
||||
}
|
||||
}
|
||||
|
||||
func ConvertTradeImagesToResponse(images []model.TradeImage) []TradeImageResponse {
|
||||
result := make([]TradeImageResponse, 0, len(images))
|
||||
for i := range images {
|
||||
result = append(result, ConvertTradeImageToResponse(&images[i]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ConvertTradeItemToResponse(item *model.TradeItem, isFavorited bool) *TradeItemResponse {
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &TradeItemResponse{
|
||||
ID: item.ID,
|
||||
UserID: item.UserID,
|
||||
TradeType: string(item.TradeType),
|
||||
Category: string(item.Category),
|
||||
Title: item.Title,
|
||||
Content: item.Content,
|
||||
Price: item.Price,
|
||||
Condition: item.Condition,
|
||||
Status: string(item.Status),
|
||||
ViewsCount: item.ViewsCount,
|
||||
FavoritesCount: item.FavoritesCount,
|
||||
IsFavorited: isFavorited,
|
||||
Images: ConvertTradeImagesToResponse(item.Images),
|
||||
CreatedAt: FormatTime(item.CreatedAt),
|
||||
UpdatedAt: FormatTime(item.UpdatedAt),
|
||||
}
|
||||
if len(item.Segments) > 0 {
|
||||
resp.Segments = item.Segments
|
||||
}
|
||||
if item.User != nil {
|
||||
resp.Author = ConvertUserToResponse(item.User)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func ConvertTradeItemsToResponse(items []*model.TradeItem, favoriteMap map[string]bool) []*TradeItemResponse {
|
||||
result := make([]*TradeItemResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
isFav := favoriteMap[item.ID]
|
||||
result = append(result, ConvertTradeItemToResponse(item, isFav))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
72
internal/dto/trade_dto.go
Normal file
72
internal/dto/trade_dto.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
type TradeImageResponse struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
PreviewURLLarge string `json:"preview_url_large"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type TradeItemResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
TradeType string `json:"trade_type"`
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments,omitempty"`
|
||||
Images []TradeImageResponse `json:"images"`
|
||||
Price *float64 `json:"price,omitempty"`
|
||||
Condition string `json:"condition,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ViewsCount int `json:"views_count"`
|
||||
FavoritesCount int `json:"favorites_count"`
|
||||
IsFavorited bool `json:"is_favorited"`
|
||||
Author *UserResponse `json:"author"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateTradeItemRequest struct {
|
||||
TradeType string `json:"trade_type" binding:"required,oneof=sell buy"`
|
||||
Category string `json:"category" binding:"required,oneof=digital book clothing daily cosmetics sport ticket other"`
|
||||
Title string `json:"title" binding:"required,max=100"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments"`
|
||||
Images []string `json:"images"`
|
||||
Price *float64 `json:"price"`
|
||||
Condition string `json:"condition" binding:"omitempty,oneof=brand_new like_new lightly_used well_used"`
|
||||
}
|
||||
|
||||
type UpdateTradeItemRequest struct {
|
||||
TradeType *string `json:"trade_type" binding:"omitempty,oneof=sell buy"`
|
||||
Category *string `json:"category" binding:"omitempty,oneof=digital book clothing daily cosmetics sport ticket other"`
|
||||
Title *string `json:"title" binding:"omitempty,max=100"`
|
||||
Content *string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments"`
|
||||
Images []string `json:"images"`
|
||||
Price *float64 `json:"price"`
|
||||
Condition *string `json:"condition" binding:"omitempty,oneof=brand_new like_new lightly_used well_used"`
|
||||
}
|
||||
|
||||
type UpdateTradeItemStatusRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=active sold bought offline"`
|
||||
}
|
||||
|
||||
type TradeCursorPageResponse struct {
|
||||
Items []*TradeItemResponse `json:"list"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
type TradeFavoriteResponse struct {
|
||||
Favorited bool `json:"favorited"`
|
||||
}
|
||||
@@ -1,217 +1,154 @@
|
||||
package dto
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/utils"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/utils"
|
||||
)
|
||||
|
||||
// ==================== User 转换 ====================
|
||||
type UserResponseOption func(*UserResponse)
|
||||
|
||||
func WithFollowing(isFollowing, isFollowingMe bool) UserResponseOption {
|
||||
return func(r *UserResponse) {
|
||||
r.IsFollowing = isFollowing
|
||||
r.IsFollowingMe = isFollowingMe
|
||||
}
|
||||
}
|
||||
|
||||
func WithPostsCount(count int) UserResponseOption {
|
||||
return func(r *UserResponse) {
|
||||
r.PostsCount = count
|
||||
}
|
||||
}
|
||||
|
||||
// getAvatarOrDefault 获取头像URL,如果为空则返回在线头像生成服务的URL
|
||||
func getAvatarOrDefault(user *model.User) string {
|
||||
return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar)
|
||||
}
|
||||
|
||||
// ConvertUserToResponse 将User转换为UserResponse
|
||||
func ConvertUserToResponse(user *model.User) *UserResponse {
|
||||
// publicVerificationFields 返回认证信息:
|
||||
// - approved 时返回真实身份和状态
|
||||
// - 其他状态统一返回 general + none,不暴露具体审核状态
|
||||
func publicVerificationFields(user *model.User) (model.UserIdentity, model.VerificationStatus) {
|
||||
if user.VerificationStatus == model.VerificationStatusApproved {
|
||||
return user.Identity, user.VerificationStatus
|
||||
}
|
||||
return model.UserIdentityGeneral, model.VerificationStatusNone
|
||||
}
|
||||
|
||||
func baseUserResponse(user *model.User) *UserResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
identity, vs := publicVerificationFields(user)
|
||||
return &UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: user.PostsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: user.PostsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
Identity: identity,
|
||||
VerificationStatus: vs,
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUserToResponseWithFollowing 将User转换为UserResponse(包含关注状态)
|
||||
func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *UserResponse {
|
||||
// ConvertUserToResponse 将User转换为UserResponse(公开信息,不包含敏感数据)
|
||||
func ConvertUserToResponse(user *model.User, opts ...UserResponseOption) *UserResponse {
|
||||
r := baseUserResponse(user)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ConvertUserToSelfResponse 将User转换为UserSelfResponse(本人信息,包含敏感数据)
|
||||
func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: user.PostsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsFollowing: isFollowing,
|
||||
IsFollowingMe: false, // 默认false,需要单独计算
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
return &UserSelfResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: postsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsVerified: user.IsVerified,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
|
||||
Identity: user.Identity,
|
||||
VerificationStatus: user.VerificationStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUserToResponseWithPostsCount 将User转换为UserResponse(使用实时计算的帖子数量)
|
||||
func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *UserResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: postsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUserToResponseWithMutualFollow 将User转换为UserResponse(包含双向关注状态)
|
||||
func ConvertUserToResponseWithMutualFollow(user *model.User, isFollowing, isFollowingMe bool) *UserResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: user.PostsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsFollowing: isFollowing,
|
||||
IsFollowingMe: isFollowingMe,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUserToResponseWithMutualFollowAndPostsCount 将User转换为UserResponse(包含双向关注状态和实时计算的帖子数量)
|
||||
func ConvertUserToResponseWithMutualFollowAndPostsCount(user *model.User, isFollowing, isFollowingMe bool, postsCount int) *UserResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: postsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsFollowing: isFollowing,
|
||||
IsFollowingMe: isFollowingMe,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUserToDetailResponse 将User转换为UserDetailResponse
|
||||
func ConvertUserToDetailResponse(user *model.User) *UserDetailResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserDetailResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: user.PostsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsVerified: user.IsVerified,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUserToDetailResponseWithPostsCount 将User转换为UserDetailResponse(使用实时计算的帖子数量)
|
||||
// ConvertUserToDetailResponseWithPostsCount 将User转换为UserDetailResponse(使用实时计算的帖子数量,仅本人可见)
|
||||
func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) *UserDetailResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserDetailResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Phone: user.Phone, // 仅当前用户自己可见
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: postsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsVerified: user.IsVerified,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Phone: user.Phone,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: postsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsVerified: user.IsVerified,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
|
||||
Identity: user.Identity,
|
||||
VerificationStatus: user.VerificationStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func convertPrivacySettingsToResponse(ps *model.PrivacySettings) *PrivacySettingsResponse {
|
||||
if ps == nil {
|
||||
return nil
|
||||
}
|
||||
return &PrivacySettingsResponse{
|
||||
FollowersVisibility: string(ps.FollowersVisibility),
|
||||
FollowingVisibility: string(ps.FollowingVisibility),
|
||||
PostsVisibility: string(ps.PostsVisibility),
|
||||
FavoritesVisibility: string(ps.FavoritesVisibility),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUsersToResponse 将User列表转换为响应列表
|
||||
func ConvertUsersToResponse(users []*model.User) []*UserResponse {
|
||||
func ConvertUsersToResponse(users []*model.User, opts ...UserResponseOption) []*UserResponse {
|
||||
result := make([]*UserResponse, 0, len(users))
|
||||
for _, user := range users {
|
||||
result = append(result, ConvertUserToResponse(user))
|
||||
result = append(result, ConvertUserToResponse(user, opts...))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertUsersToResponseWithMutualFollow 将User列表转换为响应列表(包含双向关注状态)
|
||||
// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe]
|
||||
func ConvertUsersToResponseWithMutualFollow(users []*model.User, followingStatusMap map[string][2]bool) []*UserResponse {
|
||||
result := make([]*UserResponse, 0, len(users))
|
||||
for _, user := range users {
|
||||
status, ok := followingStatusMap[user.ID]
|
||||
if ok {
|
||||
result = append(result, ConvertUserToResponseWithMutualFollow(user, status[0], status[1]))
|
||||
} else {
|
||||
result = append(result, ConvertUserToResponse(user))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表(包含双向关注状态和实时计算的帖子数量)
|
||||
// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表(包含双向关注状态和帖子数量)
|
||||
// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe]
|
||||
// postsCountMap: key是用户ID,value是帖子数量
|
||||
func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse {
|
||||
@@ -219,17 +156,15 @@ func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, fo
|
||||
for _, user := range users {
|
||||
status, hasStatus := followingStatusMap[user.ID]
|
||||
postsCount, hasPostsCount := postsCountMap[user.ID]
|
||||
|
||||
// 如果没有帖子数量,使用数据库中的值
|
||||
if !hasPostsCount {
|
||||
postsCount = int64(user.PostsCount)
|
||||
}
|
||||
|
||||
var opts []UserResponseOption
|
||||
if hasStatus {
|
||||
result = append(result, ConvertUserToResponseWithMutualFollowAndPostsCount(user, status[0], status[1], int(postsCount)))
|
||||
} else {
|
||||
result = append(result, ConvertUserToResponseWithPostsCount(user, int(postsCount)))
|
||||
opts = append(opts, WithFollowing(status[0], status[1]))
|
||||
}
|
||||
opts = append(opts, WithPostsCount(int(postsCount)))
|
||||
result = append(result, ConvertUserToResponse(user, opts...))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,59 @@ var (
|
||||
ErrCannotModifyOwnRole = &AppError{Code: "CANNOT_MODIFY_OWN_ROLE", Message: "不能修改自己的角色"}
|
||||
ErrCannotModifySuperAdmin = &AppError{Code: "CANNOT_MODIFY_SUPER_ADMIN", Message: "不能修改超级管理员角色"}
|
||||
ErrCasbinInternal = &AppError{Code: "CASBIN_INTERNAL_ERROR", Message: "权限系统内部错误"}
|
||||
|
||||
// 通话相关错误
|
||||
ErrCallNotFound = &AppError{Code: "CALL_NOT_FOUND", Message: "通话不存在"}
|
||||
ErrCallNotActive = &AppError{Code: "CALL_NOT_ACTIVE", Message: "通话未激活"}
|
||||
ErrCallInProgress = &AppError{Code: "CALL_IN_PROGRESS", Message: "用户之间已有通话"}
|
||||
ErrNotCallParticipant = &AppError{Code: "NOT_CALL_PARTICIPANT", Message: "不是通话参与者"}
|
||||
ErrInvalidCallState = &AppError{Code: "INVALID_CALL_STATE", Message: "通话状态无效"}
|
||||
ErrCalleeOffline = &AppError{Code: "CALLEE_OFFLINE", Message: "对方不在线"}
|
||||
ErrCallAlreadyAnswered = &AppError{Code: "CALL_ALREADY_ANSWERED", Message: "通话已在其他设备应答"}
|
||||
ErrGroupCallNotSupported = &AppError{Code: "GROUP_CALL_NOT_SUPPORTED", Message: "群组通话不支持"}
|
||||
ErrMaxParticipantsReached = &AppError{Code: "MAX_PARTICIPANTS_REACHED", Message: "通话人数已达上限"}
|
||||
|
||||
// 消息相关错误
|
||||
ErrConversationNotFound = &AppError{Code: "CONVERSATION_NOT_FOUND", Message: "会话不存在或无权限"}
|
||||
ErrNotConversationMember = &AppError{Code: "NOT_CONVERSATION_MEMBER", Message: "不是会话参与者"}
|
||||
ErrMessageNotFound = &AppError{Code: "MESSAGE_NOT_FOUND", Message: "消息不存在"}
|
||||
ErrMessageAlreadyRecalled = &AppError{Code: "MESSAGE_ALREADY_RECALLED", Message: "消息已撤回"}
|
||||
ErrMessageRecallTimeout = &AppError{Code: "MESSAGE_RECALL_TIMEOUT", Message: "消息撤回超时(2分钟)"}
|
||||
ErrCannotSendMessage = &AppError{Code: "CANNOT_SEND_MESSAGE", Message: "无法发送消息"}
|
||||
ErrCannotSendImage = &AppError{Code: "CANNOT_SEND_IMAGE", Message: "对方未关注你,暂不支持发送图片"}
|
||||
ErrMessageLimitReached = &AppError{Code: "MESSAGE_LIMIT_REACHED", Message: "对方未关注你前,仅允许发送一条消息"}
|
||||
ErrQRCodeSessionExpired = &AppError{Code: "QRCODE_SESSION_EXPIRED", Message: "二维码已过期"}
|
||||
ErrQRCodeAlreadyScanned = &AppError{Code: "QRCODE_ALREADY_SCANNED", Message: "二维码已被扫描"}
|
||||
ErrQRCodeInvalidStatus = &AppError{Code: "QRCODE_INVALID_STATUS", Message: "二维码状态无效"}
|
||||
ErrPushNotImplemented = &AppError{Code: "PUSH_NOT_IMPLEMENTED", Message: "推送服务未实现"}
|
||||
ErrPushQueueFull = &AppError{Code: "PUSH_QUEUE_FULL", Message: "推送队列已满"}
|
||||
ErrUserOffline = &AppError{Code: "USER_OFFLINE", Message: "用户不在线"}
|
||||
|
||||
// 学习资料相关错误
|
||||
ErrSubjectHasMaterials = &AppError{Code: "SUBJECT_HAS_MATERIALS", Message: "学科下存在资料,无法删除"}
|
||||
|
||||
// 帖子创建幂等性:同一 client_request_id 的请求仍在处理中(占位未回填真实结果)
|
||||
ErrDuplicatePostRequest = &AppError{Code: "DUPLICATE_POST_REQUEST", Message: "正在发布中,请稍候"}
|
||||
|
||||
// 身份认证相关错误
|
||||
ErrVerificationPending = &AppError{Code: "VERIFICATION_PENDING", Message: "已有待审核的认证申请,请等待审核"}
|
||||
ErrVerificationNotFound = &AppError{Code: "VERIFICATION_NOT_FOUND", Message: "认证记录不存在"}
|
||||
ErrInvalidIdentity = &AppError{Code: "INVALID_IDENTITY", Message: "无效的身份类型"}
|
||||
ErrAlreadyVerified = &AppError{Code: "ALREADY_VERIFIED", Message: "已完成身份认证,无需重复申请"}
|
||||
ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", Message: "该认证申请已被处理"}
|
||||
ErrVerificationRequired = &AppError{Code: "VERIFICATION_REQUIRED", Message: "需要完成身份认证"}
|
||||
|
||||
// 认证 / 令牌相关错误(统一由认证管道返回)
|
||||
ErrInvalidToken = &AppError{Code: "INVALID_TOKEN", Message: "令牌无效"}
|
||||
ErrTokenExpired = &AppError{Code: "TOKEN_EXPIRED", Message: "令牌已过期"}
|
||||
ErrSessionRevoked = &AppError{Code: "SESSION_REVOKED", Message: "会话已失效,请重新登录"}
|
||||
ErrAccountInactive = &AppError{Code: "ACCOUNT_INACTIVE", Message: "账号未激活"}
|
||||
ErrWrongTokenType = &AppError{Code: "WRONG_TOKEN_TYPE", Message: "令牌类型错误"}
|
||||
|
||||
// 初始化相关错误
|
||||
ErrSetupAlreadyCompleted = &AppError{Code: "SETUP_ALREADY_COMPLETED", Message: "超级管理员已初始化,无法重复设置"}
|
||||
ErrInvalidSetupSecret = &AppError{Code: "INVALID_SETUP_SECRET", Message: "初始化密钥无效"}
|
||||
ErrSetupSecretNotConfigured = &AppError{Code: "SETUP_SECRET_NOT_CONFIGURED", Message: "未配置初始化密钥,无法使用此接口"}
|
||||
)
|
||||
|
||||
// Wrap 包装错误
|
||||
@@ -110,16 +163,6 @@ func Wrap(err error, appErr *AppError) *AppError {
|
||||
}
|
||||
}
|
||||
|
||||
// New 创建新错误
|
||||
func New(code, message string) *AppError {
|
||||
return &AppError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// Is 判断是否为特定错误
|
||||
func Is(err, target error) bool {
|
||||
return errors.Is(err, target)
|
||||
}
|
||||
|
||||
// As 将错误转换为 AppError
|
||||
func As(err error) (*AppError, bool) {
|
||||
var appErr *AppError
|
||||
|
||||
365
internal/grpc/runner/bus.go
Normal file
365
internal/grpc/runner/bus.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"with_you/proto/runner"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// taskBusMsg Redis 任务频道消息格式
|
||||
type taskBusMsg struct {
|
||||
OriginInstanceID string `json:"origin_instance_id"`
|
||||
TargetRunnerID string `json:"target_runner_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
TaskType string `json:"task_type"`
|
||||
TaskPayload json.RawMessage `json:"task_payload"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
TimeoutSeconds int32 `json:"timeout_seconds"`
|
||||
}
|
||||
|
||||
// resultBusMsg Redis 结果频道消息格式
|
||||
type resultBusMsg struct {
|
||||
OriginInstanceID string `json:"origin_instance_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt int64 `json:"completed_at"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
// TaskBus 组合 RunnerHub + Redis Pub/Sub,实现跨实例任务分发和结果路由
|
||||
type TaskBus struct {
|
||||
hub *RunnerHub
|
||||
registry *RunnerRegistry
|
||||
rdb *redis.Client
|
||||
instanceID string
|
||||
taskChan string
|
||||
resultChan string
|
||||
logger *zap.Logger
|
||||
|
||||
remoteTasks sync.Map // taskID → originInstanceID(从其他实例接收的任务)
|
||||
|
||||
sub *redis.PubSub
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewTaskBus 创建 TaskBus 实例
|
||||
func NewTaskBus(hub *RunnerHub, rdb *redis.Client, registry *RunnerRegistry, instanceID, taskChan, resultChan string, logger *zap.Logger) *TaskBus {
|
||||
return &TaskBus{
|
||||
hub: hub,
|
||||
registry: registry,
|
||||
rdb: rdb,
|
||||
instanceID: instanceID,
|
||||
taskChan: taskChan,
|
||||
resultChan: resultChan,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动 Redis 订阅
|
||||
func (b *TaskBus) Start() error {
|
||||
if b.rdb == nil {
|
||||
b.logger.Info("TaskBus: no Redis client, running in standalone mode")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
b.cancel = cancel
|
||||
|
||||
sub := b.rdb.Subscribe(ctx, b.taskChan, b.resultChan)
|
||||
b.sub = sub
|
||||
|
||||
if _, err := sub.Receive(ctx); err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
go b.subscribeLoop(ctx, sub)
|
||||
|
||||
b.logger.Info("TaskBus started",
|
||||
zap.String("instance_id", b.instanceID),
|
||||
zap.String("task_channel", b.taskChan),
|
||||
zap.String("result_channel", b.resultChan),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 关闭 TaskBus
|
||||
func (b *TaskBus) Stop() error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.closed {
|
||||
return nil
|
||||
}
|
||||
b.closed = true
|
||||
|
||||
if b.cancel != nil {
|
||||
b.cancel()
|
||||
}
|
||||
if b.sub != nil {
|
||||
b.sub.Close()
|
||||
}
|
||||
if b.registry != nil {
|
||||
b.registry.Stop()
|
||||
}
|
||||
|
||||
b.logger.Info("TaskBus stopped", zap.String("instance_id", b.instanceID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstanceID 返回实例 ID
|
||||
func (b *TaskBus) InstanceID() string {
|
||||
return b.instanceID
|
||||
}
|
||||
|
||||
// DispatchTask 实现 TaskDispatcher 接口
|
||||
// 本地优先 → Redis 注册中心查找 → 远程发布
|
||||
func (b *TaskBus) DispatchTask(task *runner.Task) error {
|
||||
taskTypeStr := task.Type.String()
|
||||
|
||||
// 1. 尝试本地查找
|
||||
conn, err := b.hub.GetAvailableRunner(task.Type)
|
||||
if err == nil && conn != nil {
|
||||
return b.hub.SendTask(conn.ID, task)
|
||||
}
|
||||
|
||||
// 2. 查询 Redis 注册中心
|
||||
if b.registry != nil {
|
||||
runnerID, instanceID, err := b.registry.GetAvailableRunner(taskTypeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no available runner: %w", err)
|
||||
}
|
||||
|
||||
// 如果 runner 在本实例(可能本地 map 还没更新),直接发送
|
||||
if instanceID == b.instanceID {
|
||||
return b.hub.SendTask(runnerID, task)
|
||||
}
|
||||
|
||||
// 发布到远程实例
|
||||
return b.publishTask(task, runnerID)
|
||||
}
|
||||
|
||||
return fmt.Errorf("no available runner for task type: %s", taskTypeStr)
|
||||
}
|
||||
|
||||
// HandleResult 处理任务结果
|
||||
// 如果任务来自远程实例,转发结果;否则交给 TaskManager
|
||||
func (b *TaskBus) HandleResult(result *runner.TaskResult) {
|
||||
originInstance, ok := b.remoteTasks.Load(result.TaskId)
|
||||
if ok {
|
||||
b.remoteTasks.Delete(result.TaskId)
|
||||
|
||||
// 任务来自远程实例,转发结果
|
||||
if originStr, ok := originInstance.(string); ok && originStr != b.instanceID {
|
||||
b.publishResult(result, originStr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 本地任务,交给 TaskManager
|
||||
if b.hub.taskManager != nil {
|
||||
b.hub.taskManager.HandleResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
// publishTask 发布任务到 Redis 频道
|
||||
func (b *TaskBus) publishTask(task *runner.Task, targetRunnerID string) error {
|
||||
msg := taskBusMsg{
|
||||
OriginInstanceID: b.instanceID,
|
||||
TargetRunnerID: targetRunnerID,
|
||||
TaskID: task.TaskId,
|
||||
TaskType: task.Type.String(),
|
||||
TaskPayload: task.Payload,
|
||||
CreatedAt: task.CreatedAt,
|
||||
TimeoutSeconds: task.TimeoutSeconds,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal task message: %w", err)
|
||||
}
|
||||
|
||||
if err := b.rdb.Publish(context.Background(), b.taskChan, data).Err(); err != nil {
|
||||
return fmt.Errorf("failed to publish task to Redis: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishResult 发布结果到 Redis 频道
|
||||
func (b *TaskBus) publishResult(result *runner.TaskResult, originInstanceID string) error {
|
||||
var data json.RawMessage
|
||||
if len(result.Data) > 0 {
|
||||
data = result.Data
|
||||
} else {
|
||||
data = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
msg := resultBusMsg{
|
||||
OriginInstanceID: originInstanceID,
|
||||
TaskID: result.TaskId,
|
||||
Status: result.Status.String(),
|
||||
CompletedAt: result.CompletedAt,
|
||||
Data: data,
|
||||
ErrorCode: result.ErrorCode,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
b.logger.Error("Failed to marshal result message", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := b.rdb.Publish(context.Background(), b.resultChan, raw).Err(); err != nil {
|
||||
b.logger.Error("Failed to publish result to Redis", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// subscribeLoop 处理 Redis 订阅消息
|
||||
func (b *TaskBus) subscribeLoop(ctx context.Context, sub *redis.PubSub) {
|
||||
ch := sub.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch msg.Channel {
|
||||
case b.taskChan:
|
||||
b.handleRemoteTask(msg)
|
||||
case b.resultChan:
|
||||
b.handleRemoteResult(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleRemoteTask 处理从其他实例发来的任务
|
||||
func (b *TaskBus) handleRemoteTask(msg *redis.Message) {
|
||||
var taskMsg taskBusMsg
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &taskMsg); err != nil {
|
||||
b.logger.Error("TaskBus: failed to unmarshal task message", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤自己发送的消息
|
||||
if taskMsg.OriginInstanceID == b.instanceID {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标 Runner 是否在本实例
|
||||
conn, err := b.hub.GetRunnerByID(taskMsg.TargetRunnerID)
|
||||
if err != nil {
|
||||
b.logger.Warn("TaskBus: target runner not found locally",
|
||||
zap.String("runner_id", taskMsg.TargetRunnerID),
|
||||
zap.String("task_id", taskMsg.TaskID))
|
||||
return
|
||||
}
|
||||
|
||||
// 记录任务的来源实例,用于结果回传
|
||||
b.remoteTasks.Store(taskMsg.TaskID, taskMsg.OriginInstanceID)
|
||||
|
||||
// 构造 Task 并发送
|
||||
task := &runner.Task{
|
||||
TaskId: taskMsg.TaskID,
|
||||
Type: parseTaskType(taskMsg.TaskType),
|
||||
CreatedAt: taskMsg.CreatedAt,
|
||||
TimeoutSeconds: taskMsg.TimeoutSeconds,
|
||||
Payload: taskMsg.TaskPayload,
|
||||
}
|
||||
|
||||
if err := b.hub.SendTask(conn.ID, task); err != nil {
|
||||
b.logger.Error("TaskBus: failed to send remote task to runner",
|
||||
zap.String("runner_id", conn.ID),
|
||||
zap.String("task_id", taskMsg.TaskID),
|
||||
zap.Error(err))
|
||||
b.remoteTasks.Delete(taskMsg.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRemoteResult 处理从其他实例发来的任务结果
|
||||
func (b *TaskBus) handleRemoteResult(msg *redis.Message) {
|
||||
var resultMsg resultBusMsg
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &resultMsg); err != nil {
|
||||
b.logger.Error("TaskBus: failed to unmarshal result message", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤自己发送的消息
|
||||
if resultMsg.OriginInstanceID == b.instanceID {
|
||||
return
|
||||
}
|
||||
|
||||
// 只有 origin 匹配本实例才需要处理
|
||||
// (originInstanceID 标识的是发起任务的实例)
|
||||
// 收到其他实例转发的结果,且 origin 是我们 → 交给 TaskManager
|
||||
// 但这里收到的是其他实例广播的结果,我们只是中继
|
||||
// 实际上 result 是发到 runner:result channel 的,所有实例都会收到
|
||||
// 我们只关心 origin 是本实例的结果
|
||||
if resultMsg.OriginInstanceID != b.instanceID {
|
||||
return
|
||||
}
|
||||
|
||||
// 交给 TaskManager 处理
|
||||
result := &runner.TaskResult{
|
||||
TaskId: resultMsg.TaskID,
|
||||
Status: parseTaskStatus(resultMsg.Status),
|
||||
CompletedAt: resultMsg.CompletedAt,
|
||||
Data: resultMsg.Data,
|
||||
ErrorCode: resultMsg.ErrorCode,
|
||||
ErrorMessage: resultMsg.ErrorMessage,
|
||||
}
|
||||
|
||||
if b.hub.taskManager != nil {
|
||||
b.hub.taskManager.HandleResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
// parseTaskType 将字符串转换为 runner.TaskType 枚举
|
||||
func parseTaskType(s string) runner.TaskType {
|
||||
switch s {
|
||||
case "TASK_TYPE_LOGIN":
|
||||
return runner.TaskType_TASK_TYPE_LOGIN
|
||||
case "TASK_TYPE_GET_SCHEDULE":
|
||||
return runner.TaskType_TASK_TYPE_GET_SCHEDULE
|
||||
case "TASK_TYPE_GET_GRADES":
|
||||
return runner.TaskType_TASK_TYPE_GET_GRADES
|
||||
case "TASK_TYPE_GET_EXAMS":
|
||||
return runner.TaskType_TASK_TYPE_GET_EXAMS
|
||||
case "TASK_TYPE_GET_USER_INFO":
|
||||
return runner.TaskType_TASK_TYPE_GET_USER_INFO
|
||||
default:
|
||||
return runner.TaskType_TASK_TYPE_UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// parseTaskStatus 将字符串转换为 runner.TaskStatus 枚举
|
||||
func parseTaskStatus(s string) runner.TaskStatus {
|
||||
switch s {
|
||||
case "TASK_STATUS_SUCCESS":
|
||||
return runner.TaskStatus_TASK_STATUS_SUCCESS
|
||||
case "TASK_STATUS_FAILED":
|
||||
return runner.TaskStatus_TASK_STATUS_FAILED
|
||||
case "TASK_STATUS_TIMEOUT":
|
||||
return runner.TaskStatus_TASK_STATUS_TIMEOUT
|
||||
case "TASK_STATUS_CANCELLED":
|
||||
return runner.TaskStatus_TASK_STATUS_CANCELLED
|
||||
default:
|
||||
return runner.TaskStatus_TASK_STATUS_UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package runner
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/proto/runner"
|
||||
"with_you/proto/runner"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -26,22 +26,20 @@ type RunnerConnection struct {
|
||||
type RunnerHub struct {
|
||||
runner.UnimplementedRunnerHubServer // 嵌入以实现 gRPC 接口
|
||||
|
||||
mu sync.RWMutex
|
||||
runners map[string]*RunnerConnection
|
||||
taskManager *TaskManager
|
||||
registerChan chan *RunnerConnection
|
||||
unregisterChan chan string
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
runners map[string]*RunnerConnection
|
||||
taskManager *TaskManager
|
||||
bus *TaskBus
|
||||
registry *RunnerRegistry
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewRunnerHub 创建新的 RunnerHub
|
||||
func NewRunnerHub(taskManager *TaskManager, logger *zap.Logger) *RunnerHub {
|
||||
return &RunnerHub{
|
||||
runners: make(map[string]*RunnerConnection),
|
||||
taskManager: taskManager,
|
||||
registerChan: make(chan *RunnerConnection, 100),
|
||||
unregisterChan: make(chan string, 100),
|
||||
logger: logger,
|
||||
runners: make(map[string]*RunnerConnection),
|
||||
taskManager: taskManager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +135,15 @@ func (h *RunnerHub) handleRegister(req *runner.RegisterRequest, stream runner.Ru
|
||||
h.runners[req.RunnerId] = conn
|
||||
h.mu.Unlock()
|
||||
|
||||
// 同步到 Redis 注册中心
|
||||
if h.registry != nil {
|
||||
if err := h.registry.Register(req.RunnerId, req.Version, req.Capabilities); err != nil {
|
||||
h.logger.Error("Failed to register runner in registry",
|
||||
zap.String("runner_id", req.RunnerId),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 发送注册响应
|
||||
if err := conn.send(&runner.StreamMessage{
|
||||
Message: &runner.StreamMessage_RegisterResponse{
|
||||
@@ -161,6 +168,15 @@ func (h *RunnerHub) handleHeartbeat(conn *RunnerConnection, hb *runner.Heartbeat
|
||||
}
|
||||
conn.LastSeen = time.Now()
|
||||
|
||||
// 刷新 Redis 注册中心的 TTL
|
||||
if h.registry != nil {
|
||||
if err := h.registry.RefreshHeartbeat(conn.ID); err != nil {
|
||||
h.logger.Debug("Failed to refresh runner heartbeat in registry",
|
||||
zap.String("runner_id", conn.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := conn.send(&runner.StreamMessage{
|
||||
Message: &runner.StreamMessage_HeartbeatAck{
|
||||
HeartbeatAck: &runner.HeartbeatAck{
|
||||
@@ -179,6 +195,12 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
|
||||
zap.String("task_id", result.TaskId),
|
||||
zap.String("status", result.Status.String()))
|
||||
|
||||
// 如果有 Bus,委托给 Bus 处理(可能需要转发到远程实例)
|
||||
if h.bus != nil {
|
||||
h.bus.HandleResult(result)
|
||||
return
|
||||
}
|
||||
|
||||
if h.taskManager != nil {
|
||||
h.taskManager.HandleResult(result)
|
||||
}
|
||||
@@ -187,12 +209,19 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
|
||||
// unregisterRunner 注销 Runner
|
||||
func (h *RunnerHub) unregisterRunner(runnerID string) {
|
||||
h.mu.Lock()
|
||||
var capabilities map[string]string
|
||||
if conn, ok := h.runners[runnerID]; ok {
|
||||
capabilities = conn.Capabilities
|
||||
close(conn.sendChan)
|
||||
delete(h.runners, runnerID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
// 从 Redis 注册中心移除
|
||||
if h.registry != nil && capabilities != nil {
|
||||
h.registry.Unregister(runnerID, capabilities)
|
||||
}
|
||||
|
||||
h.logger.Info("Runner unregistered", zap.String("runner_id", runnerID))
|
||||
}
|
||||
|
||||
@@ -236,13 +265,13 @@ func (h *RunnerHub) GetAvailableRunner(taskType runner.TaskType) (*RunnerConnect
|
||||
}
|
||||
|
||||
// GetAllRunners 获取所有已连接的 Runner 信息
|
||||
func (h *RunnerHub) GetAllRunners() []map[string]interface{} {
|
||||
func (h *RunnerHub) GetAllRunners() []map[string]any {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(h.runners))
|
||||
result := make([]map[string]any, 0, len(h.runners))
|
||||
for id, conn := range h.runners {
|
||||
result = append(result, map[string]interface{}{
|
||||
result = append(result, map[string]any{
|
||||
"id": id,
|
||||
"version": conn.Version,
|
||||
"capabilities": conn.Capabilities,
|
||||
@@ -257,6 +286,37 @@ func (h *RunnerHub) SetTaskManager(tm *TaskManager) {
|
||||
h.taskManager = tm
|
||||
}
|
||||
|
||||
// SetBus 设置 TaskBus(集群模式)
|
||||
func (h *RunnerHub) SetBus(bus *TaskBus) {
|
||||
h.bus = bus
|
||||
}
|
||||
|
||||
// SetRegistry 设置 RunnerRegistry(集群模式)
|
||||
func (h *RunnerHub) SetRegistry(registry *RunnerRegistry) {
|
||||
h.registry = registry
|
||||
}
|
||||
|
||||
// DispatchTask 实现 TaskDispatcher 接口(standalone 模式)
|
||||
func (h *RunnerHub) DispatchTask(task *runner.Task) error {
|
||||
conn, err := h.GetAvailableRunner(task.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.SendTask(conn.ID, task)
|
||||
}
|
||||
|
||||
// GetRunnerByID 根据 ID 获取 Runner 连接
|
||||
func (h *RunnerHub) GetRunnerByID(runnerID string) (*RunnerConnection, error) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
conn, ok := h.runners[runnerID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("runner not found: %s", runnerID)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// send 向 RunnerConnection 发送消息
|
||||
func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
|
||||
select {
|
||||
@@ -270,8 +330,11 @@ func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
|
||||
// CheckStaleRunners 检查并清理超时的 Runner 连接
|
||||
func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
var toRemove []struct {
|
||||
id string
|
||||
capabilities map[string]string
|
||||
}
|
||||
now := time.Now()
|
||||
for id, conn := range h.runners {
|
||||
if now.Sub(conn.LastSeen) > timeout {
|
||||
@@ -280,6 +343,18 @@ func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
|
||||
zap.Time("last_seen", conn.LastSeen))
|
||||
close(conn.sendChan)
|
||||
delete(h.runners, id)
|
||||
toRemove = append(toRemove, struct {
|
||||
id string
|
||||
capabilities map[string]string
|
||||
}{id: id, capabilities: conn.Capabilities})
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
// 从 Redis 注册中心批量移除
|
||||
if h.registry != nil {
|
||||
for _, r := range toRemove {
|
||||
h.registry.Unregister(r.id, r.capabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
305
internal/grpc/runner/registry.go
Normal file
305
internal/grpc/runner/registry.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RunnerRegistry 分布式 Runner 注册中心
|
||||
// 使用 Redis Hash 存储 Runner 信息,Redis Set 按能力索引
|
||||
type RunnerRegistry struct {
|
||||
rdb *redis.Client
|
||||
instanceID string
|
||||
registryPrefix string
|
||||
capPrefix string
|
||||
onlineTTL time.Duration
|
||||
heartbeatInterval time.Duration
|
||||
localRunners sync.Map // runnerID → map[string]string (capabilities)
|
||||
logger *zap.Logger
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// runnerInfo 存储在 Redis Hash 中的 Runner 信息
|
||||
type runnerInfo struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
Capabilities string `json:"capabilities"` // JSON-encoded map
|
||||
}
|
||||
|
||||
// NewRunnerRegistry 创建 Runner 注册中心
|
||||
func NewRunnerRegistry(rdb *redis.Client, instanceID, registryPrefix, capPrefix string, onlineTTL, heartbeatInterval time.Duration, logger *zap.Logger) *RunnerRegistry {
|
||||
return &RunnerRegistry{
|
||||
rdb: rdb,
|
||||
instanceID: instanceID,
|
||||
registryPrefix: registryPrefix,
|
||||
capPrefix: capPrefix,
|
||||
onlineTTL: onlineTTL,
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册 Runner 到分布式注册中心
|
||||
func (r *RunnerRegistry) Register(runnerID, version string, capabilities map[string]string) error {
|
||||
if r.rdb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
capJSON, _ := json.Marshal(capabilities)
|
||||
key := r.registryPrefix + runnerID
|
||||
|
||||
info := runnerInfo{
|
||||
InstanceID: r.instanceID,
|
||||
Version: version,
|
||||
Capabilities: string(capJSON),
|
||||
}
|
||||
infoJSON, _ := json.Marshal(info)
|
||||
|
||||
pipe := r.rdb.Pipeline()
|
||||
pipe.Set(ctx, key, infoJSON, r.onlineTTL)
|
||||
|
||||
// 为每个能力创建索引
|
||||
taskTypeStr := parseTaskTypes(capabilities)
|
||||
for _, tt := range taskTypeStr {
|
||||
capKey := r.capPrefix + tt
|
||||
pipe.SAdd(ctx, capKey, runnerID)
|
||||
pipe.Expire(ctx, capKey, r.onlineTTL*2)
|
||||
}
|
||||
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return fmt.Errorf("failed to register runner in redis: %w", err)
|
||||
}
|
||||
|
||||
r.localRunners.Store(runnerID, capabilities)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unregister 从分布式注册中心移除 Runner
|
||||
func (r *RunnerRegistry) Unregister(runnerID string, capabilities map[string]string) {
|
||||
if r.rdb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := r.registryPrefix + runnerID
|
||||
pipe := r.rdb.Pipeline()
|
||||
pipe.Del(ctx, key)
|
||||
|
||||
taskTypeStr := parseTaskTypes(capabilities)
|
||||
for _, tt := range taskTypeStr {
|
||||
capKey := r.capPrefix + tt
|
||||
pipe.SRem(ctx, capKey, runnerID)
|
||||
}
|
||||
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
r.logger.Error("Failed to unregister runner from redis",
|
||||
zap.String("runner_id", runnerID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
r.localRunners.Delete(runnerID)
|
||||
}
|
||||
|
||||
// RefreshHeartbeat 刷新 Runner 的 TTL
|
||||
func (r *RunnerRegistry) RefreshHeartbeat(runnerID string) error {
|
||||
if r.rdb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := r.registryPrefix + runnerID
|
||||
return r.rdb.Expire(ctx, key, r.onlineTTL).Err()
|
||||
}
|
||||
|
||||
// GetAvailableRunner 查找支持指定任务类型的 Runner
|
||||
// 返回 runnerID 和所在 instanceID
|
||||
func (r *RunnerRegistry) GetAvailableRunner(taskType string) (runnerID, instanceID string, err error) {
|
||||
if r.rdb == nil {
|
||||
return "", "", fmt.Errorf("registry not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
capKey := r.capPrefix + taskType
|
||||
|
||||
members, err := r.rdb.SMembers(ctx, capKey).Result()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to query runner capabilities: %w", err)
|
||||
}
|
||||
|
||||
if len(members) == 0 {
|
||||
return "", "", fmt.Errorf("no runner available for task type: %s", taskType)
|
||||
}
|
||||
|
||||
// 随机选一个(伪随机,map遍历本身随机性)
|
||||
for _, rid := range members {
|
||||
key := r.registryPrefix + rid
|
||||
val, err := r.rdb.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
continue // 已过期,跳过
|
||||
}
|
||||
|
||||
var info runnerInfo
|
||||
if err := json.Unmarshal(val, &info); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return rid, info.InstanceID, nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("no runner available for task type: %s", taskType)
|
||||
}
|
||||
|
||||
// GetRunnerInstance 获取 Runner 所在的实例 ID
|
||||
func (r *RunnerRegistry) GetRunnerInstance(runnerID string) (string, error) {
|
||||
if r.rdb == nil {
|
||||
return "", fmt.Errorf("registry not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := r.registryPrefix + runnerID
|
||||
val, err := r.rdb.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("runner not found: %s", runnerID)
|
||||
}
|
||||
|
||||
var info runnerInfo
|
||||
if err := json.Unmarshal(val, &info); err != nil {
|
||||
return "", fmt.Errorf("failed to parse runner info: %w", err)
|
||||
}
|
||||
|
||||
return info.InstanceID, nil
|
||||
}
|
||||
|
||||
// GetAllRunners 获取所有注册的 Runner 信息
|
||||
func (r *RunnerRegistry) GetAllRunners() ([]map[string]any, error) {
|
||||
if r.rdb == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pattern := r.registryPrefix + "*"
|
||||
keys, err := r.rdb.Keys(ctx, pattern).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
val, err := r.rdb.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var info runnerInfo
|
||||
if err := json.Unmarshal(val, &info); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var caps map[string]string
|
||||
json.Unmarshal([]byte(info.Capabilities), &caps)
|
||||
|
||||
runnerID := key[len(r.registryPrefix):]
|
||||
result = append(result, map[string]any{
|
||||
"id": runnerID,
|
||||
"instance_id": info.InstanceID,
|
||||
"version": info.Version,
|
||||
"capabilities": caps,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// StartHeartbeat 启动心跳续期
|
||||
func (r *RunnerRegistry) StartHeartbeat() {
|
||||
if r.rdb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.cancel = cancel
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(r.heartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.heartbeat()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *RunnerRegistry) heartbeat() {
|
||||
ctx := context.Background()
|
||||
pipe := r.rdb.Pipeline()
|
||||
count := 0
|
||||
|
||||
r.localRunners.Range(func(key, _ any) bool {
|
||||
runnerID := key.(string)
|
||||
regKey := r.registryPrefix + runnerID
|
||||
pipe.Expire(ctx, regKey, r.onlineTTL)
|
||||
count++
|
||||
return true
|
||||
})
|
||||
|
||||
if count > 0 {
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
r.logger.Error("Failed to heartbeat runner registry", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止心跳并清理本地 Runner 的 Redis 注册
|
||||
func (r *RunnerRegistry) Stop() {
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
|
||||
if r.rdb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.localRunners.Range(func(key, value any) bool {
|
||||
runnerID := key.(string)
|
||||
capabilities, _ := value.(map[string]string)
|
||||
r.Unregister(runnerID, capabilities)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// parseTaskTypes 从 capabilities map 中提取支持的任务类型列表
|
||||
func parseTaskTypes(capabilities map[string]string) []string {
|
||||
var types []string
|
||||
for capKey, capValue := range capabilities {
|
||||
// key = "TASK_TYPE_GET_SCHEDULE", value = "true"
|
||||
if capValue == "true" && isTaskType(capKey) {
|
||||
types = append(types, capKey)
|
||||
continue
|
||||
}
|
||||
// key = "schedule", value = "TASK_TYPE_GET_SCHEDULE"
|
||||
if isTaskType(capValue) {
|
||||
types = append(types, capValue)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// isTaskType 检查字符串是否为有效的 TaskType 枚举值
|
||||
func isTaskType(s string) bool {
|
||||
switch s {
|
||||
case "TASK_TYPE_LOGIN", "TASK_TYPE_GET_SCHEDULE", "TASK_TYPE_GET_GRADES",
|
||||
"TASK_TYPE_GET_EXAMS", "TASK_TYPE_GET_USER_INFO":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
package runner
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/config"
|
||||
"carrot_bbs/proto/runner"
|
||||
"with_you/internal/config"
|
||||
"with_you/proto/runner"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Server gRPC 服务器
|
||||
@@ -23,13 +21,8 @@ type Server struct {
|
||||
grpcSrv *grpc.Server
|
||||
}
|
||||
|
||||
// NewServer 创建新的 gRPC 服务器
|
||||
func NewServer(cfg *config.GRPCConfig, logger *zap.Logger) *Server {
|
||||
// 创建 TaskManager 和 RunnerHub,解决循环依赖
|
||||
taskManager := NewTaskManager(nil, 60*time.Second, logger)
|
||||
hub := NewRunnerHub(taskManager, logger)
|
||||
taskManager.SetHub(hub)
|
||||
|
||||
// NewServerWithDeps 使用外部依赖创建 gRPC 服务器(Wire DI 使用)
|
||||
func NewServerWithDeps(cfg *config.GRPCConfig, logger *zap.Logger, hub *RunnerHub) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
@@ -103,43 +96,3 @@ func (s *Server) Stop() {
|
||||
}
|
||||
}
|
||||
|
||||
// GetHub 获取 RunnerHub
|
||||
func (s *Server) GetHub() *RunnerHub {
|
||||
return s.hub
|
||||
}
|
||||
|
||||
// GetTaskManager 获取 TaskManager
|
||||
func (s *Server) GetTaskManager() *TaskManager {
|
||||
return s.hub.taskManager
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if s.grpcSrv == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.grpcSrv.GracefulStop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
s.logger.Info("gRPC server stopped gracefully")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
s.logger.Warn("gRPC server shutdown timeout, forcing stop")
|
||||
s.grpcSrv.Stop()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientCredentials 获取客户端凭证配置
|
||||
func GetClientCredentials(cfg *config.GRPCConfig) (credentials.TransportCredentials, error) {
|
||||
if cfg.TLSEnabled {
|
||||
return credentials.NewClientTLSFromFile(cfg.TLSCertFile, "")
|
||||
}
|
||||
return insecure.NewCredentials(), nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package runner
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/proto/runner"
|
||||
"with_you/proto/runner"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
@@ -16,6 +16,11 @@ import (
|
||||
// TaskCallback 任务完成回调函数类型
|
||||
type TaskCallback func(result *runner.TaskResult)
|
||||
|
||||
// TaskDispatcher 任务分发接口
|
||||
type TaskDispatcher interface {
|
||||
DispatchTask(task *runner.Task) error
|
||||
}
|
||||
|
||||
// PendingTask 表示一个等待处理的任务
|
||||
type PendingTask struct {
|
||||
Task *runner.Task
|
||||
@@ -25,25 +30,25 @@ type PendingTask struct {
|
||||
|
||||
// TaskManager 管理任务的生命周期
|
||||
type TaskManager struct {
|
||||
mu sync.RWMutex
|
||||
pending map[string]*PendingTask
|
||||
hub *RunnerHub
|
||||
timeout time.Duration
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
pending map[string]*PendingTask
|
||||
dispatcher TaskDispatcher
|
||||
timeout time.Duration
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewTaskManager 创建新的任务管理器
|
||||
func NewTaskManager(hub *RunnerHub, timeout time.Duration, logger *zap.Logger) *TaskManager {
|
||||
func NewTaskManager(dispatcher TaskDispatcher, timeout time.Duration, logger *zap.Logger) *TaskManager {
|
||||
return &TaskManager{
|
||||
pending: make(map[string]*PendingTask),
|
||||
hub: hub,
|
||||
timeout: timeout,
|
||||
logger: logger,
|
||||
pending: make(map[string]*PendingTask),
|
||||
dispatcher: dispatcher,
|
||||
timeout: timeout,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SubmitTask 提交任务并等待结果
|
||||
func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType, payload interface{}, callback TaskCallback) (*runner.Task, error) {
|
||||
func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType, payload any, callback TaskCallback) (*runner.Task, error) {
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal payload: %w", err)
|
||||
@@ -69,17 +74,10 @@ func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType,
|
||||
zap.String("task_id", task.TaskId),
|
||||
zap.String("type", taskType.String()))
|
||||
|
||||
// 获取可用 Runner
|
||||
conn, err := tm.hub.GetAvailableRunner(taskType)
|
||||
if err != nil {
|
||||
// 通过 dispatcher 分发任务
|
||||
if err := tm.dispatcher.DispatchTask(task); err != nil {
|
||||
tm.cleanupTask(task.TaskId)
|
||||
return nil, fmt.Errorf("no available runner: %w", err)
|
||||
}
|
||||
|
||||
// 发送任务
|
||||
if err := tm.hub.SendTask(conn.ID, task); err != nil {
|
||||
tm.cleanupTask(task.TaskId)
|
||||
return nil, fmt.Errorf("failed to send task: %w", err)
|
||||
return nil, fmt.Errorf("failed to dispatch task: %w", err)
|
||||
}
|
||||
|
||||
// 启动超时检查
|
||||
@@ -89,7 +87,7 @@ func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType,
|
||||
}
|
||||
|
||||
// SubmitTaskSync 同步提交任务并等待结果
|
||||
func (tm *TaskManager) SubmitTaskSync(ctx context.Context, taskType runner.TaskType, payload interface{}) (*runner.TaskResult, error) {
|
||||
func (tm *TaskManager) SubmitTaskSync(ctx context.Context, taskType runner.TaskType, payload any) (*runner.TaskResult, error) {
|
||||
resultChan := make(chan *runner.TaskResult, 1)
|
||||
|
||||
task, err := tm.SubmitTask(ctx, taskType, payload, func(result *runner.TaskResult) {
|
||||
@@ -203,9 +201,9 @@ func (tm *TaskManager) GetPendingTaskCount() int {
|
||||
return len(tm.pending)
|
||||
}
|
||||
|
||||
// SetHub 设置 RunnerHub(用于解决循环依赖)
|
||||
func (tm *TaskManager) SetHub(hub *RunnerHub) {
|
||||
tm.hub = hub
|
||||
// SetDispatcher 设置任务分发器(用于解决循环依赖)
|
||||
func (tm *TaskManager) SetDispatcher(dispatcher TaskDispatcher) {
|
||||
tm.dispatcher = dispatcher
|
||||
}
|
||||
|
||||
// CleanupStaleTasks 清理超时的任务
|
||||
|
||||
@@ -3,10 +3,10 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -121,6 +121,58 @@ func (h *AdminCommentHandler) BatchDeleteComments(c *gin.Context) {
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *AdminCommentHandler) ModerateComment(c *gin.Context) {
|
||||
commentID := c.Param("id")
|
||||
if commentID == "" {
|
||||
response.BadRequest(c, "comment id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.AdminModerateCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != string(model.CommentStatusPublished) && req.Status != string(model.CommentStatusRejected) {
|
||||
response.BadRequest(c, "invalid status value, must be 'published' or 'rejected'")
|
||||
return
|
||||
}
|
||||
|
||||
reviewedBy := c.GetString("user_id")
|
||||
|
||||
commentDetail, err := h.adminCommentService.ModerateComment(c.Request.Context(), commentID, model.CommentStatus(req.Status), req.Reason, reviewedBy)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to moderate comment")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, commentDetail)
|
||||
}
|
||||
|
||||
func (h *AdminCommentHandler) BatchModerateComments(c *gin.Context) {
|
||||
var req dto.AdminBatchModerateCommentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != string(model.CommentStatusPublished) && req.Status != string(model.CommentStatusRejected) {
|
||||
response.BadRequest(c, "invalid status value, must be 'published' or 'rejected'")
|
||||
return
|
||||
}
|
||||
|
||||
reviewedBy := c.GetString("user_id")
|
||||
|
||||
result, err := h.adminCommentService.BatchModerateComments(c.Request.Context(), req.IDs, model.CommentStatus(req.Status), reviewedBy)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to batch moderate comments")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetPostComments 获取帖子的评论列表
|
||||
// GET /api/v1/admin/posts/:postId/comments
|
||||
func (h *AdminCommentHandler) GetPostComments(c *gin.Context) {
|
||||
|
||||
@@ -3,8 +3,8 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -36,14 +36,9 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
|
||||
// GetUserActivityTrend 获取用户活跃度趋势
|
||||
// GET /api/v1/admin/dashboard/user-activity
|
||||
func (h *AdminDashboardHandler) GetUserActivityTrend(c *gin.Context) {
|
||||
// 解析天数参数,默认7天
|
||||
// 解析天数参数,默认7天,限制在1-30天
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
|
||||
if days <= 0 {
|
||||
days = 7
|
||||
}
|
||||
if days > 30 {
|
||||
days = 30
|
||||
}
|
||||
days = min(max(days, 1), 30)
|
||||
|
||||
trend, err := h.dashboardService.GetUserActivityTrend(c.Request.Context(), days)
|
||||
if err != nil {
|
||||
@@ -69,14 +64,9 @@ func (h *AdminDashboardHandler) GetContentStats(c *gin.Context) {
|
||||
// GetPendingContent 获取待审核内容
|
||||
// GET /api/v1/admin/dashboard/pending-content
|
||||
func (h *AdminDashboardHandler) GetPendingContent(c *gin.Context) {
|
||||
// 解析limit参数,默认10条
|
||||
// 解析limit参数,默认10条,限制在1-50条
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
limit = min(max(limit, 1), 50)
|
||||
|
||||
content, err := h.dashboardService.GetPendingContent(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,9 +3,9 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
215
internal/handler/admin_log_handler.go
Normal file
215
internal/handler/admin_log_handler.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/query"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminLogHandler 管理后台日志处理器
|
||||
type AdminLogHandler struct {
|
||||
operationLogService service.OperationLogService
|
||||
loginLogService service.LoginLogService
|
||||
dataChangeLogService service.DataChangeLogService
|
||||
}
|
||||
|
||||
// NewAdminLogHandler 创建管理后台日志处理器
|
||||
func NewAdminLogHandler(
|
||||
operationLogService service.OperationLogService,
|
||||
loginLogService service.LoginLogService,
|
||||
dataChangeLogService service.DataChangeLogService,
|
||||
) *AdminLogHandler {
|
||||
return &AdminLogHandler{
|
||||
operationLogService: operationLogService,
|
||||
loginLogService: loginLogService,
|
||||
dataChangeLogService: dataChangeLogService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOperationLogs 获取操作日志列表
|
||||
// GET /api/v1/admin/logs/operations
|
||||
func (h *AdminLogHandler) GetOperationLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
filters := query.LogFilter{
|
||||
UserID: c.Query("user_id"),
|
||||
Operation: c.Query("operation"),
|
||||
TargetType: c.Query("target_type"),
|
||||
TargetID: c.Query("target_id"),
|
||||
Status: c.Query("status"),
|
||||
IP: c.Query("ip"),
|
||||
StartTime: c.Query("start_time"),
|
||||
EndTime: c.Query("end_time"),
|
||||
}
|
||||
|
||||
logs, total, err := h.operationLogService.GetOperationLogs(c.Request.Context(), filters, page, pageSize)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to get operation logs")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"logs": logs,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetLoginLogs 获取登录日志列表
|
||||
// GET /api/v1/admin/logs/logins
|
||||
func (h *AdminLogHandler) GetLoginLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
filters := query.LoginFilter{
|
||||
UserID: c.Query("user_id"),
|
||||
Event: c.Query("event"),
|
||||
Result: c.Query("result"),
|
||||
FailReason: c.Query("fail_reason"),
|
||||
LoginType: c.Query("login_type"),
|
||||
IP: c.Query("ip"),
|
||||
}
|
||||
|
||||
if startTime := c.Query("start_time"); startTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, startTime); err == nil {
|
||||
filters.StartTime = t
|
||||
}
|
||||
}
|
||||
|
||||
if endTime := c.Query("end_time"); endTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, endTime); err == nil {
|
||||
filters.EndTime = t
|
||||
}
|
||||
}
|
||||
|
||||
logs, total, err := h.loginLogService.GetLoginLogs(c.Request.Context(), filters, page, pageSize)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to get login logs")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"logs": logs,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetDataChangeLogs 获取数据变更日志列表
|
||||
// GET /api/v1/admin/logs/data-changes
|
||||
func (h *AdminLogHandler) GetDataChangeLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
filters := query.DataChangeFilter{
|
||||
UserID: c.Query("user_id"),
|
||||
OperatorID: c.Query("operator_id"),
|
||||
ChangeType: c.Query("change_type"),
|
||||
TargetType: c.Query("target_type"),
|
||||
OperatorType: c.Query("operator_type"),
|
||||
IP: c.Query("ip"),
|
||||
StartTime: c.Query("start_time"),
|
||||
EndTime: c.Query("end_time"),
|
||||
}
|
||||
|
||||
logs, total, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), filters, page, pageSize)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to get data change logs")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"logs": logs,
|
||||
"pagination": gin.H{
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetLogStatistics 获取日志统计
|
||||
// GET /api/v1/admin/logs/statistics
|
||||
func (h *AdminLogHandler) GetLogStatistics(c *gin.Context) {
|
||||
startTime := c.DefaultQuery("start_time", "")
|
||||
endTime := c.DefaultQuery("end_time", "")
|
||||
|
||||
operationStats, err := h.operationLogService.GetStatistics(c.Request.Context(), startTime, endTime)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to get operation log statistics")
|
||||
return
|
||||
}
|
||||
|
||||
loginStats, err := h.loginLogService.GetStatistics(c.Request.Context(), startTime, endTime)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to get login log statistics")
|
||||
return
|
||||
}
|
||||
|
||||
dataChangeStats, err := h.dataChangeLogService.GetStatistics(c.Request.Context(), startTime, endTime)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to get data change log statistics")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"operation_logs": operationStats,
|
||||
"login_logs": loginStats,
|
||||
"data_change_logs": dataChangeStats,
|
||||
})
|
||||
}
|
||||
|
||||
// ExportLogs 导出日志
|
||||
// GET /api/v1/admin/logs/export
|
||||
func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
|
||||
logType := c.Query("type")
|
||||
startTime := c.Query("start_time")
|
||||
endTime := c.Query("end_time")
|
||||
|
||||
switch logType {
|
||||
case "operation":
|
||||
logs, _, err := h.operationLogService.GetOperationLogsByTimeRange(c.Request.Context(), startTime, endTime, 1, 10000)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to export operation logs")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"type": "operation",
|
||||
"logs": logs,
|
||||
})
|
||||
case "login":
|
||||
logs, _, err := h.loginLogService.GetLoginLogs(c.Request.Context(), query.LoginFilter{}, 1, 10000)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to export login logs")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"type": "login",
|
||||
"log": logs,
|
||||
})
|
||||
case "data_change":
|
||||
logs, _, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), query.DataChangeFilter{}, 1, 10000)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to export data change logs")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"type": "data_change",
|
||||
"log": logs,
|
||||
})
|
||||
default:
|
||||
response.Error(c, 400, "invalid log type")
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
129
internal/handler/admin_profile_audit_handler.go
Normal file
129
internal/handler/admin_profile_audit_handler.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AdminProfileAuditHandler struct {
|
||||
profileAuditService service.UserProfileAuditService
|
||||
}
|
||||
|
||||
func NewAdminProfileAuditHandler(profileAuditService service.UserProfileAuditService) *AdminProfileAuditHandler {
|
||||
return &AdminProfileAuditHandler{
|
||||
profileAuditService: profileAuditService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminProfileAuditHandler) GetPendingList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
contentType := c.Query("content_type")
|
||||
var ct model.UserProfileContentType
|
||||
switch contentType {
|
||||
case "avatar":
|
||||
ct = model.UserProfileContentTypeAvatar
|
||||
case "cover":
|
||||
ct = model.UserProfileContentTypeCover
|
||||
case "bio":
|
||||
ct = model.UserProfileContentTypeBio
|
||||
default:
|
||||
ct = ""
|
||||
}
|
||||
|
||||
audits, total, err := h.profileAuditService.GetPendingList(c.Request.Context(), ct, page, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get pending profile audits")
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(audits))
|
||||
for _, audit := range audits {
|
||||
item := map[string]any{
|
||||
"id": audit.ID,
|
||||
"user_id": audit.UserID,
|
||||
"content_type": audit.ContentType,
|
||||
"content": audit.Content,
|
||||
"status": audit.Status,
|
||||
"created_at": audit.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
if audit.User != nil {
|
||||
item["user"] = map[string]any{
|
||||
"id": audit.User.ID,
|
||||
"username": audit.User.Username,
|
||||
"nickname": audit.User.Nickname,
|
||||
"avatar": audit.User.Avatar,
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
response.Paginated(c, items, total, page, pageSize)
|
||||
}
|
||||
|
||||
func (h *AdminProfileAuditHandler) ModerateAudit(c *gin.Context) {
|
||||
auditID := c.Param("id")
|
||||
if auditID == "" {
|
||||
response.BadRequest(c, "audit id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != string(model.UserProfileAuditStatusApproved) && req.Status != string(model.UserProfileAuditStatusRejected) {
|
||||
response.BadRequest(c, "invalid status, must be 'approved' or 'rejected'")
|
||||
return
|
||||
}
|
||||
|
||||
reviewedBy := c.GetString("user_id")
|
||||
if err := h.profileAuditService.ModerateAudit(c.Request.Context(), auditID, model.UserProfileAuditStatus(req.Status), req.Reason, reviewedBy); err != nil {
|
||||
response.HandleError(c, err, "failed to moderate profile audit")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
func (h *AdminProfileAuditHandler) BatchModerate(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
Status string `json:"status" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != string(model.UserProfileAuditStatusApproved) && req.Status != string(model.UserProfileAuditStatusRejected) {
|
||||
response.BadRequest(c, "invalid status, must be 'approved' or 'rejected'")
|
||||
return
|
||||
}
|
||||
|
||||
reviewedBy := c.GetString("user_id")
|
||||
if err := h.profileAuditService.BatchModerateAudits(c.Request.Context(), req.IDs, model.UserProfileAuditStatus(req.Status), req.Reason, reviewedBy); err != nil {
|
||||
response.InternalServerError(c, "failed to batch moderate profile audits")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, map[string]any{"success": true})
|
||||
}
|
||||
139
internal/handler/admin_report_handler.go
Normal file
139
internal/handler/admin_report_handler.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/query"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminReportHandler 管理端举报处理器
|
||||
type AdminReportHandler struct {
|
||||
adminReportService service.AdminReportService
|
||||
}
|
||||
|
||||
// NewAdminReportHandler 创建管理端举报处理器
|
||||
func NewAdminReportHandler(adminReportService service.AdminReportService) *AdminReportHandler {
|
||||
return &AdminReportHandler{
|
||||
adminReportService: adminReportService,
|
||||
}
|
||||
}
|
||||
|
||||
// List 获取举报列表
|
||||
func (h *AdminReportHandler) List(c *gin.Context) {
|
||||
// 解析查询参数
|
||||
var query query.AdminReportListQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.BadRequest(c, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认分页
|
||||
if query.Page == 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize == 0 {
|
||||
query.PageSize = 20
|
||||
}
|
||||
|
||||
// 查询列表
|
||||
reports, total, err := h.adminReportService.GetReportList(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to get report list", zap.Error(err))
|
||||
response.InternalServerError(c, "获取举报列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.Paginated(c, reports, total, query.Page, query.PageSize)
|
||||
}
|
||||
|
||||
// Detail 获取举报详情
|
||||
func (h *AdminReportHandler) Detail(c *gin.Context) {
|
||||
// 获取举报ID
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, "举报ID不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 查询详情
|
||||
report, err := h.adminReportService.GetReportDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to get report detail", zap.Error(err), zap.String("id", id))
|
||||
response.NotFound(c, "举报不存在")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, report)
|
||||
}
|
||||
|
||||
// Handle 处理举报
|
||||
func (h *AdminReportHandler) Handle(c *gin.Context) {
|
||||
// 获取处理人ID
|
||||
handledBy := c.GetString("user_id")
|
||||
if handledBy == "" {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取举报ID
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, "举报ID不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
var req dto.HandleReportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 处理举报
|
||||
err := h.adminReportService.HandleReport(c.Request.Context(), id, req.Action, req.Result, handledBy)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to handle report",
|
||||
zap.Error(err),
|
||||
zap.String("id", id),
|
||||
zap.String("action", req.Action),
|
||||
)
|
||||
response.Error(c, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "处理成功"})
|
||||
}
|
||||
|
||||
// BatchHandle 批量处理举报
|
||||
func (h *AdminReportHandler) BatchHandle(c *gin.Context) {
|
||||
// 获取处理人ID
|
||||
handledBy := c.GetString("user_id")
|
||||
if handledBy == "" {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
var req dto.BatchHandleReportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 批量处理
|
||||
result, err := h.adminReportService.BatchHandle(c.Request.Context(), req.IDs, req.Action, req.Result, handledBy)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to batch handle reports",
|
||||
zap.Error(err),
|
||||
zap.Int("count", len(req.IDs)),
|
||||
)
|
||||
response.Error(c, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
// AdminUserHandler 管理端用户处理器
|
||||
type AdminUserHandler struct {
|
||||
adminUserService service.AdminUserService
|
||||
pushService service.PushService
|
||||
}
|
||||
|
||||
// NewAdminUserHandler 创建管理端用户处理器
|
||||
func NewAdminUserHandler(adminUserService service.AdminUserService) *AdminUserHandler {
|
||||
func NewAdminUserHandler(adminUserService service.AdminUserService, pushService service.PushService) *AdminUserHandler {
|
||||
return &AdminUserHandler{
|
||||
adminUserService: adminUserService,
|
||||
pushService: pushService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,3 +105,22 @@ func (h *AdminUserHandler) UpdateUserStatus(c *gin.Context) {
|
||||
|
||||
response.Success(c, userDetail)
|
||||
}
|
||||
|
||||
// GetUserDevices 获取用户设备列表(含 registration_id)
|
||||
// GET /api/v1/admin/users/:id/devices
|
||||
// 供后台查询目标用户各设备的 JPush RegistrationID(push_token),用于排查推送问题或手动推送测试。
|
||||
func (h *AdminUserHandler) GetUserDevices(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" {
|
||||
response.BadRequest(c, "user id is required")
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get user devices")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.DeviceTokensToAdminResponse(devices))
|
||||
}
|
||||
|
||||
187
internal/handler/admin_verification_handler.go
Normal file
187
internal/handler/admin_verification_handler.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AdminVerificationHandler struct {
|
||||
adminVerificationService service.AdminVerificationService
|
||||
userService service.UserService
|
||||
}
|
||||
|
||||
func NewAdminVerificationHandler(
|
||||
adminVerificationService service.AdminVerificationService,
|
||||
userService service.UserService,
|
||||
) *AdminVerificationHandler {
|
||||
return &AdminVerificationHandler{
|
||||
adminVerificationService: adminVerificationService,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminVerificationHandler) ListVerifications(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
status := c.Query("status")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
if status != "" && status != string(model.VerificationStatusPending) &&
|
||||
status != string(model.VerificationStatusApproved) &&
|
||||
status != string(model.VerificationStatusRejected) {
|
||||
response.BadRequest(c, "无效的状态值")
|
||||
return
|
||||
}
|
||||
|
||||
records, total, err := h.adminVerificationService.ListVerifications(c.Request.Context(), page, pageSize, status, keyword)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "获取认证列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*dto.AdminVerificationListResponse, len(records))
|
||||
for i, record := range records {
|
||||
responses[i] = h.convertToAdminListResponse(c.Request.Context(), record)
|
||||
}
|
||||
|
||||
response.Paginated(c, responses, total, page, pageSize)
|
||||
}
|
||||
|
||||
func (h *AdminVerificationHandler) GetVerificationDetail(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, "认证ID不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.adminVerificationService.GetVerificationDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "认证记录不存在")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, h.convertToAdminDetailResponse(c.Request.Context(), record))
|
||||
}
|
||||
|
||||
func (h *AdminVerificationHandler) ReviewVerification(c *gin.Context) {
|
||||
reviewerID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, "认证ID不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.AdminReviewVerificationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "请求参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !req.Approve && req.RejectReason == "" {
|
||||
response.BadRequest(c, "拒绝时必须填写拒绝原因")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.adminVerificationService.ReviewVerification(
|
||||
c.Request.Context(),
|
||||
reviewerID.(string),
|
||||
id,
|
||||
req.Approve,
|
||||
req.RejectReason,
|
||||
)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "审核失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, h.convertToAdminDetailResponse(c.Request.Context(), record))
|
||||
}
|
||||
|
||||
func (h *AdminVerificationHandler) convertToAdminListResponse(ctx context.Context, record *model.VerificationRecord) *dto.AdminVerificationListResponse {
|
||||
resp := &dto.AdminVerificationListResponse{
|
||||
ID: record.ID,
|
||||
UserID: record.UserID,
|
||||
Identity: string(record.Identity),
|
||||
Status: string(record.Status),
|
||||
RealName: record.RealName,
|
||||
StudentID: record.StudentID,
|
||||
EmployeeID: record.EmployeeID,
|
||||
Department: record.Department,
|
||||
CreatedAt: dto.FormatTime(record.CreatedAt),
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserByID(ctx, record.UserID)
|
||||
if err == nil && user != nil {
|
||||
resp.Username = user.Username
|
||||
resp.Nickname = user.Nickname
|
||||
resp.Avatar = user.Avatar
|
||||
}
|
||||
|
||||
if record.ReviewedAt != nil {
|
||||
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
|
||||
if record.ReviewedBy != nil {
|
||||
reviewer, err := h.userService.GetUserByID(ctx, *record.ReviewedBy)
|
||||
if err == nil && reviewer != nil {
|
||||
resp.ReviewerName = reviewer.Nickname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *AdminVerificationHandler) convertToAdminDetailResponse(ctx context.Context, record *model.VerificationRecord) *dto.AdminVerificationDetailResponse {
|
||||
resp := &dto.AdminVerificationDetailResponse{
|
||||
ID: record.ID,
|
||||
UserID: record.UserID,
|
||||
Identity: string(record.Identity),
|
||||
Status: string(record.Status),
|
||||
RealName: record.RealName,
|
||||
StudentID: record.StudentID,
|
||||
EmployeeID: record.EmployeeID,
|
||||
Department: record.Department,
|
||||
ProofMaterials: record.ProofMaterials,
|
||||
CreatedAt: dto.FormatTime(record.CreatedAt),
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserByID(ctx, record.UserID)
|
||||
if err == nil && user != nil {
|
||||
resp.Username = user.Username
|
||||
resp.Nickname = user.Nickname
|
||||
resp.Avatar = user.Avatar
|
||||
}
|
||||
|
||||
if record.ReviewedAt != nil {
|
||||
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
|
||||
}
|
||||
if record.ReviewedBy != nil {
|
||||
resp.ReviewedBy = *record.ReviewedBy
|
||||
if reviewer, err := h.userService.GetUserByID(ctx, *record.ReviewedBy); err == nil && reviewer != nil {
|
||||
resp.ReviewerName = reviewer.Nickname
|
||||
}
|
||||
}
|
||||
if record.RejectReason != "" {
|
||||
resp.RejectReason = record.RejectReason
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
51
internal/handler/call_handler.go
Normal file
51
internal/handler/call_handler.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// CallHandler 通话记录处理器
|
||||
type CallHandler struct {
|
||||
callService service.CallService
|
||||
}
|
||||
|
||||
// NewCallHandler 创建通话处理器
|
||||
func NewCallHandler(callService service.CallService) *CallHandler {
|
||||
return &CallHandler{callService: callService}
|
||||
}
|
||||
|
||||
// GetCallHistory 获取通话记录
|
||||
// GET /api/v1/calls/history?page=1&page_size=20
|
||||
func (h *CallHandler) GetCallHistory(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 50 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
calls, total, err := h.callService.GetCallHistory(c.Request.Context(), userID.(string), page, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get call history")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"items": calls,
|
||||
"total": total,
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
122
internal/handler/channel_handler.go
Normal file
122
internal/handler/channel_handler.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ChannelHandler struct {
|
||||
channelService service.ChannelService
|
||||
}
|
||||
|
||||
func NewChannelHandler(channelService service.ChannelService) *ChannelHandler {
|
||||
return &ChannelHandler{channelService: channelService}
|
||||
}
|
||||
|
||||
type channelResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func toChannelResponse(c *model.Channel) *channelResponse {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return &channelResponse{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Slug: c.Slug,
|
||||
Description: c.Description,
|
||||
SortOrder: c.SortOrder,
|
||||
IsActive: c.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
// ListPublic 公开频道列表(仅启用)
|
||||
func (h *ChannelHandler) ListPublic(c *gin.Context) {
|
||||
items, err := h.channelService.ListActive()
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get channels")
|
||||
return
|
||||
}
|
||||
result := make([]*channelResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, toChannelResponse(item))
|
||||
}
|
||||
response.Success(c, gin.H{"list": result})
|
||||
}
|
||||
|
||||
// AdminList 管理端频道列表(全部)
|
||||
func (h *ChannelHandler) AdminList(c *gin.Context) {
|
||||
items, err := h.channelService.ListAll()
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get channels")
|
||||
return
|
||||
}
|
||||
result := make([]*channelResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, toChannelResponse(item))
|
||||
}
|
||||
response.Success(c, gin.H{"list": result})
|
||||
}
|
||||
|
||||
type adminChannelUpsertRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Slug string `json:"slug" binding:"required,max=100"`
|
||||
Description string `json:"description" binding:"max=255"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) AdminCreate(c *gin.Context) {
|
||||
var req adminChannelUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
isActive := true
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
item, err := h.channelService.Create(req.Name, req.Slug, req.Description, req.SortOrder, isActive)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to create channel")
|
||||
return
|
||||
}
|
||||
response.Success(c, toChannelResponse(item))
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) AdminUpdate(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req adminChannelUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
isActive := true
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
item, err := h.channelService.Update(id, req.Name, req.Slug, req.Description, req.SortOrder, isActive)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to update channel")
|
||||
return
|
||||
}
|
||||
response.Success(c, toChannelResponse(item))
|
||||
}
|
||||
|
||||
func (h *ChannelHandler) AdminDelete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.channelService.Delete(id); err != nil {
|
||||
response.InternalServerError(c, "failed to delete channel")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
@@ -2,23 +2,24 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/cursor"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// CommentHandler 评论处理器
|
||||
type CommentHandler struct {
|
||||
commentService *service.CommentService
|
||||
commentService service.CommentService
|
||||
}
|
||||
|
||||
// NewCommentHandler 创建评论处理器
|
||||
func NewCommentHandler(commentService *service.CommentService) *CommentHandler {
|
||||
func NewCommentHandler(commentService service.CommentService) *CommentHandler {
|
||||
return &CommentHandler{
|
||||
commentService: commentService,
|
||||
}
|
||||
@@ -33,10 +34,11 @@ func (h *CommentHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
PostID string `json:"post_id" binding:"required"`
|
||||
Content string `json:"content"` // 内容可选,允许只发图片
|
||||
ParentID *string `json:"parent_id"`
|
||||
Images []string `json:"images"` // 图片URL列表
|
||||
PostID string `json:"post_id" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
var req CreateRequest
|
||||
@@ -45,12 +47,18 @@ func (h *CommentHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证:评论必须有内容或图片
|
||||
if req.Content == "" && len(req.Images) == 0 {
|
||||
// 验证:评论必须有内容或图片或segments
|
||||
if req.Content == "" && len(req.Images) == 0 && len(req.Segments) == 0 {
|
||||
response.BadRequest(c, "评论内容或图片不能同时为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有content但有segments,从segments提取文本
|
||||
content := req.Content
|
||||
if content == "" && len(req.Segments) > 0 {
|
||||
content = dto.ExtractTextContent(req.Segments)
|
||||
}
|
||||
|
||||
// 将图片列表转换为JSON字符串
|
||||
var imagesJSON string
|
||||
if len(req.Images) > 0 {
|
||||
@@ -58,13 +66,8 @@ func (h *CommentHandler) Create(c *gin.Context) {
|
||||
imagesJSON = string(imagesBytes)
|
||||
}
|
||||
|
||||
comment, err := h.commentService.Create(c.Request.Context(), req.PostID, userID, req.Content, req.ParentID, imagesJSON, req.Images)
|
||||
comment, err := h.commentService.Create(c.Request.Context(), req.PostID, userID, content, req.Segments, req.ParentID, imagesJSON, req.Images)
|
||||
if err != nil {
|
||||
var moderationErr *service.CommentModerationRejectedError
|
||||
if errors.As(err, &moderationErr) {
|
||||
response.BadRequest(c, moderationErr.UserMessage())
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to create comment")
|
||||
return
|
||||
}
|
||||
@@ -175,9 +178,9 @@ func (h *CommentHandler) Update(c *gin.Context) {
|
||||
|
||||
comment.Content = req.Content
|
||||
|
||||
err = h.commentService.Update(c.Request.Context(), comment)
|
||||
err = h.commentService.Update(c.Request.Context(), userID, comment)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to update comment")
|
||||
response.HandleError(c, err, "failed to update comment")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -205,9 +208,9 @@ func (h *CommentHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err = h.commentService.Delete(c.Request.Context(), id)
|
||||
err = h.commentService.Delete(c.Request.Context(), userID, id)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to delete comment")
|
||||
response.HandleError(c, err, "failed to delete comment")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -251,3 +254,70 @@ func (h *CommentHandler) Unlike(c *gin.Context) {
|
||||
|
||||
response.SuccessWithMessage(c, "unliked", nil)
|
||||
}
|
||||
|
||||
// GetByPostIDByCursor 游标分页获取帖子评论
|
||||
func (h *CommentHandler) GetByPostIDByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
postID := c.Param("id")
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.commentService.GetCommentsByCursor(c.Request.Context(), postID, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get comments")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构,检查每个评论的点赞状态
|
||||
commentResponses := dto.ConvertCommentsToResponseWithUser(result.Items, userID, h.commentService)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.CommentCursorPageResponse{
|
||||
Items: commentResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetRepliesByRootIDByCursor 游标分页获取根评论的回复
|
||||
func (h *CommentHandler) GetRepliesByRootIDByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
rootID := c.Param("id")
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.commentService.GetRepliesByCursor(c.Request.Context(), rootID, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get replies")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构,检查每个回复的点赞状态
|
||||
replyResponses := dto.ConvertCommentsToResponseWithUser(result.Items, userID, h.commentService)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.CommentCursorPageResponse{
|
||||
Items: replyResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// parseCursorRequest 解析游标分页请求参数
|
||||
func (h *CommentHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
||||
}
|
||||
|
||||
83
internal/handler/empty_classroom_handler.go
Normal file
83
internal/handler/empty_classroom_handler.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
type EmptyClassroomHandler struct {
|
||||
classroomSyncService service.EmptyClassroomSyncService
|
||||
}
|
||||
|
||||
func NewEmptyClassroomHandler(
|
||||
classroomSyncService service.EmptyClassroomSyncService,
|
||||
) *EmptyClassroomHandler {
|
||||
return &EmptyClassroomHandler{
|
||||
classroomSyncService: classroomSyncService,
|
||||
}
|
||||
}
|
||||
|
||||
type syncEmptyClassroomsRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Semester string `json:"semester"`
|
||||
WeekStart string `json:"week_start"`
|
||||
WeekEnd string `json:"week_end"`
|
||||
CampusCode string `json:"campus_code"`
|
||||
}
|
||||
|
||||
func (h *EmptyClassroomHandler) SyncEmptyClassrooms(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req syncEmptyClassroomsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.classroomSyncService.SyncUserEmptyClassrooms(c.Request.Context(), userID, &service.SyncEmptyClassroomsRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Semester: req.Semester,
|
||||
WeekStart: req.WeekStart,
|
||||
WeekEnd: req.WeekEnd,
|
||||
CampusCode: req.CampusCode,
|
||||
})
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to sync empty classrooms")
|
||||
return
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
response.Success(c, result)
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, result.Message, result)
|
||||
}
|
||||
|
||||
func (h *EmptyClassroomHandler) ListEmptyClassrooms(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
semester := c.Query("semester")
|
||||
if semester == "" {
|
||||
response.BadRequest(c, "semester is required")
|
||||
return
|
||||
}
|
||||
|
||||
classrooms, err := h.classroomSyncService.ListClassrooms(userID, semester)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to list empty classrooms")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"classrooms": classrooms})
|
||||
}
|
||||
75
internal/handler/exam_handler.go
Normal file
75
internal/handler/exam_handler.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
type ExamHandler struct {
|
||||
examSyncService service.ExamSyncService
|
||||
}
|
||||
|
||||
func NewExamHandler(examSyncService service.ExamSyncService) *ExamHandler {
|
||||
return &ExamHandler{
|
||||
examSyncService: examSyncService,
|
||||
}
|
||||
}
|
||||
|
||||
type syncExamsRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Semester string `json:"semester"`
|
||||
}
|
||||
|
||||
func (h *ExamHandler) SyncExams(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req syncExamsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.examSyncService.SyncUserExams(c.Request.Context(), userID, &service.SyncExamsRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Semester: req.Semester,
|
||||
})
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to sync exams")
|
||||
return
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
response.Success(c, result)
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, result.Message, result)
|
||||
}
|
||||
|
||||
func (h *ExamHandler) ListExams(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
semester := c.Query("semester")
|
||||
if semester == "" {
|
||||
response.BadRequest(c, "semester is required")
|
||||
return
|
||||
}
|
||||
|
||||
exams, err := h.examSyncService.ListExams(userID, semester)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to list exams")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"exams": exams})
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/config"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/gorse"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
gorseio "github.com/gorse-io/gorse-go"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GorseHandler Gorse推荐处理器
|
||||
type GorseHandler struct {
|
||||
importPassword string
|
||||
gorseConfig config.GorseConfig
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewGorseHandler 创建Gorse处理器
|
||||
func NewGorseHandler(cfg config.GorseConfig, db *gorm.DB) *GorseHandler {
|
||||
return &GorseHandler{
|
||||
importPassword: cfg.ImportPassword,
|
||||
gorseConfig: cfg,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ImportRequest 导入请求
|
||||
type ImportRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// ImportData 导入数据到Gorse
|
||||
// POST /api/v1/gorse/import
|
||||
func (h *GorseHandler) ImportData(c *gin.Context) {
|
||||
// 验证密码
|
||||
if h.importPassword == "" {
|
||||
response.BadRequest(c, "Gorse import is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
var req ImportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password != h.importPassword {
|
||||
response.Unauthorized(c, "invalid password")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
stats, err := h.importAllData(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] gorse import failed: %v", err)
|
||||
response.InternalServerError(c, "gorse import failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"message": "import completed",
|
||||
"status": "done",
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// GetStatus 获取Gorse状态
|
||||
// GET /api/v1/gorse/status
|
||||
func (h *GorseHandler) GetStatus(c *gin.Context) {
|
||||
// 返回Gorse连接状态和配置信息
|
||||
hasPassword := h.importPassword != ""
|
||||
response.Success(c, gin.H{
|
||||
"enabled": h.gorseConfig.Enabled,
|
||||
"has_password": hasPassword,
|
||||
"address": h.gorseConfig.Address,
|
||||
"api_key": strings.Repeat("*", 8), // 不返回实际APIKey
|
||||
})
|
||||
}
|
||||
|
||||
func (h *GorseHandler) importAllData(ctx context.Context) (gin.H, error) {
|
||||
gorseClient := gorseio.NewGorseClient(h.gorseConfig.Address, h.gorseConfig.APIKey)
|
||||
gorse.InitEmbeddingWithConfig(h.gorseConfig.EmbeddingAPIKey, h.gorseConfig.EmbeddingURL, h.gorseConfig.EmbeddingModel)
|
||||
|
||||
stats := gin.H{
|
||||
"items": 0,
|
||||
"users": 0,
|
||||
"likes": 0,
|
||||
"favorites": 0,
|
||||
"comments": 0,
|
||||
}
|
||||
|
||||
// 导入帖子
|
||||
var posts []model.Post
|
||||
if err := h.db.Find(&posts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, post := range posts {
|
||||
embedding, err := gorse.GetEmbedding(strings.TrimSpace(post.Title + " " + post.Content))
|
||||
if err != nil {
|
||||
log.Printf("[WARN] get embedding failed for post %s: %v", post.ID, err)
|
||||
embedding = make([]float64, 1024)
|
||||
}
|
||||
_, err = gorseClient.InsertItem(ctx, gorseio.Item{
|
||||
ItemId: post.ID,
|
||||
IsHidden: post.DeletedAt.Valid,
|
||||
Categories: buildPostCategories(&post),
|
||||
Comment: post.Title,
|
||||
Timestamp: post.CreatedAt.UTC().Truncate(time.Second),
|
||||
Labels: map[string]any{
|
||||
"embedding": embedding,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import item failed (%s): %v", post.ID, err)
|
||||
continue
|
||||
}
|
||||
stats["items"] = stats["items"].(int) + 1
|
||||
}
|
||||
|
||||
// 导入用户
|
||||
var users []model.User
|
||||
if err := h.db.Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users {
|
||||
_, err := gorseClient.InsertUser(ctx, gorseio.User{
|
||||
UserId: user.ID,
|
||||
Labels: map[string]any{
|
||||
"posts_count": user.PostsCount,
|
||||
"followers_count": user.FollowersCount,
|
||||
"following_count": user.FollowingCount,
|
||||
},
|
||||
Comment: user.Nickname,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import user failed (%s): %v", user.ID, err)
|
||||
continue
|
||||
}
|
||||
stats["users"] = stats["users"].(int) + 1
|
||||
}
|
||||
|
||||
// 导入点赞
|
||||
var likes []model.PostLike
|
||||
if err := h.db.Find(&likes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, like := range likes {
|
||||
_, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{
|
||||
FeedbackType: string(gorse.FeedbackTypeLike),
|
||||
UserId: like.UserID,
|
||||
ItemId: like.PostID,
|
||||
Timestamp: like.CreatedAt.UTC().Truncate(time.Second),
|
||||
}})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import like failed (%s/%s): %v", like.UserID, like.PostID, err)
|
||||
continue
|
||||
}
|
||||
stats["likes"] = stats["likes"].(int) + 1
|
||||
}
|
||||
|
||||
// 导入收藏
|
||||
var favorites []model.Favorite
|
||||
if err := h.db.Find(&favorites).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, fav := range favorites {
|
||||
_, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{
|
||||
FeedbackType: string(gorse.FeedbackTypeStar),
|
||||
UserId: fav.UserID,
|
||||
ItemId: fav.PostID,
|
||||
Timestamp: fav.CreatedAt.UTC().Truncate(time.Second),
|
||||
}})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import favorite failed (%s/%s): %v", fav.UserID, fav.PostID, err)
|
||||
continue
|
||||
}
|
||||
stats["favorites"] = stats["favorites"].(int) + 1
|
||||
}
|
||||
|
||||
// 导入评论(按用户-帖子去重)
|
||||
var comments []model.Comment
|
||||
if err := h.db.Where("status = ?", model.CommentStatusPublished).Find(&comments).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for _, cm := range comments {
|
||||
key := cm.UserID + ":" + cm.PostID
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
_, err := gorseClient.InsertFeedback(ctx, []gorseio.Feedback{{
|
||||
FeedbackType: string(gorse.FeedbackTypeComment),
|
||||
UserId: cm.UserID,
|
||||
ItemId: cm.PostID,
|
||||
Timestamp: cm.CreatedAt.UTC().Truncate(time.Second),
|
||||
}})
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import comment failed (%s/%s): %v", cm.UserID, cm.PostID, err)
|
||||
continue
|
||||
}
|
||||
stats["comments"] = stats["comments"].(int) + 1
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func buildPostCategories(post *model.Post) []string {
|
||||
var categories []string
|
||||
if post.ViewsCount > 1000 {
|
||||
categories = append(categories, "hot_high")
|
||||
} else if post.ViewsCount > 100 {
|
||||
categories = append(categories, "hot_medium")
|
||||
}
|
||||
if post.LikesCount > 100 {
|
||||
categories = append(categories, "likes_100+")
|
||||
} else if post.LikesCount > 10 {
|
||||
categories = append(categories, "likes_10+")
|
||||
}
|
||||
age := time.Since(post.CreatedAt)
|
||||
if age < 24*time.Hour {
|
||||
categories = append(categories, "today")
|
||||
} else if age < 7*24*time.Hour {
|
||||
categories = append(categories, "this_week")
|
||||
}
|
||||
return categories
|
||||
}
|
||||
73
internal/handler/grade_handler.go
Normal file
73
internal/handler/grade_handler.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
type GradeHandler struct {
|
||||
gradeSyncService service.GradeSyncService
|
||||
}
|
||||
|
||||
func NewGradeHandler(gradeSyncService service.GradeSyncService) *GradeHandler {
|
||||
return &GradeHandler{
|
||||
gradeSyncService: gradeSyncService,
|
||||
}
|
||||
}
|
||||
|
||||
type syncGradesRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Semester string `json:"semester"`
|
||||
}
|
||||
|
||||
func (h *GradeHandler) SyncGrades(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req syncGradesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.gradeSyncService.SyncUserGrades(c.Request.Context(), userID, &service.SyncGradesRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Semester: req.Semester,
|
||||
})
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to sync grades")
|
||||
return
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
response.Success(c, result)
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, result.Message, result)
|
||||
}
|
||||
|
||||
func (h *GradeHandler) ListGrades(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.gradeSyncService.ListGradesWithSummary(userID)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to list grades")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"grades": summary.Grades,
|
||||
"gpa_summary": summary.GpaSummary,
|
||||
})
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/cursor"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// GroupHandler 群组处理器
|
||||
@@ -89,7 +91,7 @@ func (h *GroupHandler) GetGroup(c *gin.Context) {
|
||||
|
||||
group, err := h.groupService.GetGroupByID(groupID)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -128,7 +130,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
updates := make(map[string]any)
|
||||
if req.Name != "" {
|
||||
updates["name"] = req.Name
|
||||
}
|
||||
@@ -144,7 +146,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
||||
response.Forbidden(c, "没有权限修改群组信息")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -177,7 +179,7 @@ func (h *GroupHandler) DissolveGroup(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以解散群组")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -214,7 +216,7 @@ func (h *GroupHandler) TransferOwner(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以转让群主")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -278,7 +280,7 @@ func (h *GroupHandler) InviteMembers(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群成员可以邀请他人")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -313,7 +315,7 @@ func (h *GroupHandler) JoinGroup(c *gin.Context) {
|
||||
response.Forbidden(c, "该群不允许加入")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -352,7 +354,7 @@ func (h *GroupHandler) LeaveGroup(c *gin.Context) {
|
||||
response.BadRequest(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -389,7 +391,7 @@ func (h *GroupHandler) RemoveMember(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以移除成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -428,7 +430,7 @@ func (h *GroupHandler) GetMembers(c *gin.Context) {
|
||||
|
||||
members, total, err := h.groupService.GetMembers(groupID, page, pageSize)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -516,7 +518,7 @@ func (h *GroupHandler) HandleGetMyMemberInfo(c *gin.Context) {
|
||||
// 获取群组信息
|
||||
group, err := h.groupService.GetGroupByID(groupID)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -541,7 +543,7 @@ func (h *GroupHandler) HandleGetMyMemberInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 添加群组禁言状态信息
|
||||
response.Success(c, map[string]interface{}{
|
||||
response.Success(c, map[string]any{
|
||||
"member": memberResp,
|
||||
"mute_all": group.MuteAll,
|
||||
"is_muted": member.Muted || group.MuteAll,
|
||||
@@ -569,7 +571,7 @@ func (h *GroupHandler) HandleDissolveGroup(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以解散群组")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -611,7 +613,7 @@ func (h *GroupHandler) HandleTransferOwner(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以转让群主")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -656,7 +658,7 @@ func (h *GroupHandler) HandleInviteMembers(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以邀请他人")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -695,7 +697,7 @@ func (h *GroupHandler) HandleJoinGroup(c *gin.Context) {
|
||||
response.Forbidden(c, "该群不允许加入")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -740,7 +742,7 @@ func (h *GroupHandler) HandleSetNickname(c *gin.Context) {
|
||||
response.BadRequest(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -777,7 +779,7 @@ func (h *GroupHandler) HandleSetJoinType(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置加群方式")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -823,7 +825,7 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以发布公告")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -836,6 +838,8 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
|
||||
|
||||
// HandleGetAnnouncements 获取群公告列表
|
||||
// GET /api/v1/groups/:id/announcements
|
||||
//
|
||||
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||
func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
@@ -849,12 +853,18 @@ func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 成员校验:公告仅群成员可见。
|
||||
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||
response.Forbidden(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
announcements, total, err := h.groupService.GetAnnouncements(groupID, page, pageSize)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -915,7 +925,7 @@ func (h *GroupHandler) GetMyMemberInfo(c *gin.Context) {
|
||||
// 获取群组信息
|
||||
group, err := h.groupService.GetGroupByID(groupID)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -940,7 +950,7 @@ func (h *GroupHandler) GetMyMemberInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 添加群组禁言状态信息
|
||||
response.Success(c, map[string]interface{}{
|
||||
response.Success(c, map[string]any{
|
||||
"member": memberResp,
|
||||
"mute_all": group.MuteAll,
|
||||
"is_muted": member.Muted || group.MuteAll,
|
||||
@@ -980,7 +990,7 @@ func (h *GroupHandler) SetMemberRole(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置成员角色")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1021,7 +1031,7 @@ func (h *GroupHandler) SetNickname(c *gin.Context) {
|
||||
response.BadRequest(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1064,7 +1074,7 @@ func (h *GroupHandler) MuteMember(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以禁言成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1115,7 +1125,7 @@ func (h *GroupHandler) SetMuteAll(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置全员禁言")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1156,7 +1166,7 @@ func (h *GroupHandler) SetJoinType(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置加群方式")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1204,7 +1214,7 @@ func (h *GroupHandler) CreateAnnouncement(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以发布公告")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1235,7 +1245,7 @@ func (h *GroupHandler) GetAnnouncements(c *gin.Context) {
|
||||
|
||||
announcements, total, err := h.groupService.GetAnnouncements(groupID, page, pageSize)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1279,6 +1289,171 @@ func (h *GroupHandler) DeleteAnnouncement(c *gin.Context) {
|
||||
response.SuccessWithMessage(c, "公告已删除", nil)
|
||||
}
|
||||
|
||||
// ==================== 游标分页方法 ====================
|
||||
|
||||
// GetGroupsByCursor 游标分页获取群组列表
|
||||
// GET /api/groups/cursor
|
||||
func (h *GroupHandler) GetGroupsByCursor(c *gin.Context) {
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.groupService.GetGroupsByCursor(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构
|
||||
groupResponses := dto.GroupsToResponseFromPtr(result.Items)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.GroupCursorPageResponse{
|
||||
Items: groupResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetUserGroupsByCursor 游标分页获取用户加入的群组列表
|
||||
// GET /api/users/:id/groups/cursor
|
||||
func (h *GroupHandler) GetUserGroupsByCursor(c *gin.Context) {
|
||||
userID := parseUserIDFromPath(c)
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.groupService.GetUserGroupsByCursor(c.Request.Context(), userID, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构
|
||||
groupResponses := dto.GroupsToResponseFromPtr(result.Items)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.GroupCursorPageResponse{
|
||||
Items: groupResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetMembersByCursor 游标分页获取群成员列表
|
||||
// GET /api/groups/:id/members/cursor
|
||||
func (h *GroupHandler) GetMembersByCursor(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
groupID := parseGroupID(c)
|
||||
if groupID == "" {
|
||||
response.BadRequest(c, "invalid group id")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.groupService.GetMembersByCursor(c.Request.Context(), groupID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构
|
||||
memberResponses := dto.GroupMembersToResponseFromPtr(result.Items)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.GroupMemberCursorPageResponse{
|
||||
Items: memberResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetAnnouncementsByCursor 游标分页获取群公告列表
|
||||
// GET /api/v1/groups/:id/announcements/cursor
|
||||
//
|
||||
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||
func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
groupID := parseGroupID(c)
|
||||
if groupID == "" {
|
||||
response.BadRequest(c, "invalid group id")
|
||||
return
|
||||
}
|
||||
|
||||
// 成员校验
|
||||
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||
response.Forbidden(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.groupService.GetAnnouncementsByCursor(c.Request.Context(), groupID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构
|
||||
announcementResponses := dto.GroupAnnouncementsToResponseFromPtr(result.Items)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.GroupAnnouncementCursorPageResponse{
|
||||
Items: announcementResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// parseCursorRequest 解析游标分页请求参数
|
||||
func (h *GroupHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
||||
}
|
||||
|
||||
// ==================== RESTful Action 端点 ====================
|
||||
|
||||
// HandleSetGroupKick 群组踢人
|
||||
@@ -1314,7 +1489,7 @@ func (h *GroupHandler) HandleSetGroupKick(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以移除成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1361,19 +1536,31 @@ func (h *GroupHandler) HandleSetGroupBan(c *gin.Context) {
|
||||
|
||||
// duration > 0 或 duration = -1 表示禁言,duration = 0 表示解除禁言
|
||||
muted := params.Duration != 0
|
||||
log.Printf("[HandleSetGroupBan] 开始禁言操作: userID=%s, groupID=%s, targetUserID=%s, duration=%d, muted=%v", userID, groupID, params.UserID, params.Duration, muted)
|
||||
zap.L().Info("开始禁言操作",
|
||||
zap.String("component", "HandleSetGroupBan"),
|
||||
zap.String("userID", userID),
|
||||
zap.String("groupID", groupID),
|
||||
zap.String("targetUserID", params.UserID),
|
||||
zap.Int64("duration", params.Duration),
|
||||
zap.Bool("muted", muted),
|
||||
)
|
||||
err := h.groupService.MuteMember(userID, groupID, params.UserID, muted)
|
||||
if err != nil {
|
||||
log.Printf("[HandleSetGroupBan] 禁言操作失败: %v", err)
|
||||
zap.L().Warn("禁言操作失败",
|
||||
zap.String("component", "HandleSetGroupBan"),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
log.Printf("[HandleSetGroupBan] 禁言操作成功")
|
||||
zap.L().Info("禁言操作成功",
|
||||
zap.String("component", "HandleSetGroupBan"),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
if err == service.ErrNotGroupAdmin {
|
||||
response.Forbidden(c, "只有群主或管理员可以禁言成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1423,7 +1610,7 @@ func (h *GroupHandler) HandleSetGroupWholeBan(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置全员禁言")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1476,7 +1663,7 @@ func (h *GroupHandler) HandleSetGroupAdmin(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置管理员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1521,7 +1708,7 @@ func (h *GroupHandler) HandleSetGroupName(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
updates := map[string]any{
|
||||
"name": params.GroupName,
|
||||
}
|
||||
|
||||
@@ -1531,7 +1718,7 @@ func (h *GroupHandler) HandleSetGroupName(c *gin.Context) {
|
||||
response.Forbidden(c, "没有权限修改群组信息")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1570,7 +1757,7 @@ func (h *GroupHandler) HandleSetGroupAvatar(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
updates := map[string]any{
|
||||
"avatar": params.Avatar,
|
||||
}
|
||||
|
||||
@@ -1580,7 +1767,51 @@ func (h *GroupHandler) HandleSetGroupAvatar(c *gin.Context) {
|
||||
response.Forbidden(c, "没有权限修改群组信息")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取更新后的群组信息
|
||||
group, _ := h.groupService.GetGroupByID(groupID)
|
||||
response.Success(c, dto.GroupToResponse(group))
|
||||
}
|
||||
|
||||
// HandleSetGroupDescription 设置群描述
|
||||
// PUT /api/v1/groups/:id/description
|
||||
func (h *GroupHandler) HandleSetGroupDescription(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
groupID := parseGroupID(c)
|
||||
if groupID == "" {
|
||||
response.BadRequest(c, "group_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var params dto.SetGroupDescriptionParams
|
||||
if err := c.ShouldBindJSON(¶ms); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"description": params.Description,
|
||||
}
|
||||
|
||||
err := h.groupService.UpdateGroup(userID, groupID, updates)
|
||||
if err != nil {
|
||||
if err == service.ErrNotGroupAdmin {
|
||||
response.Forbidden(c, "没有权限修改群组信息")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1614,7 +1845,7 @@ func (h *GroupHandler) HandleSetGroupLeave(c *gin.Context) {
|
||||
response.BadRequest(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1722,6 +1953,8 @@ func (h *GroupHandler) HandleRespondInvite(c *gin.Context) {
|
||||
|
||||
// HandleGetGroupInfo 获取群信息
|
||||
// GET /api/v1/groups/:id
|
||||
//
|
||||
// 可见性策略:成员返回完整详情;非成员仅返回公开字段(name/avatar/description/member_count)。
|
||||
func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
@@ -1737,7 +1970,7 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
||||
|
||||
group, err := h.groupService.GetGroupByID(groupID)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1752,11 +1985,26 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
||||
resp := dto.GroupToResponse(group)
|
||||
resp.MemberCount = memberCount
|
||||
|
||||
// 非成员仅返回公开字段:与成员分支同构(仍是 GroupResponse),但清空敏感字段。
|
||||
// 与成员分支共用 GroupResponse 结构,依赖完整字段的客户端解析不会因字段缺失出错。
|
||||
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||
resp.OwnerID = ""
|
||||
resp.MaxMembers = 0
|
||||
resp.JoinType = 0
|
||||
resp.MuteAll = false
|
||||
resp.IsMember = false
|
||||
response.Success(c, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp.IsMember = true
|
||||
response.Success(c, resp)
|
||||
}
|
||||
|
||||
// HandleGetGroupMemberList 获取群成员列表
|
||||
// GET /api/v1/groups/:id/members
|
||||
//
|
||||
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||
func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
@@ -1773,9 +2021,14 @@ func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
|
||||
members, total, err := h.groupService.GetMembers(groupID, page, pageSize)
|
||||
// 成员校验在 service 层完成。
|
||||
members, total, err := h.groupService.GetMembersForUser(userID, groupID, page, pageSize)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrNotGroupMember) {
|
||||
response.Forbidden(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
|
||||
166
internal/handler/livekit_handler.go
Normal file
166
internal/handler/livekit_handler.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// LiveKitHandler LiveKit 令牌和 Webhook 处理器
|
||||
type LiveKitHandler struct {
|
||||
liveKitService service.LiveKitService
|
||||
callService service.CallService
|
||||
config *config.LiveKitConfig
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLiveKitHandler 创建 LiveKit 处理器
|
||||
func NewLiveKitHandler(
|
||||
liveKitService service.LiveKitService,
|
||||
callService service.CallService,
|
||||
cfg *config.Config,
|
||||
logger *zap.Logger,
|
||||
) *LiveKitHandler {
|
||||
return &LiveKitHandler{
|
||||
liveKitService: liveKitService,
|
||||
callService: callService,
|
||||
config: &cfg.LiveKit,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken 生成 LiveKit 访问令牌
|
||||
// GET /api/v1/calls/token?room=<callID>
|
||||
func (h *LiveKitHandler) GetToken(c *gin.Context) {
|
||||
if !h.config.Enabled {
|
||||
response.Forbidden(c, "livekit is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
room := c.Query("room")
|
||||
if room == "" {
|
||||
response.BadRequest(c, "room parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证用户是该通话的参与者
|
||||
activeCall := h.callService.GetActiveCall(c.Request.Context(), room)
|
||||
if activeCall == nil {
|
||||
response.NotFound(c, "call not found or already ended")
|
||||
return
|
||||
}
|
||||
|
||||
isParticipant := false
|
||||
for _, p := range activeCall.Participants {
|
||||
if p.UserID == userID.(string) {
|
||||
isParticipant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isParticipant && activeCall.CallerID != userID.(string) && activeCall.CalleeID != userID.(string) {
|
||||
response.Forbidden(c, "not a participant of this call")
|
||||
return
|
||||
}
|
||||
|
||||
canPublish := true
|
||||
canSubscribe := true
|
||||
if cp := c.Query("can_publish"); cp != "" {
|
||||
canPublish, _ = strconv.ParseBool(cp)
|
||||
}
|
||||
if cs := c.Query("can_subscribe"); cs != "" {
|
||||
canSubscribe, _ = strconv.ParseBool(cs)
|
||||
}
|
||||
|
||||
token, err := h.liveKitService.GenerateToken(room, userID.(string), canPublish, canSubscribe)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to generate livekit token", zap.Error(err))
|
||||
response.InternalServerError(c, "failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"token": token,
|
||||
"url": h.config.URL,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleWebhook 处理 LiveKit Webhook 回调
|
||||
// POST /api/v1/calls/webhook
|
||||
func (h *LiveKitHandler) HandleWebhook(c *gin.Context) {
|
||||
if !h.config.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "livekit is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to read webhook body", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
event, err := h.liveKitService.ProcessWebhook(c.Request.Context(), body, authHeader)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to process webhook", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook"})
|
||||
return
|
||||
}
|
||||
|
||||
// 根据 webhook 事件类型处理
|
||||
switch event.Event {
|
||||
case "room_finished":
|
||||
h.handleRoomFinished(c.Request.Context(), event)
|
||||
case "participant_left":
|
||||
identity := event.Participant.GetIdentity()
|
||||
roomName := event.Room.GetName()
|
||||
h.logger.Info("participant left room",
|
||||
zap.String("room", roomName),
|
||||
zap.String("identity", identity),
|
||||
)
|
||||
if identity != "" && roomName != "" {
|
||||
if err := h.callService.ParticipantLeave(c.Request.Context(), roomName, identity, "left_livekit"); err != nil {
|
||||
h.logger.Warn("failed to handle participant_left",
|
||||
zap.String("room", roomName),
|
||||
zap.String("identity", identity),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleRoomFinished 处理 room_finished 事件,作为通话历史持久化的安全兜底
|
||||
func (h *LiveKitHandler) handleRoomFinished(ctx context.Context, event *livekit.WebhookEvent) {
|
||||
roomName := event.Room.GetName()
|
||||
if roomName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试结束通话(如果尚未结束)
|
||||
// callService.End 会检查调用是否仍然活跃,如果已经结束则返回 nil
|
||||
_, err := h.callService.End(ctx, roomName, "", "room_finished")
|
||||
if err != nil {
|
||||
h.logger.Debug("room_finished: call end returned error (may already be ended)",
|
||||
zap.String("room", roomName),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
491
internal/handler/material_handler.go
Normal file
491
internal/handler/material_handler.go
Normal file
@@ -0,0 +1,491 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/query"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// MaterialHandler 学习资料处理器
|
||||
type MaterialHandler struct {
|
||||
materialService service.MaterialService
|
||||
}
|
||||
|
||||
// NewMaterialHandler 创建学习资料处理器
|
||||
func NewMaterialHandler(materialService service.MaterialService) *MaterialHandler {
|
||||
return &MaterialHandler{materialService: materialService}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 响应结构体
|
||||
// =====================================================
|
||||
|
||||
type subjectResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
MaterialCount int `json:"material_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type materialResponse struct {
|
||||
ID string `json:"id"`
|
||||
SubjectID string `json:"subject_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
FileType string `json:"file_type"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileURL string `json:"file_url"`
|
||||
FileName string `json:"file_name"`
|
||||
DownloadCount int `json:"download_count"`
|
||||
AuthorID string `json:"author_id,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Subject *subjectResponse `json:"subject,omitempty"`
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 转换函数
|
||||
// =====================================================
|
||||
|
||||
func toSubjectResponse(s *model.MaterialSubject, materialCount int64) *subjectResponse {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return &subjectResponse{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
Icon: s.Icon,
|
||||
Color: s.Color,
|
||||
Description: s.Description,
|
||||
SortOrder: s.SortOrder,
|
||||
IsActive: s.IsActive,
|
||||
MaterialCount: int(materialCount),
|
||||
CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: s.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
|
||||
func toMaterialResponse(f *model.MaterialFile) *materialResponse {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
var subjectResp *subjectResponse
|
||||
if f.Subject != nil {
|
||||
subjectResp = toSubjectResponse(f.Subject, 0)
|
||||
}
|
||||
return &materialResponse{
|
||||
ID: f.ID,
|
||||
SubjectID: f.SubjectID,
|
||||
Title: f.Title,
|
||||
Description: f.Description,
|
||||
FileType: string(f.FileType),
|
||||
FileSize: f.FileSize,
|
||||
FileURL: f.FileURL,
|
||||
FileName: f.FileName,
|
||||
DownloadCount: f.DownloadCount,
|
||||
AuthorID: f.AuthorID,
|
||||
Tags: f.Tags,
|
||||
Status: string(f.Status),
|
||||
CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: f.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
Subject: subjectResp,
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 公开API - 学科相关
|
||||
// =====================================================
|
||||
|
||||
// ListSubjects 公开学科列表(仅启用)
|
||||
func (h *MaterialHandler) ListSubjects(c *gin.Context) {
|
||||
items, err := h.materialService.ListActiveSubjects()
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get subjects")
|
||||
return
|
||||
}
|
||||
result := make([]*subjectResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
_, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID)
|
||||
result = append(result, toSubjectResponse(item, count))
|
||||
}
|
||||
response.Success(c, gin.H{"list": result})
|
||||
}
|
||||
|
||||
// GetSubject 获取学科详情
|
||||
func (h *MaterialHandler) GetSubject(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
item, count, err := h.materialService.GetSubjectByIDWithMaterialCount(id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "subject not found")
|
||||
return
|
||||
}
|
||||
response.Success(c, toSubjectResponse(item, count))
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 公开API - 资料相关
|
||||
// =====================================================
|
||||
|
||||
// ListMaterials 资料列表
|
||||
func (h *MaterialHandler) ListMaterials(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
subjectID := c.Query("subject_id")
|
||||
fileType := c.Query("file_type")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
params := query.MaterialFileQueryParams{
|
||||
SubjectID: subjectID,
|
||||
FileType: fileType,
|
||||
Keyword: keyword,
|
||||
Status: string(model.MaterialStatusActive),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
SortBy: "created_at",
|
||||
SortOrder: "DESC",
|
||||
}
|
||||
|
||||
items, total, err := h.materialService.ListMaterials(params)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get materials")
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]*materialResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, toMaterialResponse(item))
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"list": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"has_more": int64(page*pageSize) < total,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMaterial 获取资料详情
|
||||
func (h *MaterialHandler) GetMaterial(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
item, err := h.materialService.GetMaterialByIDWithSubject(id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "material not found")
|
||||
return
|
||||
}
|
||||
response.Success(c, toMaterialResponse(item))
|
||||
}
|
||||
|
||||
// SearchMaterials 搜索资料
|
||||
func (h *MaterialHandler) SearchMaterials(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
items, total, err := h.materialService.SearchMaterials(keyword, page, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to search materials")
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]*materialResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, toMaterialResponse(item))
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"list": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"has_more": int64(page*pageSize) < total,
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadMaterial 下载资料(增加下载次数)
|
||||
func (h *MaterialHandler) DownloadMaterial(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
item, err := h.materialService.GetMaterialByID(id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "material not found")
|
||||
return
|
||||
}
|
||||
// 增加下载次数
|
||||
_ = h.materialService.IncrementDownloadCount(id)
|
||||
// 返回下载链接
|
||||
response.Success(c, gin.H{
|
||||
"id": item.ID,
|
||||
"file_url": item.FileURL,
|
||||
"file_name": item.FileName,
|
||||
})
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 管理API - 学科相关
|
||||
// =====================================================
|
||||
|
||||
// AdminListSubjects 管理端学科列表(全部)
|
||||
func (h *MaterialHandler) AdminListSubjects(c *gin.Context) {
|
||||
items, err := h.materialService.ListAllSubjects()
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get subjects")
|
||||
return
|
||||
}
|
||||
result := make([]*subjectResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
_, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID)
|
||||
result = append(result, toSubjectResponse(item, count))
|
||||
}
|
||||
response.Success(c, gin.H{"list": result})
|
||||
}
|
||||
|
||||
type adminSubjectUpsertRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Icon string `json:"icon" binding:"max=50"`
|
||||
Color string `json:"color" binding:"max=20"`
|
||||
Description string `json:"description" binding:"max=255"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// AdminCreateSubject 创建学科
|
||||
func (h *MaterialHandler) AdminCreateSubject(c *gin.Context) {
|
||||
var req adminSubjectUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
isActive := true
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
item, err := h.materialService.CreateSubject(req.Name, req.Icon, req.Color, req.Description, req.SortOrder, isActive)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to create subject")
|
||||
return
|
||||
}
|
||||
response.Success(c, toSubjectResponse(item, 0))
|
||||
}
|
||||
|
||||
// AdminUpdateSubject 更新学科
|
||||
func (h *MaterialHandler) AdminUpdateSubject(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req adminSubjectUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
isActive := true
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
item, err := h.materialService.UpdateSubject(id, req.Name, req.Icon, req.Color, req.Description, req.SortOrder, isActive)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to update subject")
|
||||
return
|
||||
}
|
||||
_, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID)
|
||||
response.Success(c, toSubjectResponse(item, count))
|
||||
}
|
||||
|
||||
// AdminDeleteSubject 删除学科
|
||||
func (h *MaterialHandler) AdminDeleteSubject(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.materialService.DeleteSubject(id); err != nil {
|
||||
if errors.Is(err, service.ErrSubjectHasMaterials) {
|
||||
response.BadRequest(c, "学科下存在资料,无法删除")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to delete subject")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 管理API - 资料相关
|
||||
// =====================================================
|
||||
|
||||
// AdminListMaterials 管理端资料列表
|
||||
func (h *MaterialHandler) AdminListMaterials(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
subjectID := c.Query("subject_id")
|
||||
fileType := c.Query("file_type")
|
||||
status := c.Query("status")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
params := query.MaterialFileQueryParams{
|
||||
SubjectID: subjectID,
|
||||
FileType: fileType,
|
||||
Status: status,
|
||||
Keyword: keyword,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
SortBy: "created_at",
|
||||
SortOrder: "DESC",
|
||||
}
|
||||
|
||||
items, total, err := h.materialService.ListMaterials(params)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get materials")
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]*materialResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, toMaterialResponse(item))
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"list": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
type adminMaterialCreateRequest struct {
|
||||
SubjectID string `json:"subject_id" binding:"required"`
|
||||
Title string `json:"title" binding:"required,max=200"`
|
||||
Description string `json:"description"`
|
||||
FileType string `json:"file_type" binding:"required,oneof=pdf word ppt"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileURL string `json:"file_url" binding:"required"`
|
||||
FileName string `json:"file_name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type adminMaterialUpdateRequest struct {
|
||||
SubjectID string `json:"subject_id"`
|
||||
Title string `json:"title" binding:"max=200"`
|
||||
Description string `json:"description"`
|
||||
FileType string `json:"file_type" binding:"omitempty,oneof=pdf word ppt"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileURL string `json:"file_url"`
|
||||
FileName string `json:"file_name"`
|
||||
Tags []string `json:"tags"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
}
|
||||
|
||||
// AdminGetMaterial 管理端获取资料详情
|
||||
func (h *MaterialHandler) AdminGetMaterial(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
item, err := h.materialService.GetMaterialByIDWithSubject(id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "material not found")
|
||||
return
|
||||
}
|
||||
response.Success(c, toMaterialResponse(item))
|
||||
}
|
||||
|
||||
// AdminCreateMaterial 创建资料
|
||||
func (h *MaterialHandler) AdminCreateMaterial(c *gin.Context) {
|
||||
var req adminMaterialCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户ID作为作者
|
||||
userID, exists := c.Get("user_id")
|
||||
var authorID string
|
||||
if exists {
|
||||
authorID = userID.(string)
|
||||
}
|
||||
|
||||
file := &model.MaterialFile{
|
||||
SubjectID: req.SubjectID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
FileType: model.MaterialFileType(req.FileType),
|
||||
FileSize: req.FileSize,
|
||||
FileURL: req.FileURL,
|
||||
FileName: req.FileName,
|
||||
Tags: req.Tags,
|
||||
Status: model.MaterialStatusActive,
|
||||
AuthorID: authorID,
|
||||
}
|
||||
|
||||
if err := h.materialService.CreateMaterial(file); err != nil {
|
||||
response.InternalServerError(c, "failed to create material")
|
||||
return
|
||||
}
|
||||
response.Success(c, toMaterialResponse(file))
|
||||
}
|
||||
|
||||
// AdminUpdateMaterial 更新资料
|
||||
func (h *MaterialHandler) AdminUpdateMaterial(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req adminMaterialUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.materialService.GetMaterialByID(id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "material not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.SubjectID != "" {
|
||||
file.SubjectID = req.SubjectID
|
||||
}
|
||||
if req.Title != "" {
|
||||
file.Title = req.Title
|
||||
}
|
||||
if req.Description != "" {
|
||||
file.Description = req.Description
|
||||
}
|
||||
if req.FileType != "" {
|
||||
file.FileType = model.MaterialFileType(req.FileType)
|
||||
}
|
||||
if req.FileSize > 0 {
|
||||
file.FileSize = req.FileSize
|
||||
}
|
||||
if req.FileURL != "" {
|
||||
file.FileURL = req.FileURL
|
||||
}
|
||||
if req.FileName != "" {
|
||||
file.FileName = req.FileName
|
||||
}
|
||||
if req.Tags != nil {
|
||||
file.Tags = req.Tags
|
||||
}
|
||||
if req.Status != "" {
|
||||
file.Status = model.MaterialStatus(req.Status)
|
||||
}
|
||||
|
||||
if err := h.materialService.UpdateMaterial(file); err != nil {
|
||||
response.InternalServerError(c, "failed to update material")
|
||||
return
|
||||
}
|
||||
response.Success(c, toMaterialResponse(file))
|
||||
}
|
||||
|
||||
// AdminDeleteMaterial 删除资料
|
||||
func (h *MaterialHandler) AdminDeleteMaterial(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.materialService.DeleteMaterial(id); err != nil {
|
||||
response.InternalServerError(c, "failed to delete material")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
@@ -2,110 +2,123 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/cursor"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/pkg/ws"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// enrichConversations 批量填充会话列表响应数据(解决 N+1 问题)
|
||||
func (h *MessageHandler) enrichConversations(ctx context.Context, convs []*model.Conversation, userID string) []*dto.ConversationResponse {
|
||||
if len(convs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
convIDs := make([]string, len(convs))
|
||||
for i, c := range convs {
|
||||
convIDs[i] = c.ID
|
||||
}
|
||||
|
||||
// 批量查询:4-5 次查询替代 N*5 次
|
||||
unreadCounts, _ := h.chatService.GetUnreadCountBatch(ctx, userID, convIDs)
|
||||
lastMessages, _ := h.chatService.GetLastMessagesBatch(ctx, convIDs)
|
||||
myParticipants, _ := h.messageService.GetMyParticipantsBatch(ctx, convIDs, userID)
|
||||
allParticipants, _ := h.messageService.GetParticipantsBatch(ctx, convIDs)
|
||||
|
||||
// 收集需要额外查询的 ID
|
||||
var groupIDs []string
|
||||
var userIDs []string
|
||||
for _, conv := range convs {
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
groupIDs = append(groupIDs, *conv.GroupID)
|
||||
}
|
||||
}
|
||||
for _, participants := range allParticipants {
|
||||
for _, p := range participants {
|
||||
if p.UserID != userID {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memberCounts, _ := h.groupService.GetMemberCountBatch(ctx, groupIDs)
|
||||
usersMap, _ := h.userService.GetUsersByIDs(ctx, userIDs)
|
||||
|
||||
// 组装响应
|
||||
result := make([]*dto.ConversationResponse, len(convs))
|
||||
for i, conv := range convs {
|
||||
unreadCount := unreadCounts[conv.ID]
|
||||
lastMessage := lastMessages[conv.ID]
|
||||
|
||||
myP := myParticipants[conv.ID]
|
||||
isPinned := myP != nil && myP.IsPinned
|
||||
notificationMuted := myP != nil && myP.NotificationMuted
|
||||
|
||||
var resp *dto.ConversationResponse
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
if mc, ok := memberCounts[*conv.GroupID]; ok {
|
||||
resp.MemberCount = int(mc)
|
||||
}
|
||||
} else {
|
||||
participants := allParticipants[conv.ID]
|
||||
var users []*model.User
|
||||
for _, p := range participants {
|
||||
if p.UserID == userID {
|
||||
continue
|
||||
}
|
||||
if u, ok := usersMap[p.UserID]; ok {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
resp = dto.ConvertConversationToResponse(conv, users, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
}
|
||||
result[i] = resp
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MessageHandler 消息处理器
|
||||
type MessageHandler struct {
|
||||
chatService service.ChatService
|
||||
messageService *service.MessageService
|
||||
messageService service.MessageService
|
||||
userService service.UserService
|
||||
groupService service.GroupService
|
||||
sseHub *sse.Hub
|
||||
uploadService service.UploadService
|
||||
wsPublisher ws.MessagePublisher
|
||||
}
|
||||
|
||||
// NewMessageHandler 创建消息处理器
|
||||
func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService service.UserService, groupService service.GroupService, sseHub *sse.Hub) *MessageHandler {
|
||||
func NewMessageHandler(chatService service.ChatService, messageService service.MessageService, userService service.UserService, groupService service.GroupService, uploadService service.UploadService, wsPublisher ws.MessagePublisher) *MessageHandler {
|
||||
return &MessageHandler{
|
||||
chatService: chatService,
|
||||
messageService: messageService,
|
||||
userService: userService,
|
||||
groupService: groupService,
|
||||
sseHub: sseHub,
|
||||
uploadService: uploadService,
|
||||
wsPublisher: wsPublisher,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSSE 实时消息订阅(SSE)
|
||||
// GET /api/v1/realtime/sse
|
||||
func (h *MessageHandler) HandleSSE(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
if h.sseHub == nil {
|
||||
response.InternalServerError(c, "sse hub not available")
|
||||
return
|
||||
}
|
||||
|
||||
lastID := sse.ParseEventID(c.GetHeader("Last-Event-ID"))
|
||||
if lastID == 0 {
|
||||
lastID = sse.ParseEventID(c.Query("last_event_id"))
|
||||
}
|
||||
ch, cancel, replay := h.sseHub.Subscribe(userID, lastID)
|
||||
defer cancel()
|
||||
|
||||
w := c.Writer
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
response.InternalServerError(c, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Status(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
writeEvent := func(ev sse.Event) bool {
|
||||
data, err := sse.EncodeData(ev)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Event, data); err != nil {
|
||||
return false
|
||||
}
|
||||
flusher.Flush()
|
||||
return true
|
||||
}
|
||||
|
||||
for _, ev := range replay {
|
||||
if !writeEvent(ev) {
|
||||
return
|
||||
// convertMessagesWithExpiry 将消息列表转换为响应,并对已过期的文件 segment 注入 expired 标记。
|
||||
// 若 uploadService 不可用或查询失败,降级为不带过期标记的普通转换。
|
||||
func (h *MessageHandler) convertMessagesWithExpiry(ctx context.Context, messages []*model.Message) []*dto.MessageResponse {
|
||||
var expiredSet map[string]struct{}
|
||||
if h.uploadService != nil {
|
||||
if es, err := h.uploadService.GetExpiredURLSet(ctx); err == nil {
|
||||
expiredSet = es
|
||||
}
|
||||
}
|
||||
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case ev, ok := <-ch:
|
||||
if !ok || !writeEvent(ev) {
|
||||
return
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
if _, err := fmt.Fprint(w, "event: heartbeat\ndata: {}\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if len(expiredSet) == 0 {
|
||||
return dto.ConvertMessagesToResponse(messages)
|
||||
}
|
||||
return dto.ConvertMessagesToResponseWithExpiry(messages, expiredSet)
|
||||
}
|
||||
|
||||
// HandleTyping 输入状态上报
|
||||
@@ -154,36 +167,8 @@ func (h *MessageHandler) GetConversations(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := make([]*dto.ConversationResponse, len(filteredConvs))
|
||||
for i, conv := range filteredConvs {
|
||||
// 获取未读数
|
||||
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
|
||||
|
||||
// 获取最后一条消息
|
||||
var lastMessage *model.Message
|
||||
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
|
||||
if len(messages) > 0 {
|
||||
lastMessage = messages[0]
|
||||
}
|
||||
|
||||
// 群聊时返回member_count,私聊时返回participants
|
||||
var resp *dto.ConversationResponse
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
// 群聊:实时计算群成员数量
|
||||
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
|
||||
// 创建响应并设置member_count
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned)
|
||||
resp.MemberCount = memberCount
|
||||
} else {
|
||||
// 私聊:获取参与者信息
|
||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned)
|
||||
}
|
||||
result[i] = resp
|
||||
}
|
||||
// 批量填充会话数据(解决 N+1 问题)
|
||||
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
||||
|
||||
// 更新 total 为过滤后的数量
|
||||
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
||||
@@ -227,8 +212,9 @@ func (h *MessageHandler) CreateConversation(c *gin.Context) {
|
||||
participants := []*model.User{targetUser}
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
||||
|
||||
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned))
|
||||
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned, notificationMuted))
|
||||
}
|
||||
|
||||
// GetConversationByID 获取会话详情
|
||||
@@ -262,18 +248,26 @@ func (h *MessageHandler) GetConversationByID(c *gin.Context) {
|
||||
// 获取当前用户的已读位置
|
||||
myLastReadSeq := int64(0)
|
||||
isPinned := false
|
||||
notificationMuted := false
|
||||
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
|
||||
for _, p := range allParticipants {
|
||||
if p.UserID == userID {
|
||||
myLastReadSeq = p.LastReadSeq
|
||||
isPinned = p.IsPinned
|
||||
notificationMuted = p.NotificationMuted
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 获取对方用户的已读位置
|
||||
otherLastReadSeq := int64(0)
|
||||
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned))
|
||||
for _, p := range allParticipants {
|
||||
if p.UserID != userID {
|
||||
otherLastReadSeq = p.LastReadSeq
|
||||
break
|
||||
}
|
||||
}
|
||||
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
|
||||
}
|
||||
|
||||
// GetMessages 获取消息列表
|
||||
@@ -310,8 +304,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -338,8 +332,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -358,12 +352,43 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetReplyMessage 按消息 ID 获取被引用消息(用于引用预览回填/跳转)
|
||||
// GET /api/v1/messages/:id
|
||||
func (h *MessageHandler) GetReplyMessage(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
messageID := c.Param("id")
|
||||
if messageID == "" {
|
||||
response.BadRequest(c, "message id is required")
|
||||
return
|
||||
}
|
||||
|
||||
message, err := h.chatService.GetReplyMessage(c.Request.Context(), messageID, userID)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式(标记已过期文件 / 撤回占位等,保持与列表接口一致)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), []*model.Message{message})
|
||||
if len(result) == 0 {
|
||||
response.InternalServerError(c, "failed to convert message")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result[0])
|
||||
}
|
||||
|
||||
// SendMessage 发送消息
|
||||
// POST /api/conversations/:id/messages
|
||||
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
||||
@@ -386,8 +411,7 @@ func (h *MessageHandler) SendMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 直接使用 segments
|
||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID)
|
||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
@@ -429,7 +453,7 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID)
|
||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID, params.ClientMsgID)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
@@ -437,13 +461,14 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
|
||||
|
||||
// 构建 WSEventResponse 格式响应
|
||||
wsResponse := dto.WSEventResponse{
|
||||
ID: msg.ID,
|
||||
Time: msg.CreatedAt.UnixMilli(),
|
||||
Type: "message",
|
||||
DetailType: params.DetailType,
|
||||
Seq: strconv.FormatInt(msg.Seq, 10),
|
||||
Segments: params.Segments,
|
||||
SenderID: userID,
|
||||
ID: msg.ID,
|
||||
Time: msg.CreatedAt.UnixMilli(),
|
||||
Type: "message",
|
||||
DetailType: params.DetailType,
|
||||
ConversationID: conversationID,
|
||||
Seq: msg.Seq,
|
||||
Segments: params.Segments,
|
||||
SenderID: userID,
|
||||
}
|
||||
|
||||
response.Success(c, wsResponse)
|
||||
@@ -507,36 +532,8 @@ func (h *MessageHandler) HandleGetConversationList(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := make([]*dto.ConversationResponse, len(filteredConvs))
|
||||
for i, conv := range filteredConvs {
|
||||
// 获取未读数
|
||||
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
|
||||
|
||||
// 获取最后一条消息
|
||||
var lastMessage *model.Message
|
||||
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
|
||||
if len(messages) > 0 {
|
||||
lastMessage = messages[0]
|
||||
}
|
||||
|
||||
// 群聊时返回member_count,私聊时返回participants
|
||||
var resp *dto.ConversationResponse
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
// 群聊:实时计算群成员数量
|
||||
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
|
||||
// 创建响应并设置member_count
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned)
|
||||
resp.MemberCount = memberCount
|
||||
} else {
|
||||
// 私聊:获取参与者信息
|
||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned)
|
||||
}
|
||||
result[i] = resp
|
||||
}
|
||||
// 批量填充会话数据(解决 N+1 问题)
|
||||
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
||||
|
||||
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
||||
}
|
||||
@@ -616,6 +613,57 @@ func (h *MessageHandler) GetUnreadCount(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGetSyncData 获取同步元数据(轻量级 seq + 时间)
|
||||
// GET /api/v1/conversations/sync-data
|
||||
func (h *MessageHandler) HandleGetSyncData(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.chatService.GetSyncData(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get sync data")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, &dto.SyncDataResponse{
|
||||
Conversations: items,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGetSyncByVersion 增量同步:按版本号获取会话变更
|
||||
// GET /api/v1/conversations/sync?version=0&limit=100
|
||||
func (h *MessageHandler) HandleGetSyncByVersion(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
versionStr := c.DefaultQuery("version", "0")
|
||||
sinceVersion, err := strconv.ParseInt(versionStr, 10, 64)
|
||||
if err != nil || sinceVersion < 0 {
|
||||
response.BadRequest(c, "invalid version parameter")
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit < 1 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
result, err := h.chatService.GetSyncByVersion(c.Request.Context(), userID, sinceVersion, limit)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetConversationUnreadCount 获取单个会话的未读数
|
||||
// GET /api/conversations/:id/unread/count
|
||||
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
|
||||
@@ -684,16 +732,6 @@ func (h *MessageHandler) DeleteMessage(c *gin.Context) {
|
||||
response.SuccessWithMessage(c, "message deleted", nil)
|
||||
}
|
||||
|
||||
// 辅助函数:验证内容类型
|
||||
func isValidContentType(contentType model.ContentType) bool {
|
||||
switch contentType {
|
||||
case model.ContentTypeText, model.ContentTypeImage, model.ContentTypeVideo, model.ContentTypeAudio, model.ContentTypeFile:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:获取会话参与者信息
|
||||
func (h *MessageHandler) getConversationParticipants(ctx context.Context, conversationID string, currentUserID string) ([]*model.User, error) {
|
||||
// 从repository获取参与者列表
|
||||
@@ -777,8 +815,9 @@ func (h *MessageHandler) HandleCreateConversation(c *gin.Context) {
|
||||
participants := []*model.User{targetUser}
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
||||
|
||||
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned))
|
||||
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned, notificationMuted))
|
||||
}
|
||||
|
||||
// HandleGetConversation 获取会话详情
|
||||
@@ -808,21 +847,29 @@ func (h *MessageHandler) HandleGetConversation(c *gin.Context) {
|
||||
// 获取参与者信息
|
||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID)
|
||||
|
||||
// 获取当前用户的已读位置
|
||||
// 获取当前用户的已读位置(优先从 Redis 缓存读取)
|
||||
myLastReadSeq := int64(0)
|
||||
isPinned := false
|
||||
notificationMuted := false
|
||||
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
|
||||
for _, p := range allParticipants {
|
||||
if p.UserID == userID {
|
||||
myLastReadSeq = p.LastReadSeq
|
||||
isPinned = p.IsPinned
|
||||
notificationMuted = p.NotificationMuted
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 获取对方用户的已读位置
|
||||
// 获取对方用户的已读位置(优先从 Redis 缓存读取)
|
||||
otherLastReadSeq := int64(0)
|
||||
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned))
|
||||
for _, p := range allParticipants {
|
||||
if p.UserID != userID {
|
||||
otherLastReadSeq = p.LastReadSeq
|
||||
break
|
||||
}
|
||||
}
|
||||
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
|
||||
}
|
||||
|
||||
// HandleGetMessages 获取会话消息
|
||||
@@ -858,8 +905,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -886,8 +933,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -906,8 +953,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
@@ -942,6 +989,33 @@ func (h *MessageHandler) HandleMarkRead(c *gin.Context) {
|
||||
response.SuccessWithMessage(c, "marked as read", nil)
|
||||
}
|
||||
|
||||
// HandleMarkReadAll 批量标记所有会话已读
|
||||
// POST /api/v1/conversations/read-all
|
||||
func (h *MessageHandler) HandleMarkReadAll(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.BatchMarkReadRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
successCount, err := h.chatService.MarkAsReadBatch(c.Request.Context(), userID, req.Conversations)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to mark all as read")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, &dto.BatchMarkReadResponse{
|
||||
SuccessCount: successCount,
|
||||
TotalCount: len(req.Conversations),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleSetConversationPinned 设置会话置顶
|
||||
// PUT /api/v1/conversations/:id/pinned
|
||||
func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
||||
@@ -975,3 +1049,129 @@ func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
||||
"is_pinned": req.IsPinned,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleSetConversationNotificationMuted 设置会话免打扰状态
|
||||
// PUT /api/v1/conversations/:id/notification_muted
|
||||
func (h *MessageHandler) HandleSetConversationNotificationMuted(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
conversationID := getIDParam(c, "id")
|
||||
if conversationID == "" {
|
||||
response.BadRequest(c, "conversation id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
NotificationMuted bool `json:"notification_muted"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.chatService.SetConversationNotificationMuted(c.Request.Context(), conversationID, userID, req.NotificationMuted); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "conversation notification_muted status updated", gin.H{
|
||||
"conversation_id": conversationID,
|
||||
"notification_muted": req.NotificationMuted,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Cursor Pagination Handlers ====================
|
||||
|
||||
// GetMessagesByCursor 游标分页获取会话消息
|
||||
// GET /api/v1/conversations/:id/messages/cursor
|
||||
// 请求参数:
|
||||
// - cursor: 游标字符串(可选)
|
||||
// - direction: 分页方向 forward 或 backward(默认 forward)
|
||||
// - page_size: 每页数量(默认 20,最大 100)
|
||||
func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
conversationID := getIDParam(c, "id")
|
||||
if conversationID == "" {
|
||||
response.BadRequest(c, "conversation_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页参数
|
||||
pageReq := parseCursorPageRequest(c)
|
||||
|
||||
// 调用服务层(内部会校验 currentUserID 是否是会话参与者,否则拒绝)
|
||||
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, userID, pageReq)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to get messages")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
items := h.convertMessagesWithExpiry(c.Request.Context(), result.Items)
|
||||
|
||||
response.Success(c, &dto.MessageCursorPageResponse{
|
||||
Items: items,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
})
|
||||
}
|
||||
|
||||
// GetConversationsByCursor 游标分页获取会话列表
|
||||
// GET /api/v1/conversations/cursor
|
||||
// 请求参数:
|
||||
// - cursor: 游标字符串(可选)
|
||||
// - direction: 分页方向 forward 或 backward(默认 forward)
|
||||
// - page_size: 每页数量(默认 20,最大 100)
|
||||
func (h *MessageHandler) GetConversationsByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页参数
|
||||
pageReq := parseCursorPageRequest(c)
|
||||
|
||||
// 调用服务层
|
||||
result, err := h.messageService.GetConversationsByCursor(c.Request.Context(), userID, pageReq)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get conversations")
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤掉系统会话
|
||||
filteredItems := make([]*model.Conversation, 0)
|
||||
for _, conv := range result.Items {
|
||||
if conv.ID != model.SystemConversationID {
|
||||
filteredItems = append(filteredItems, conv)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量填充会话数据(解决 N+1 问题)
|
||||
items := h.enrichConversations(c.Request.Context(), filteredItems, userID)
|
||||
response.Success(c, &dto.ConversationCursorPageResponse{
|
||||
Items: items,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
})
|
||||
}
|
||||
|
||||
// parseCursorPageRequest 解析游标分页请求参数
|
||||
func parseCursorPageRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
||||
}
|
||||
|
||||
@@ -5,17 +5,19 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/pkg/cursor"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// NotificationHandler 通知处理器
|
||||
type NotificationHandler struct {
|
||||
notificationService *service.NotificationService
|
||||
notificationService service.NotificationService
|
||||
}
|
||||
|
||||
// NewNotificationHandler 创建通知处理器
|
||||
func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler {
|
||||
func NewNotificationHandler(notificationService service.NotificationService) *NotificationHandler {
|
||||
return &NotificationHandler{
|
||||
notificationService: notificationService,
|
||||
}
|
||||
@@ -54,7 +56,7 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
||||
|
||||
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to mark as read")
|
||||
response.HandleError(c, err, "failed to mark as read")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,7 +109,7 @@ func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
|
||||
|
||||
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to delete notification")
|
||||
response.HandleError(c, err, "failed to delete notification")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,3 +132,46 @@ func (h *NotificationHandler) ClearAllNotifications(c *gin.Context) {
|
||||
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetNotificationsByCursor 游标分页获取通知列表
|
||||
func (h *NotificationHandler) GetNotificationsByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
unreadOnly := c.Query("unread_only") == "true"
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.notificationService.GetNotificationsByCursor(c.Request.Context(), userID, unreadOnly, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get notifications")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应结构
|
||||
notificationResponses := dto.ConvertNotificationsToResponse(result.Items)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.NotificationCursorPageResponse{
|
||||
Items: notificationResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// parseCursorRequest 解析游标分页请求参数
|
||||
func (h *NotificationHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,89 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/cursor"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// PostHandler 帖子处理器
|
||||
type PostHandler struct {
|
||||
postService service.PostService
|
||||
userService service.UserService
|
||||
postService service.PostService
|
||||
postRefService service.PostRefService
|
||||
userService service.UserService
|
||||
channelService service.ChannelService
|
||||
}
|
||||
|
||||
// NewPostHandler 创建帖子处理器
|
||||
func NewPostHandler(postService service.PostService, userService service.UserService) *PostHandler {
|
||||
func NewPostHandler(postService service.PostService, postRefService service.PostRefService, userService service.UserService, channelService service.ChannelService) *PostHandler {
|
||||
return &PostHandler{
|
||||
postService: postService,
|
||||
userService: userService,
|
||||
postService: postService,
|
||||
postRefService: postRefService,
|
||||
userService: userService,
|
||||
channelService: channelService,
|
||||
}
|
||||
}
|
||||
|
||||
// collectAuthorIDs 从帖子列表中收集去重的作者ID
|
||||
func collectAuthorIDs(posts []*model.Post) []string {
|
||||
seen := make(map[string]struct{})
|
||||
ids := make([]string, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if _, ok := seen[p.UserID]; !ok {
|
||||
seen[p.UserID] = struct{}{}
|
||||
ids = append(ids, p.UserID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// filterBlockedPosts 过滤掉被当前用户拉黑的作者发布的帖子
|
||||
func (h *PostHandler) filterBlockedPosts(ctx context.Context, posts []*model.Post, currentUserID string) []*model.Post {
|
||||
if currentUserID == "" || len(posts) == 0 {
|
||||
return posts
|
||||
}
|
||||
|
||||
authorIDs := collectAuthorIDs(posts)
|
||||
blockedMap, err := h.userService.IsBlockedBatch(ctx, currentUserID, authorIDs)
|
||||
if err != nil {
|
||||
return posts
|
||||
}
|
||||
|
||||
filtered := make([]*model.Post, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if blockedMap[p.UserID] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// channelMapForPosts 批量解析帖子中的频道 ID,用于填充响应里的 channel 名称
|
||||
func (h *PostHandler) channelMapForPosts(posts []*model.Post) map[string]*model.Channel {
|
||||
ids := dto.CollectPostChannelIDs(posts)
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
m, err := h.channelService.MapByIDs(ids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Create 创建帖子
|
||||
func (h *PostHandler) Create(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -35,30 +92,42 @@ func (h *PostHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
var req CreateRequest
|
||||
var req dto.CreatePostWithSegmentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, req.Content, req.Images)
|
||||
content := req.Content
|
||||
segments := req.Segments
|
||||
|
||||
if len(segments) == 0 && content == "" {
|
||||
response.BadRequest(c, "content or segments is required")
|
||||
return
|
||||
}
|
||||
|
||||
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, content, segments, req.Images, req.ChannelID, req.ClientRequestID)
|
||||
if err != nil {
|
||||
var moderationErr *service.PostModerationRejectedError
|
||||
if errors.As(err, &moderationErr) {
|
||||
response.BadRequest(c, moderationErr.UserMessage())
|
||||
// 幂等命中"进行中"占位:提示客户端稍候,避免重复提交
|
||||
if errors.Is(err, service.ErrDuplicatePostRequest) {
|
||||
response.ErrorWithStatusCode(c, http.StatusTooManyRequests, 429, err.Error())
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to create post")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.ConvertPostToResponse(post, false, false))
|
||||
if h.postRefService != nil && len(segments) > 0 {
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if err := h.postRefService.ProcessPostReferences(bgCtx, post.ID, segments); err != nil {
|
||||
log.Printf("[WARN] process post references failed for post %s: %v", post.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.ConvertPostToResponse(post, chMap, false, false))
|
||||
}
|
||||
|
||||
// GetByID 获取帖子(不增加浏览量)
|
||||
@@ -80,44 +149,60 @@ func (h *PostHandler) GetByID(c *gin.Context) {
|
||||
|
||||
// 注意:不再自动增加浏览量,浏览量通过 RecordView 端点单独记录
|
||||
|
||||
// 增强 post_ref segments
|
||||
enrichedSegments := dto.SegmentsOrDefault(post.Segments, post.Content)
|
||||
if h.postRefService != nil && len(enrichedSegments) > 0 {
|
||||
if enriched, err := h.postRefService.EnrichPostRefSegments(c.Request.Context(), enrichedSegments, currentUserID); err == nil {
|
||||
enrichedSegments = enriched
|
||||
}
|
||||
}
|
||||
|
||||
// 获取交互状态
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, currentUserID)
|
||||
|
||||
// 如果有当前用户,检查与帖子作者的相互关注状态
|
||||
var authorWithFollowStatus *dto.UserResponse
|
||||
if currentUserID != "" && post.User != nil {
|
||||
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
|
||||
if err == nil {
|
||||
authorWithFollowStatus = dto.ConvertUserToResponseWithMutualFollow(post.User, isFollowing, isFollowingMe)
|
||||
if post.User != nil {
|
||||
if currentUserID != "" {
|
||||
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
|
||||
if err == nil {
|
||||
authorWithFollowStatus = dto.ConvertUserToResponse(post.User, dto.WithFollowing(isFollowing, isFollowingMe))
|
||||
} else {
|
||||
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
|
||||
}
|
||||
} else {
|
||||
// 如果出错,使用默认的author
|
||||
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
responseData := &dto.PostResponse{
|
||||
ID: post.ID,
|
||||
UserID: post.UserID,
|
||||
Title: post.Title,
|
||||
Content: post.Content,
|
||||
Images: dto.ConvertPostImagesToResponse(post.Images),
|
||||
Status: string(post.Status),
|
||||
LikesCount: post.LikesCount,
|
||||
CommentsCount: post.CommentsCount,
|
||||
FavoritesCount: post.FavoritesCount,
|
||||
SharesCount: post.SharesCount,
|
||||
ViewsCount: post.ViewsCount,
|
||||
IsPinned: post.IsPinned,
|
||||
IsLocked: post.IsLocked,
|
||||
IsVote: post.IsVote,
|
||||
CreatedAt: dto.FormatTime(post.CreatedAt),
|
||||
UpdatedAt: dto.FormatTime(post.UpdatedAt),
|
||||
Author: authorWithFollowStatus,
|
||||
IsLiked: isLiked,
|
||||
IsFavorited: isFavorited,
|
||||
ID: post.ID,
|
||||
UserID: post.UserID,
|
||||
ChannelID: post.ChannelID,
|
||||
Title: post.Title,
|
||||
Content: post.Content,
|
||||
Segments: enrichedSegments,
|
||||
Images: dto.ConvertPostImagesToResponse(post.Images),
|
||||
Status: string(post.Status),
|
||||
LikesCount: post.LikesCount,
|
||||
CommentsCount: post.CommentsCount,
|
||||
FavoritesCount: post.FavoritesCount,
|
||||
SharesCount: post.SharesCount,
|
||||
ViewsCount: post.ViewsCount,
|
||||
IsPinned: post.IsPinned,
|
||||
IsLocked: post.IsLocked,
|
||||
IsVote: post.IsVote,
|
||||
CreatedAt: dto.FormatTime(post.CreatedAt),
|
||||
UpdatedAt: dto.FormatTime(post.UpdatedAt),
|
||||
ContentEditedAt: dto.FormatTimePointer(post.ContentEditedAt),
|
||||
Author: authorWithFollowStatus,
|
||||
IsLiked: isLiked,
|
||||
IsFavorited: isFavorited,
|
||||
}
|
||||
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
responseData.Channel = dto.PostChannelBriefForPost(post, chMap)
|
||||
|
||||
response.Success(c, responseData)
|
||||
}
|
||||
|
||||
@@ -143,12 +228,61 @@ func (h *PostHandler) RecordView(c *gin.Context) {
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// List 获取帖子列表
|
||||
// RecordShare 记录帖子分享(shares_count +1,仅已发布)
|
||||
// 支持可选 JSON:{"channel":"wechat"|"qq"|"copy_link"|"system"|...},用于后续统计
|
||||
func (h *PostHandler) RecordShare(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
_ = c.GetString("user_id") // 预留:登录用户维度统计
|
||||
|
||||
type shareBody struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
var body shareBody
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, "invalid json body")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sharesCount, err := h.postService.RecordShare(c.Request.Context(), id, c.GetString("user_id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.NotFound(c, "post not found")
|
||||
return
|
||||
}
|
||||
fmt.Printf("[ERROR] RecordShare post %s: %v\n", id, err)
|
||||
response.InternalServerError(c, "failed to record share")
|
||||
return
|
||||
}
|
||||
|
||||
data := gin.H{"success": true, "shares_count": sharesCount}
|
||||
if body.Channel != "" {
|
||||
data["channel"] = body.Channel
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
// List 获取帖子列表(支持游标分页和偏移分页)
|
||||
func (h *PostHandler) List(c *gin.Context) {
|
||||
// 检查是否使用游标分页
|
||||
// 当 cursor 参数存在时(包括空字符串),使用游标分页
|
||||
_, cursorExists := c.GetQuery("cursor")
|
||||
if cursorExists {
|
||||
h.ListByCursor(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 偏移分页(向后兼容)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
page, pageSize = normalizePagination(page, pageSize)
|
||||
userID := c.Query("user_id")
|
||||
tab := c.Query("tab") // recommend, follow, hot, latest
|
||||
tab := c.Query("tab")
|
||||
var channelID *string
|
||||
if cid := c.Query("channel_id"); cid != "" {
|
||||
channelID = &cid
|
||||
}
|
||||
|
||||
// 获取当前用户ID
|
||||
currentUserID := c.GetString("user_id")
|
||||
@@ -160,31 +294,24 @@ func (h *PostHandler) List(c *gin.Context) {
|
||||
// 根据 tab 参数选择不同的获取方式
|
||||
switch tab {
|
||||
case "follow":
|
||||
// 获取关注用户的帖子,需要登录
|
||||
if currentUserID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
posts, total, err = h.postService.GetFollowingPosts(c.Request.Context(), currentUserID, page, pageSize)
|
||||
posts, total, err = h.postService.GetFollowingPosts(c.Request.Context(), currentUserID, page, pageSize, channelID)
|
||||
case "hot":
|
||||
// 获取热门帖子
|
||||
posts, total, err = h.postService.GetHotPosts(c.Request.Context(), page, pageSize)
|
||||
case "recommend":
|
||||
// 推荐帖子(从Gorse获取个性化推荐)
|
||||
posts, total, err = h.postService.GetRecommendedPosts(c.Request.Context(), currentUserID, page, pageSize)
|
||||
posts, total, err = h.postService.GetHotPosts(c.Request.Context(), page, pageSize, channelID)
|
||||
case "latest":
|
||||
// 最新帖子
|
||||
if userID != "" && userID == currentUserID {
|
||||
posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID)
|
||||
posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID, channelID)
|
||||
} else {
|
||||
posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID)
|
||||
posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID, channelID)
|
||||
}
|
||||
default:
|
||||
// 默认获取最新帖子
|
||||
if userID != "" && userID == currentUserID {
|
||||
posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID)
|
||||
posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID, channelID)
|
||||
} else {
|
||||
posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID)
|
||||
posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID, channelID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +320,8 @@ func (h *PostHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
|
||||
|
||||
// 批量获取交互状态
|
||||
postIDs := make([]string, len(posts))
|
||||
for i, post := range posts {
|
||||
@@ -200,12 +329,77 @@ func (h *PostHandler) List(c *gin.Context) {
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(posts)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
|
||||
// ListByCursor 游标分页获取帖子列表
|
||||
func (h *PostHandler) ListByCursor(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
tab := c.Query("tab")
|
||||
var channelID *string
|
||||
if cid := c.Query("channel_id"); cid != "" {
|
||||
channelID = &cid
|
||||
}
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 判断是否包含待审核帖子
|
||||
includePending := userID != "" && userID == currentUserID
|
||||
|
||||
var result *cursor.CursorPageResult[*model.Post]
|
||||
var err error
|
||||
|
||||
// 根据 tab 参数选择不同的获取方式
|
||||
switch tab {
|
||||
case "follow":
|
||||
if currentUserID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
result, err = h.postService.GetFollowingPostsByCursor(c.Request.Context(), currentUserID, channelID, req)
|
||||
case "hot":
|
||||
result, err = h.postService.GetHotPostsByCursor(c.Request.Context(), channelID, req)
|
||||
case "latest":
|
||||
// 最新帖子
|
||||
result, err = h.postService.ListByCursor(c.Request.Context(), userID, includePending, channelID, req)
|
||||
default:
|
||||
// 默认获取最新帖子
|
||||
result, err = h.postService.ListByCursor(c.Request.Context(), userID, includePending, channelID, req)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get posts")
|
||||
return
|
||||
}
|
||||
|
||||
result.Items = h.filterBlockedPosts(c.Request.Context(), result.Items, currentUserID)
|
||||
|
||||
// 批量获取交互状态
|
||||
postIDs := make([]string, len(result.Items))
|
||||
for i, post := range result.Items {
|
||||
postIDs[i] = post.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
chMap := h.channelMapForPosts(result.Items)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.PostCursorPageResponse{
|
||||
Items: postResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// Update 更新帖子
|
||||
func (h *PostHandler) Update(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -228,9 +422,10 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Images *[]string `json:"images"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments"`
|
||||
Images *[]string `json:"images"`
|
||||
}
|
||||
|
||||
var req UpdateRequest
|
||||
@@ -245,6 +440,12 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
if req.Content != "" {
|
||||
post.Content = req.Content
|
||||
}
|
||||
if len(req.Segments) > 0 {
|
||||
post.Segments = req.Segments
|
||||
if req.Content == "" {
|
||||
post.Content = dto.ExtractTextContent(req.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
err = h.postService.UpdateWithImages(c.Request.Context(), post, req.Images)
|
||||
if err != nil {
|
||||
@@ -252,6 +453,15 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.postRefService != nil && len(req.Segments) > 0 {
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if err := h.postRefService.ProcessPostReferences(bgCtx, post.ID, post.Segments); err != nil {
|
||||
log.Printf("[WARN] process post references failed for post %s: %v", post.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
post, err = h.postService.GetByID(c.Request.Context(), post.ID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get updated post")
|
||||
@@ -262,7 +472,8 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
currentUserID := c.GetString("user_id")
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), post.ID, currentUserID)
|
||||
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// Delete 删除帖子
|
||||
@@ -286,12 +497,21 @@ func (h *PostHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err = h.postService.Delete(c.Request.Context(), id)
|
||||
err = h.postService.Delete(c.Request.Context(), userID, id)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to delete post")
|
||||
response.HandleError(c, err, "failed to delete post")
|
||||
return
|
||||
}
|
||||
|
||||
if h.postRefService != nil {
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if err := h.postRefService.HandlePostDeleted(bgCtx, id); err != nil {
|
||||
log.Printf("[WARN] mark dead post references failed for post %s: %v", id, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "post deleted", nil)
|
||||
}
|
||||
|
||||
@@ -321,7 +541,8 @@ func (h *PostHandler) Like(c *gin.Context) {
|
||||
// 获取交互状态
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
||||
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// Unlike 取消点赞
|
||||
@@ -350,7 +571,8 @@ func (h *PostHandler) Unlike(c *gin.Context) {
|
||||
// 获取交互状态
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
||||
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// Favorite 收藏帖子
|
||||
@@ -379,7 +601,8 @@ func (h *PostHandler) Favorite(c *gin.Context) {
|
||||
// 获取交互状态
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
||||
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// Unfavorite 取消收藏
|
||||
@@ -408,16 +631,63 @@ func (h *PostHandler) Unfavorite(c *gin.Context) {
|
||||
// 获取交互状态
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
||||
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// GetUserPosts 获取用户帖子列表
|
||||
// GetUserPosts 获取用户帖子列表(支持游标分页和偏移分页)
|
||||
func (h *PostHandler) GetUserPosts(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
canView, err := h.userService.CanViewPosts(c.Request.Context(), currentUserID, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to check privacy settings")
|
||||
return
|
||||
}
|
||||
if !canView {
|
||||
response.Forbidden(c, "该用户的帖子列表不对外公开")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否拉黑了对方
|
||||
if currentUserID != "" && currentUserID != userID {
|
||||
isBlocked, _ := h.userService.IsBlocked(c.Request.Context(), currentUserID, userID)
|
||||
if isBlocked {
|
||||
// 检查是否使用游标分页
|
||||
cursorStr := c.Query("cursor")
|
||||
if cursorStr != "" {
|
||||
response.Success(c, &dto.PostCursorPageResponse{
|
||||
Items: []*dto.PostResponse{},
|
||||
HasMore: false,
|
||||
IsBlocked: true,
|
||||
})
|
||||
} else {
|
||||
response.Success(c, gin.H{
|
||||
"list": []*dto.PostResponse{},
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 0,
|
||||
"is_blocked": true,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否使用游标分页
|
||||
cursorStr := c.Query("cursor")
|
||||
if cursorStr != "" {
|
||||
h.GetUserPostsByCursor(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 偏移分页(向后兼容)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
page, pageSize = normalizePagination(page, pageSize)
|
||||
|
||||
currentUserID := c.GetString("user_id")
|
||||
includePending := currentUserID != "" && currentUserID == userID
|
||||
posts, total, err := h.postService.GetUserPosts(c.Request.Context(), userID, page, pageSize, includePending)
|
||||
if err != nil {
|
||||
@@ -432,17 +702,69 @@ func (h *PostHandler) GetUserPosts(c *gin.Context) {
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(posts)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetUserPostsByCursor 游标分页获取用户帖子列表
|
||||
func (h *PostHandler) GetUserPostsByCursor(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 判断是否包含待审核帖子
|
||||
includePending := currentUserID != "" && currentUserID == userID
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.postService.GetUserPostsByCursor(c.Request.Context(), userID, includePending, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get user posts")
|
||||
return
|
||||
}
|
||||
|
||||
// 批量获取交互状态
|
||||
postIDs := make([]string, len(result.Items))
|
||||
for i, post := range result.Items {
|
||||
postIDs[i] = post.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
chMap := h.channelMapForPosts(result.Items)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.PostCursorPageResponse{
|
||||
Items: postResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetFavorites 获取收藏列表
|
||||
func (h *PostHandler) GetFavorites(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
canView, err := h.userService.CanViewFavorites(c.Request.Context(), currentUserID, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to check privacy settings")
|
||||
return
|
||||
}
|
||||
if !canView {
|
||||
response.Forbidden(c, "该用户的收藏列表不对外公开")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
page, pageSize = normalizePagination(page, pageSize)
|
||||
|
||||
posts, total, err := h.postService.GetFavorites(c.Request.Context(), userID, page, pageSize)
|
||||
if err != nil {
|
||||
@@ -450,16 +772,14 @@ func (h *PostHandler) GetFavorites(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 批量获取交互状态
|
||||
currentUserID := c.GetString("user_id")
|
||||
postIDs := make([]string, len(posts))
|
||||
for i, post := range posts {
|
||||
postIDs[i] = post.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(posts)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
@@ -467,8 +787,18 @@ func (h *PostHandler) GetFavorites(c *gin.Context) {
|
||||
// Search 搜索帖子
|
||||
func (h *PostHandler) Search(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
// 检查是否使用游标分页
|
||||
cursorStr := c.Query("cursor")
|
||||
if cursorStr != "" {
|
||||
h.SearchByCursor(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 偏移分页(向后兼容)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
page, pageSize = normalizePagination(page, pageSize)
|
||||
|
||||
posts, total, err := h.postService.Search(c.Request.Context(), keyword, page, pageSize)
|
||||
if err != nil {
|
||||
@@ -478,14 +808,172 @@ func (h *PostHandler) Search(c *gin.Context) {
|
||||
|
||||
// 批量获取交互状态
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
|
||||
|
||||
postIDs := make([]string, len(posts))
|
||||
for i, post := range posts {
|
||||
postIDs[i] = post.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(posts)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
|
||||
// SearchByCursor 游标分页搜索帖子
|
||||
func (h *PostHandler) SearchByCursor(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
// 解析游标分页请求
|
||||
req := h.parseCursorRequest(c)
|
||||
|
||||
// 调用游标分页服务
|
||||
result, err := h.postService.SearchByCursor(c.Request.Context(), keyword, req)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to search posts")
|
||||
return
|
||||
}
|
||||
|
||||
result.Items = h.filterBlockedPosts(c.Request.Context(), result.Items, currentUserID)
|
||||
|
||||
// 批量获取交互状态
|
||||
postIDs := make([]string, len(result.Items))
|
||||
for i, post := range result.Items {
|
||||
postIDs[i] = post.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
chMap := h.channelMapForPosts(result.Items)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.PostCursorPageResponse{
|
||||
Items: postResponses,
|
||||
NextCursor: result.NextCursor,
|
||||
PrevCursor: result.PrevCursor,
|
||||
HasMore: result.HasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// RecordRefClick 记录内链点击
|
||||
func (h *PostHandler) RecordRefClick(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
type RefClickRequest struct {
|
||||
TargetPostID string `json:"target_post_id" binding:"required"`
|
||||
}
|
||||
var req RefClickRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "target_post_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if h.postRefService != nil {
|
||||
if err := h.postRefService.RecordClick(c.Request.Context(), id, req.TargetPostID); err != nil {
|
||||
log.Printf("[WARN] record ref click failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetRelatedPosts 获取引用当前帖子的相关帖子
|
||||
func (h *PostHandler) GetRelatedPosts(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
page, pageSize = normalizePagination(page, pageSize)
|
||||
|
||||
if h.postRefService == nil {
|
||||
response.Paginated(c, []*dto.PostResponse{}, 0, page, pageSize)
|
||||
return
|
||||
}
|
||||
|
||||
posts, total, err := h.postRefService.GetRelatedPosts(c.Request.Context(), id, page, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get related posts")
|
||||
return
|
||||
}
|
||||
|
||||
currentUserID := c.GetString("user_id")
|
||||
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
|
||||
|
||||
postIDs := make([]string, len(posts))
|
||||
for i, p := range posts {
|
||||
postIDs[i] = p.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
chMap := h.channelMapForPosts(posts)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetReferencedPosts 获取当前帖子引用的目标帖子
|
||||
func (h *PostHandler) GetReferencedPosts(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
if h.postRefService == nil {
|
||||
response.Success(c, []*dto.PostResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
posts, err := h.postRefService.GetReferencedPosts(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get referenced posts")
|
||||
return
|
||||
}
|
||||
|
||||
currentUserID := c.GetString("user_id")
|
||||
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
|
||||
|
||||
postIDs := make([]string, len(posts))
|
||||
for i, p := range posts {
|
||||
postIDs[i] = p.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
chMap := h.channelMapForPosts(posts)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
response.Success(c, postResponses)
|
||||
}
|
||||
|
||||
// Suggest 帖子搜索联想
|
||||
func (h *PostHandler) Suggest(c *gin.Context) {
|
||||
q := c.Query("q")
|
||||
if q == "" {
|
||||
response.Success(c, []dto.PostRefBrief{})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
briefs, err := h.postRefService.SuggestPosts(c.Request.Context(), q, limit)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to search posts")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, briefs)
|
||||
}
|
||||
|
||||
// parseCursorRequest 解析游标分页请求参数
|
||||
func (h *PostHandler) parseCursorRequest(c *gin.Context) *cursor.PageRequest {
|
||||
cursorStr := c.Query("cursor")
|
||||
direction := cursor.Direction(c.DefaultQuery("direction", "forward"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
return cursor.NewPageRequest(cursorStr, direction, pageSize)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -67,7 +67,7 @@ func (h *PushHandler) UnregisterDevice(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.pushService.UnregisterDevice(c.Request.Context(), deviceID)
|
||||
err := h.pushService.UnregisterDevice(c.Request.Context(), userID, deviceID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to unregister device")
|
||||
return
|
||||
@@ -85,12 +85,13 @@ func (h *PushHandler) GetMyDevices(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 这里需要从DeviceTokenRepository获取设备列表
|
||||
// 由于PushService接口没有提供获取设备列表的方法,我们暂时返回空列表
|
||||
// TODO: 在PushService接口中添加GetUserDevices方法
|
||||
_ = userID // 避免未使用变量警告
|
||||
devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get devices")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, []*dto.DeviceTokenResponse{})
|
||||
response.Success(c, dto.DeviceTokensToResponse(devices))
|
||||
}
|
||||
|
||||
// GetPushRecords 获取推送记录
|
||||
@@ -140,14 +141,14 @@ func (h *PushHandler) UpdateDeviceToken(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PushToken string `json:"push_token" binding:"required"`
|
||||
PushToken string `json:"push_token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err := h.pushService.UpdateDeviceToken(c.Request.Context(), deviceID, req.PushToken)
|
||||
err := h.pushService.UpdateDeviceToken(c.Request.Context(), userID, deviceID, req.PushToken)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to update device token")
|
||||
return
|
||||
|
||||
230
internal/handler/qrcode_handler.go
Normal file
230
internal/handler/qrcode_handler.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/pkg/ws"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// QRCodeHandler 二维码登录处理器
|
||||
type QRCodeHandler struct {
|
||||
qrcodeService service.QRCodeLoginService
|
||||
}
|
||||
|
||||
// NewQRCodeHandler 创建二维码登录处理器
|
||||
func NewQRCodeHandler(qrcodeService service.QRCodeLoginService) *QRCodeHandler {
|
||||
return &QRCodeHandler{
|
||||
qrcodeService: qrcodeService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetQRCode 获取二维码
|
||||
// GET /api/v1/auth/qrcode
|
||||
func (h *QRCodeHandler) GetQRCode(c *gin.Context) {
|
||||
session, err := h.qrcodeService.CreateSession(c.Request.Context())
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to create qrcode session")
|
||||
return
|
||||
}
|
||||
|
||||
// 生成二维码URL (deeplink格式)
|
||||
qrcodeURL := fmt.Sprintf("carrotbbs://qrcode/login?session_id=%s", session.SessionID)
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"session_id": session.SessionID,
|
||||
"qrcode_url": qrcodeURL,
|
||||
"expires_in": 300, // 5分钟
|
||||
"expires_at": session.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
// WSEvents WebSocket事件流
|
||||
// GET /api/v1/auth/qrcode/events?session_id=xxx
|
||||
func (h *QRCodeHandler) WSEvents(c *gin.Context) {
|
||||
sessionID := c.Query("session_id")
|
||||
if sessionID == "" {
|
||||
response.BadRequest(c, "session_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
ch, cancel := h.qrcodeService.GetWSHub().Subscribe(sessionID, 0)
|
||||
defer cancel()
|
||||
|
||||
w := c.Writer
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
response.InternalServerError(c, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Status(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
writeEvent := func(ev ws.Event) bool {
|
||||
data, err := ws.EncodeData(ev)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Event, data); err != nil {
|
||||
return false
|
||||
}
|
||||
flusher.Flush()
|
||||
return true
|
||||
}
|
||||
|
||||
// 心跳
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case ev, ok := <-ch:
|
||||
if !ok || !writeEvent(ev) {
|
||||
return
|
||||
}
|
||||
case <-heartbeat.C:
|
||||
if _, err := fmt.Fprint(w, "event: heartbeat\ndata: {}\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan 扫描二维码
|
||||
// POST /api/v1/auth/qrcode/scan
|
||||
func (h *QRCodeHandler) Scan(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
type ScanRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
}
|
||||
|
||||
var req ScanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.qrcodeService.Scan(c.Request.Context(), req.SessionID, userID); err != nil {
|
||||
if err.Error() == "qrcode already scanned" {
|
||||
response.BadRequest(c, "qrcode already scanned by others")
|
||||
return
|
||||
}
|
||||
if err.Error() == "session not found or expired" {
|
||||
response.NotFound(c, "qrcode expired")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to scan qrcode")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息返回
|
||||
user, err := h.qrcodeService.GetUserService().GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get user info")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"nickname": user.Nickname,
|
||||
"avatar": user.Avatar,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Confirm 确认登录
|
||||
// POST /api/v1/auth/qrcode/confirm
|
||||
func (h *QRCodeHandler) Confirm(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
type ConfirmRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
}
|
||||
|
||||
var req ConfirmRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
result, err := h.qrcodeService.Confirm(c.Request.Context(), req.SessionID, userID, ip, userAgent)
|
||||
if err != nil {
|
||||
if err.Error() == "unauthorized" {
|
||||
response.Unauthorized(c, "not authorized to confirm this login")
|
||||
return
|
||||
}
|
||||
if err.Error() == "session not found or expired" {
|
||||
response.NotFound(c, "qrcode expired")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to confirm login")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"success": true,
|
||||
"user": gin.H{
|
||||
"id": result.User.ID,
|
||||
"username": result.User.Username,
|
||||
"nickname": result.User.Nickname,
|
||||
"avatar": result.User.Avatar,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Cancel 取消登录
|
||||
// POST /api/v1/auth/qrcode/cancel
|
||||
func (h *QRCodeHandler) Cancel(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
type CancelRequest struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
}
|
||||
|
||||
var req CancelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.qrcodeService.Cancel(c.Request.Context(), req.SessionID, userID); err != nil {
|
||||
if err.Error() == "unauthorized" {
|
||||
response.Unauthorized(c, "not authorized to cancel this login")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to cancel login")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
61
internal/handler/report_handler.go
Normal file
61
internal/handler/report_handler.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ReportHandler 举报处理器
|
||||
type ReportHandler struct {
|
||||
reportService service.ReportService
|
||||
}
|
||||
|
||||
// NewReportHandler 创建举报处理器
|
||||
func NewReportHandler(reportService service.ReportService) *ReportHandler {
|
||||
return &ReportHandler{
|
||||
reportService: reportService,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建举报
|
||||
func (h *ReportHandler) Create(c *gin.Context) {
|
||||
// 获取用户ID
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
var req dto.CreateReportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 创建举报
|
||||
report, err := h.reportService.CreateReport(
|
||||
c.Request.Context(),
|
||||
userID,
|
||||
req.TargetType,
|
||||
req.TargetID,
|
||||
req.Reason,
|
||||
req.Description,
|
||||
)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to create report",
|
||||
zap.Error(err),
|
||||
zap.String("user_id", userID),
|
||||
zap.String("target_type", req.TargetType),
|
||||
zap.String("target_id", req.TargetID),
|
||||
)
|
||||
response.Error(c, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.ConvertReportToResponse(report))
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
apperrors "carrot_bbs/internal/errors"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/service"
|
||||
apperrors "with_you/internal/errors"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
type ScheduleHandler struct {
|
||||
|
||||
38
internal/handler/setup_handler.go
Normal file
38
internal/handler/setup_handler.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SetupHandler struct {
|
||||
setupService service.SetupService
|
||||
}
|
||||
|
||||
func NewSetupHandler(setupService service.SetupService) *SetupHandler {
|
||||
return &SetupHandler{
|
||||
setupService: setupService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SetupHandler) SetupSuperAdmin(c *gin.Context) {
|
||||
type SetupSuperAdminRequest struct {
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
SetupSecret string `json:"setup_secret" binding:"required"`
|
||||
}
|
||||
|
||||
var req SetupSuperAdminRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "user_id and setup_secret are required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.setupService.SetupSuperAdmin(c.Request.Context(), req.UserID, req.SetupSecret); err != nil {
|
||||
response.HandleError(c, err, "failed to setup super admin")
|
||||
return
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "超级管理员设置成功", nil)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// StickerHandler 自定义表情处理器
|
||||
@@ -81,11 +82,11 @@ func (h *StickerHandler) AddSticker(c *gin.Context) {
|
||||
|
||||
sticker, err := h.stickerService.AddSticker(userID, req.URL, req.Width, req.Height)
|
||||
if err != nil {
|
||||
if err == service.ErrStickerAlreadyExists {
|
||||
if errors.Is(err, service.ErrStickerAlreadyExists) {
|
||||
response.Error(c, http.StatusConflict, "sticker already exists")
|
||||
return
|
||||
}
|
||||
if err == service.ErrInvalidStickerURL {
|
||||
if errors.Is(err, service.ErrInvalidStickerURL) {
|
||||
response.BadRequest(c, "invalid sticker url, only http/https is allowed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,33 +3,24 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/repository"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// SystemMessageHandler 系统消息处理器
|
||||
type SystemMessageHandler struct {
|
||||
systemMsgService service.SystemMessageService
|
||||
notifyRepo *repository.SystemNotificationRepository
|
||||
cache cache.Cache
|
||||
}
|
||||
|
||||
// NewSystemMessageHandler 创建系统消息处理器
|
||||
func NewSystemMessageHandler(
|
||||
systemMsgService service.SystemMessageService,
|
||||
notifyRepo *repository.SystemNotificationRepository,
|
||||
cacheBackend cache.Cache,
|
||||
) *SystemMessageHandler {
|
||||
return &SystemMessageHandler{
|
||||
systemMsgService: systemMsgService,
|
||||
notifyRepo: notifyRepo,
|
||||
cache: cacheBackend,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +33,19 @@ func (h *SystemMessageHandler) GetSystemMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否使用游标分页
|
||||
_, cursorExists := c.GetQuery("cursor")
|
||||
if cursorExists {
|
||||
h.GetSystemMessagesByCursor(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 偏移分页(向后兼容)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
// 获取当前用户的系统通知(从独立表中获取)
|
||||
notifications, total, err := h.notifyRepo.GetByReceiverID(userID, page, pageSize)
|
||||
// 获取当前用户的系统通知
|
||||
notifications, total, err := h.systemMsgService.GetNotifications(userID, page, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get system messages")
|
||||
return
|
||||
@@ -62,6 +61,43 @@ func (h *SystemMessageHandler) GetSystemMessages(c *gin.Context) {
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetSystemMessagesByCursor 游标分页获取系统消息列表
|
||||
func (h *SystemMessageHandler) GetSystemMessagesByCursor(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析游标分页请求
|
||||
cursorStr := c.Query("cursor")
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
// 获取当前用户的系统通知(使用兼容旧版接口)
|
||||
notifications, nextCursor, hasMore, err := h.systemMsgService.GetNotificationsByCursor(userID, cursorStr, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get system messages")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
items := make([]*dto.SystemMessageResponse, 0)
|
||||
for _, n := range notifications {
|
||||
resp := dto.SystemNotificationToResponse(n)
|
||||
items = append(items, resp)
|
||||
}
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.SystemMessageCursorPageResponse{
|
||||
Items: items,
|
||||
NextCursor: nextCursor,
|
||||
PrevCursor: "",
|
||||
HasMore: hasMore,
|
||||
}
|
||||
|
||||
response.Success(c, cursorResp)
|
||||
}
|
||||
|
||||
// GetUnreadCount 获取系统消息未读数
|
||||
// GET /api/v1/messages/system/unread-count
|
||||
func (h *SystemMessageHandler) GetUnreadCount(c *gin.Context) {
|
||||
@@ -72,7 +108,7 @@ func (h *SystemMessageHandler) GetUnreadCount(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 获取当前用户的未读通知数
|
||||
unreadCount, err := h.notifyRepo.GetUnreadCount(userID)
|
||||
unreadCount, err := h.systemMsgService.GetUnreadCount(userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get unread count")
|
||||
return
|
||||
@@ -100,12 +136,11 @@ func (h *SystemMessageHandler) MarkAsRead(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 标记为已读
|
||||
err = h.notifyRepo.MarkAsRead(notificationID, userID)
|
||||
err = h.systemMsgService.MarkAsRead(notificationID, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to mark as read")
|
||||
return
|
||||
}
|
||||
cache.InvalidateUnreadSystem(h.cache, userID)
|
||||
|
||||
response.SuccessWithMessage(c, "marked as read", nil)
|
||||
}
|
||||
@@ -120,12 +155,11 @@ func (h *SystemMessageHandler) MarkAllAsRead(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 标记当前用户所有通知为已读
|
||||
err := h.notifyRepo.MarkAllAsRead(userID)
|
||||
err := h.systemMsgService.MarkAllAsRead(userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to mark all as read")
|
||||
return
|
||||
}
|
||||
cache.InvalidateUnreadSystem(h.cache, userID)
|
||||
|
||||
response.SuccessWithMessage(c, "all messages marked as read", nil)
|
||||
}
|
||||
@@ -147,12 +181,11 @@ func (h *SystemMessageHandler) DeleteSystemMessage(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 删除通知
|
||||
err = h.notifyRepo.Delete(notificationID, userID)
|
||||
err = h.systemMsgService.DeleteNotification(notificationID, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to delete notification")
|
||||
return
|
||||
}
|
||||
cache.InvalidateUnreadSystem(h.cache, userID)
|
||||
|
||||
response.SuccessWithMessage(c, "notification deleted", nil)
|
||||
}
|
||||
|
||||
220
internal/handler/trade_handler.go
Normal file
220
internal/handler/trade_handler.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
type TradeHandler struct {
|
||||
tradeService service.TradeService
|
||||
}
|
||||
|
||||
func NewTradeHandler(tradeService service.TradeService) *TradeHandler {
|
||||
return &TradeHandler{tradeService: tradeService}
|
||||
}
|
||||
|
||||
func (h *TradeHandler) List(c *gin.Context) {
|
||||
currentUserID := c.GetString("user_id")
|
||||
if currentUserID == "" {
|
||||
currentUserID = ""
|
||||
}
|
||||
|
||||
var tradeType *model.TradeType
|
||||
if v := c.Query("trade_type"); v != "" {
|
||||
t := model.TradeType(v)
|
||||
tradeType = &t
|
||||
}
|
||||
|
||||
var category *model.TradeCategory
|
||||
if v := c.Query("category"); v != "" {
|
||||
cat := model.TradeCategory(v)
|
||||
category = &cat
|
||||
}
|
||||
|
||||
cursorStr := c.Query("cursor")
|
||||
pageSize := 20
|
||||
if v := c.Query("page_size"); v != "" {
|
||||
if ps, err := strconv.Atoi(v); err == nil && ps > 0 && ps <= 100 {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
var currentUserIDPtr *string
|
||||
if currentUserID != "" {
|
||||
currentUserIDPtr = ¤tUserID
|
||||
}
|
||||
|
||||
var resp *dto.TradeCursorPageResponse
|
||||
var err error
|
||||
if keyword != "" {
|
||||
resp, err = h.tradeService.Search(c.Request.Context(), keyword, tradeType, category, cursorStr, pageSize, currentUserIDPtr)
|
||||
} else {
|
||||
resp, err = h.tradeService.List(c.Request.Context(), tradeType, category, cursorStr, pageSize, currentUserIDPtr)
|
||||
}
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "获取交易列表失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, resp)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) GetByID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
var currentUserIDPtr *string
|
||||
if currentUserID != "" {
|
||||
currentUserIDPtr = ¤tUserID
|
||||
}
|
||||
|
||||
resp, err := h.tradeService.GetByID(c.Request.Context(), id, currentUserIDPtr)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "获取交易详情失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, resp)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) Create(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateTradeItemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.tradeService.Create(c.Request.Context(), userID, req)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "创建交易失败")
|
||||
return
|
||||
}
|
||||
|
||||
var currentUserIDPtr *string
|
||||
if userID != "" {
|
||||
currentUserIDPtr = &userID
|
||||
}
|
||||
detail, err := h.tradeService.GetByID(c.Request.Context(), item.ID, currentUserIDPtr)
|
||||
if err != nil {
|
||||
response.Success(c, item)
|
||||
return
|
||||
}
|
||||
response.Success(c, detail)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) Update(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
var req dto.UpdateTradeItemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tradeService.Update(c.Request.Context(), userID, id, req); err != nil {
|
||||
response.HandleError(c, err, "更新交易失败")
|
||||
return
|
||||
}
|
||||
|
||||
var currentUserIDPtr *string
|
||||
if userID != "" {
|
||||
currentUserIDPtr = &userID
|
||||
}
|
||||
detail, err := h.tradeService.GetByID(c.Request.Context(), id, currentUserIDPtr)
|
||||
if err != nil {
|
||||
response.Success(c, nil)
|
||||
return
|
||||
}
|
||||
response.Success(c, detail)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) Delete(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if err := h.tradeService.Delete(c.Request.Context(), userID, id); err != nil {
|
||||
response.HandleError(c, err, "删除交易失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) UpdateStatus(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
var req dto.UpdateTradeItemStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tradeService.UpdateStatus(c.Request.Context(), userID, id, model.TradeItemStatus(req.Status)); err != nil {
|
||||
response.HandleError(c, err, "更新交易状态失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) RecordView(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
_ = h.tradeService.RecordView(c.Request.Context(), id)
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) Favorite(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if err := h.tradeService.Favorite(c.Request.Context(), userID, id); err != nil {
|
||||
response.HandleError(c, err, "收藏失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.TradeFavoriteResponse{Favorited: true})
|
||||
}
|
||||
|
||||
func (h *TradeHandler) Unfavorite(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if err := h.tradeService.Unfavorite(c.Request.Context(), userID, id); err != nil {
|
||||
response.HandleError(c, err, "取消收藏失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.TradeFavoriteResponse{Favorited: false})
|
||||
}
|
||||
@@ -3,17 +3,17 @@ package handler
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// UploadHandler 上传处理器
|
||||
type UploadHandler struct {
|
||||
uploadService *service.UploadService
|
||||
uploadService service.UploadService
|
||||
}
|
||||
|
||||
// NewUploadHandler 创建上传处理器
|
||||
func NewUploadHandler(uploadService *service.UploadService) *UploadHandler {
|
||||
func NewUploadHandler(uploadService service.UploadService) *UploadHandler {
|
||||
return &UploadHandler{
|
||||
uploadService: uploadService,
|
||||
}
|
||||
@@ -33,13 +33,17 @@ func (h *UploadHandler) UploadImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
url, err := h.uploadService.UploadImage(c.Request.Context(), file)
|
||||
url, previewURL, previewURLLarge, err := h.uploadService.UploadImage(c.Request.Context(), file)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to upload image")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"url": url})
|
||||
response.Success(c, gin.H{
|
||||
"url": url,
|
||||
"preview_url": previewURL,
|
||||
"preview_url_large": previewURLLarge,
|
||||
})
|
||||
}
|
||||
|
||||
// UploadAvatar 上传头像
|
||||
@@ -88,3 +92,43 @@ func (h *UploadHandler) UploadCover(c *gin.Context) {
|
||||
|
||||
response.Success(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// UploadFile 上传聊天/通用文件(非图片类资源)
|
||||
// form 字段:file(必填)、folder(可选,默认 chat)
|
||||
func (h *UploadHandler) UploadFile(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "file is required")
|
||||
return
|
||||
}
|
||||
|
||||
folder := c.DefaultPostForm("folder", "chat")
|
||||
// 前端(尤其原生端 expo-file-system)无法自定义 multipart filename,
|
||||
// 文件常被复制到缓存目录并以 UUID 命名,导致 header Filename 丢失真实名。
|
||||
// 因此前端额外把真实文件名通过 "name" 字段回传,作为权威展示名。
|
||||
displayName := c.PostForm("name")
|
||||
|
||||
result, err := h.uploadService.UploadFile(c.Request.Context(), file, folder, userID, displayName)
|
||||
if err != nil {
|
||||
// 参数类错误(大小/类型)返回 400,其余返回 500
|
||||
if file.Size > 0 && file.Size <= 100<<20 {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to upload file")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"url": result.URL,
|
||||
"name": result.Name,
|
||||
"size": result.Size,
|
||||
"mime_type": result.MimeType,
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user