Compare commits
147 Commits
9a2ef67c85
...
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 |
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
|
# Build artifacts
|
||||||
server
|
/server
|
||||||
|
*.exe
|
||||||
*.tar
|
*.tar
|
||||||
|
|
||||||
# Runtime files
|
# Runtime files
|
||||||
@@ -14,3 +15,7 @@ logs/
|
|||||||
# Local environment files
|
# Local environment files
|
||||||
.env
|
.env
|
||||||
.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
|
COPY configs ./configs
|
||||||
|
|
||||||
# 给可执行文件添加执行权限
|
# 给可执行文件添加执行权限,并创建运行时所需目录
|
||||||
RUN chmod +x ./carrot_bbs
|
RUN chmod +x ./carrot_bbs \
|
||||||
|
&& mkdir -p logs data
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 8080
|
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
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"gorm.io/driver/postgres"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config 数据库配置
|
|
||||||
type Config struct {
|
|
||||||
Host string
|
|
||||||
Port int
|
|
||||||
User string
|
|
||||||
Password string
|
|
||||||
DBName string
|
|
||||||
SSLMode string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PostImage struct {
|
|
||||||
ID string `gorm:"type:varchar(36);primaryKey"`
|
|
||||||
PostID string `gorm:"type:varchar(36);index"`
|
|
||||||
URL string `gorm:"type:text"`
|
|
||||||
ThumbnailURL string `gorm:"type:text"`
|
|
||||||
PreviewURL string `gorm:"type:text"`
|
|
||||||
PreviewURLLarge string `gorm:"type:text"`
|
|
||||||
Width int `gorm:"default:0"`
|
|
||||||
Height int `gorm:"default:0"`
|
|
||||||
Size int64 `gorm:"default:0"`
|
|
||||||
MimeType string `gorm:"type:varchar(50)"`
|
|
||||||
SortOrder int `gorm:"default:0"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (PostImage) TableName() string {
|
|
||||||
return "post_images"
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// 解析命令行参数
|
|
||||||
host := flag.String("host", "localhost", "数据库主机")
|
|
||||||
port := flag.Int("port", 5432, "数据库端口")
|
|
||||||
user := flag.String("user", "postgres", "数据库用户")
|
|
||||||
password := flag.String("password", "", "数据库密码")
|
|
||||||
dbname := flag.String("dbname", "carrot_bbs", "数据库名称")
|
|
||||||
sslmode := flag.String("sslmode", "disable", "SSL 模式")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
Host: *host,
|
|
||||||
Port: *port,
|
|
||||||
User: *user,
|
|
||||||
Password: *password,
|
|
||||||
DBName: *dbname,
|
|
||||||
SSLMode: *sslmode,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 连接数据库
|
|
||||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
|
||||||
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode)
|
|
||||||
|
|
||||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试连接
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to get database instance: %v", err)
|
|
||||||
}
|
|
||||||
defer sqlDB.Close()
|
|
||||||
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
|
||||||
log.Fatalf("Failed to ping database: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Connected to database")
|
|
||||||
|
|
||||||
// 运行迁移
|
|
||||||
if err := runMigration(db); err != nil {
|
|
||||||
log.Fatalf("Migration failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Migration completed successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMigration(db *gorm.DB) error {
|
|
||||||
// 检查字段是否已存在
|
|
||||||
columns, err := db.Migrator().ColumnTypes(&PostImage{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check column existence: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasPreviewURL := false
|
|
||||||
hasPreviewURLLarge := false
|
|
||||||
for _, col := range columns {
|
|
||||||
if col.Name() == "preview_url" {
|
|
||||||
hasPreviewURL = true
|
|
||||||
}
|
|
||||||
if col.Name() == "preview_url_large" {
|
|
||||||
hasPreviewURLLarge = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasPreviewURL && hasPreviewURLLarge {
|
|
||||||
log.Println("Columns already exist, skipping migration")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加字段
|
|
||||||
if !hasPreviewURL {
|
|
||||||
log.Println("Adding preview_url column...")
|
|
||||||
if err := db.Exec("ALTER TABLE post_images ADD COLUMN preview_url TEXT").Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to add preview_url column: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasPreviewURLLarge {
|
|
||||||
log.Println("Adding preview_url_large column...")
|
|
||||||
if err := db.Exec("ALTER TABLE post_images ADD COLUMN preview_url_large TEXT").Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to add preview_url_large column: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Migration completed successfully")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注意:此脚本仅用于添加数据库字段
|
|
||||||
// 预览图的生成由后端的 upload_service 自动处理
|
|
||||||
// 现有图片需要重新上传才能生成预览图,或者编写额外的后台任务来生成
|
|
||||||
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]
|
[policy_effect]
|
||||||
e = some(where (p.eft == allow))
|
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]
|
[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
|
# 环境变量: APP_SERVER_HOST, APP_SERVER_PORT, APP_SERVER_MODE
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
@@ -13,14 +13,38 @@ server:
|
|||||||
database:
|
database:
|
||||||
type: sqlite # sqlite 或 postgres
|
type: sqlite # sqlite 或 postgres
|
||||||
sqlite:
|
sqlite:
|
||||||
path: ./data/carrot_bbs.db
|
path: ./data/with_you.db
|
||||||
postgres:
|
postgres:
|
||||||
host: localhost
|
host: localhost
|
||||||
port: 5432
|
port: 5432
|
||||||
user: postgres
|
user: postgres
|
||||||
password: postgres
|
password: postgres
|
||||||
dbname: carrot_bbs
|
dbname: with_you
|
||||||
sslmode: disable
|
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_idle_conns: 10
|
||||||
max_open_conns: 100
|
max_open_conns: 100
|
||||||
log_level: warn
|
log_level: warn
|
||||||
@@ -64,6 +88,15 @@ cache:
|
|||||||
unread_count_ttl: 30
|
unread_count_ttl: 30
|
||||||
group_members_ttl: 120
|
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对象存储配置
|
# S3对象存储配置
|
||||||
# 环境变量: APP_S3_ENDPOINT, APP_S3_ACCESS_KEY, APP_S3_SECRET_KEY, APP_S3_BUCKET, APP_S3_DOMAIN
|
# 环境变量: APP_S3_ENDPOINT, APP_S3_ACCESS_KEY, APP_S3_SECRET_KEY, APP_S3_BUCKET, APP_S3_DOMAIN
|
||||||
s3:
|
s3:
|
||||||
@@ -110,7 +143,7 @@ log:
|
|||||||
- bank_card
|
- bank_card
|
||||||
rotation:
|
rotation:
|
||||||
max_size: 100
|
max_size: 100
|
||||||
max_age: 30
|
max_age: 180
|
||||||
max_backups: 10
|
max_backups: 10
|
||||||
compress: true
|
compress: true
|
||||||
|
|
||||||
@@ -126,6 +159,14 @@ upload:
|
|||||||
- image/gif
|
- image/gif
|
||||||
- image/webp
|
- 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:
|
sensitive:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -153,18 +194,6 @@ audit:
|
|||||||
baidu_api_key: ""
|
baidu_api_key: ""
|
||||||
baidu_secret_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兼容接口配置(用于帖子审核,支持图文)
|
# OpenAI兼容接口配置(用于帖子审核,支持图文)
|
||||||
# 环境变量:
|
# 环境变量:
|
||||||
# APP_OPENAI_ENABLED, APP_OPENAI_BASE_URL, APP_OPENAI_API_KEY
|
# APP_OPENAI_ENABLED, APP_OPENAI_BASE_URL, APP_OPENAI_API_KEY
|
||||||
@@ -179,6 +208,19 @@ openai:
|
|||||||
request_timeout: 30
|
request_timeout: 30
|
||||||
strict_moderation: false
|
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)
|
# SMTP发信配置(gomail.v2)
|
||||||
# 环境变量:
|
# 环境变量:
|
||||||
# APP_EMAIL_ENABLED, APP_EMAIL_HOST, APP_EMAIL_PORT
|
# APP_EMAIL_ENABLED, APP_EMAIL_HOST, APP_EMAIL_PORT
|
||||||
@@ -192,7 +234,7 @@ email:
|
|||||||
username: "no-reply@qczlit.cn"
|
username: "no-reply@qczlit.cn"
|
||||||
password: "HbvwwVjRyiWg9gsK"
|
password: "HbvwwVjRyiWg9gsK"
|
||||||
from_address: "no-reply@qczlit.cn"
|
from_address: "no-reply@qczlit.cn"
|
||||||
from_name: "Carrot BBS"
|
from_name: "WithYou"
|
||||||
use_tls: true
|
use_tls: true
|
||||||
insecure_skip_verify: false
|
insecure_skip_verify: false
|
||||||
timeout: 15
|
timeout: 15
|
||||||
@@ -246,3 +288,125 @@ casbin:
|
|||||||
- /api/v1/auth/password/send-code
|
- /api/v1/auth/password/send-code
|
||||||
- /api/v1/auth/password/reset
|
- /api/v1/auth/password/reset
|
||||||
- /api/v1/auth/refresh
|
- /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
|
timeout: 20s
|
||||||
retries: 3
|
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:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -76,3 +128,5 @@ volumes:
|
|||||||
# postgres_data:
|
# postgres_data:
|
||||||
# redis_data:
|
# redis_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
|
livekit_redis_data:
|
||||||
|
livekit_pg_data:
|
||||||
|
|||||||
178
go.mod
178
go.mod
@@ -1,115 +1,159 @@
|
|||||||
module carrot_bbs
|
module with_you
|
||||||
|
|
||||||
go 1.25
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alicebob/miniredis/v2 v2.31.0
|
github.com/alicebob/miniredis/v2 v2.37.0
|
||||||
github.com/casbin/casbin/v2 v2.135.0
|
|
||||||
github.com/casbin/casbin/v3 v3.10.0
|
github.com/casbin/casbin/v3 v3.10.0
|
||||||
github.com/casbin/gorm-adapter/v3 v3.41.0
|
github.com/casbin/gorm-adapter/v3 v3.41.0
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/cespare/xxhash/v2 v2.3.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
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/uuid v1.6.0
|
||||||
github.com/google/wire v0.7.0
|
github.com/google/wire v0.7.0
|
||||||
github.com/gorse-io/gorse-go v0.5.0-alpha.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/minio/minio-go/v7 v7.0.66
|
github.com/livekit/protocol v1.39.4-0.20250721114233-52633eee694f
|
||||||
github.com/redis/go-redis/v9 v9.3.1
|
github.com/minio/minio-go/v7 v7.0.99
|
||||||
github.com/spf13/viper v1.18.2
|
github.com/redis/go-redis/v9 v9.18.0
|
||||||
go.uber.org/zap v1.26.0
|
github.com/spf13/viper v1.21.0
|
||||||
golang.org/x/crypto v0.46.0
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/image v0.24.0
|
golang.org/x/crypto v0.49.0
|
||||||
google.golang.org/grpc v1.59.0
|
golang.org/x/image v0.38.0
|
||||||
google.golang.org/protobuf v1.31.0
|
google.golang.org/grpc v1.79.3
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
|
gorm.io/plugin/dbresolver v1.6.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 // indirect
|
||||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
|
buf.build/go/protovalidate v0.14.0 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
buf.build/go/protoyaml v0.6.0 // indirect
|
||||||
github.com/bytedance/sonic v1.9.1 // 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/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/dennwc/iters v1.1.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
github.com/frostbyte73/core v0.1.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // 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/go-sqlite v1.22.0 // indirect
|
||||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||||
github.com/go-logr/logr v1.2.3 // indirect
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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/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/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||||
github.com/golang/protobuf v1.5.3 // indirect
|
github.com/google/cel-go v0.26.0 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.17.4 // indirect
|
github.com/jxskiss/base62 v1.1.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // 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-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||||
github.com/microsoft/go-mssqldb v1.9.5 // 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/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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rs/xid v1.5.0 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/afero v1.11.0 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/spf13/cast v1.6.0 // indirect
|
github.com/stoewer/go-strcase v1.3.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
|
||||||
github.com/subosito/gotenv v1.6.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/yuin/gopher-lua v1.1.0 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.4 // indirect
|
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||||
go.opentelemetry.io/otel v1.11.1 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
go.opentelemetry.io/otel/metric v0.33.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.11.1 // indirect
|
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
|
||||||
golang.org/x/arch v0.3.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/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // 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/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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/driver/mysql v1.6.0 // indirect
|
gorm.io/driver/mysql v1.6.0 // indirect
|
||||||
gorm.io/driver/sqlserver v1.6.3 // indirect
|
gorm.io/driver/sqlserver v1.6.3 // indirect
|
||||||
gorm.io/plugin/dbresolver v1.6.2 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/libc v1.67.4 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.42.2 // indirect
|
modernc.org/sqlite v1.48.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
476
go.sum
476
go.sum
@@ -1,46 +1,64 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 h1:Lg6klmCi3v7VvpqeeLEER9/m5S8y9e9DjhqQnSCNy4k=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
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.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.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.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.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||||
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/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.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.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.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||||
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/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.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.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.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.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||||
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/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.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.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA=
|
||||||
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/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.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.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
|
||||||
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/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.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.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.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||||
github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||||
github.com/alicebob/miniredis/v2 v2.31.0 h1:ObEFUNlJwoIiyjxdrYF0QIDE7qXcLc7D3WpSH4c22PU=
|
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
|
||||||
github.com/alicebob/miniredis/v2 v2.31.0/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
|
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.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.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
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 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
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/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
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 h1:039ORla55vCeIZWd0LfzWFt1yiEA5X4W41xBW2bQuHs=
|
||||||
github.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
|
github.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
|
||||||
github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=
|
github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=
|
||||||
@@ -48,46 +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.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 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
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/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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/dennwc/iters v1.1.0 h1:PsS3DbOU7GxSUQO0e7SGmzHkPhtwOlwbqggJ++Bgnr8=
|
||||||
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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
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 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
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.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
github.com/gammazero/deque v1.1.0 h1:OyiyReBbnEG2PP0Bnv1AASLIYvyKqIFN5xfl1t8oGLo=
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
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 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
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 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
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-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
@@ -96,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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
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 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
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.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.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.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 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
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 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
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/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
|
||||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
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/google/go-cmp v0.6.0/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/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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@@ -129,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/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/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/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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorse-io/gorse-go v0.5.0-alpha.3/go.mod h1:ZxmVHzZPKm5pmEIlqaRDwK0rkfTRHlfziO033XZ+RW0=
|
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.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/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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
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.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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=
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
@@ -158,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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
|
||||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
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.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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -173,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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
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 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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.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.8 h1:d4IFMvF/o+HdpXUqbBfzHvn/NlFA75YGcfHUUvDFJEM=
|
||||||
github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q=
|
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 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
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.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE=
|
||||||
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
|
github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||||
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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
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/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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -201,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/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/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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
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-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 h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
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/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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||||
github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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.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/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
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 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
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 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
|
||||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -251,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.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.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.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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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/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.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.4 h1:aUEBEdCa6iamGzg6fuYxDA8ThxvOG240mAvWDU+XLio=
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.4/go.mod h1:l2MdsbKTocpPS5nQZscqTR9jd8u96VYZdcpF8Sye7mA=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
go.opentelemetry.io/otel v1.11.1 h1:4WLLAmcfkmDk2ukNXJyq3/kiz/3UzCaYq6PskJsaou4=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
go.opentelemetry.io/otel v1.11.1/go.mod h1:1nNhXBbWSD0nsL38H6btgnFN2k4i0sNLHNNMZMSbUGE=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.opentelemetry.io/otel/metric v0.33.0 h1:xQAyl7uGEYvrLAiV/09iTJlp1pZnQ9Wl793qbVvED1E=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.opentelemetry.io/otel/metric v0.33.0/go.mod h1:QlTYc+EnYNq/M2mNk1qDDMRLpqCOj2f/r5c7Fd5FYaI=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.opentelemetry.io/otel/trace v1.11.1 h1:ofxdnzsNrGBYXbP7t7zpUK281+go5rF7dvdIZXF8gdQ=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/otel/trace v1.11.1/go.mod h1:f/Q9G7vzk5u91PhbmKbg1Qn0rzH1LJ4vbPHFGkTPtOk=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||||
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||||
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
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-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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
@@ -294,22 +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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
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 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.9.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.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.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.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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
@@ -327,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.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
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.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -336,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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.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.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
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-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-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-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-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.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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -359,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.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.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.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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/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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -389,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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
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.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
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-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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
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.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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||||
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||||
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
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 h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -417,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/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 h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
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.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.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.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.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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
@@ -441,18 +572,18 @@ gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
|
|||||||
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
|
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 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
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.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -461,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/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
|
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||||
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
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
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrKeyNotFound = errors.New("key not found")
|
||||||
|
|
||||||
// Cache 缓存接口
|
// Cache 缓存接口
|
||||||
type Cache interface {
|
type Cache interface {
|
||||||
// Set 设置缓存值,支持TTL
|
// Set 设置缓存值,支持TTL
|
||||||
Set(key string, value interface{}, ttl time.Duration)
|
Set(key string, value any, ttl time.Duration)
|
||||||
// Get 获取缓存值
|
// Get 获取缓存值
|
||||||
Get(key string) (interface{}, bool)
|
Get(key string) (any, bool)
|
||||||
// Delete 删除缓存
|
// Delete 删除缓存
|
||||||
Delete(key string)
|
Delete(key string)
|
||||||
// DeleteByPrefix 根据前缀删除缓存
|
// DeleteByPrefix 根据前缀删除缓存
|
||||||
@@ -26,20 +32,24 @@ type Cache interface {
|
|||||||
Increment(key string) int64
|
Increment(key string) int64
|
||||||
// IncrementBy 增加指定值
|
// IncrementBy 增加指定值
|
||||||
IncrementBy(key string, value int64) int64
|
IncrementBy(key string, value int64) int64
|
||||||
|
// DeleteBatch 批量删除多个 key(使用 Pipeline 减少网络往返)
|
||||||
|
DeleteBatch(keys []string)
|
||||||
|
|
||||||
// ==================== Hash 操作 ====================
|
// ==================== Hash 操作 ====================
|
||||||
// HSet 设置 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 批量设置 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 获取 Hash 字段值
|
||||||
HGet(ctx context.Context, key string, field string) (string, error)
|
HGet(ctx context.Context, key string, field string) (string, error)
|
||||||
// HMGet 批量获取 Hash 字段值
|
// 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 获取 Hash 所有字段
|
||||||
HGetAll(ctx context.Context, key string) (map[string]string, error)
|
HGetAll(ctx context.Context, key string) (map[string]string, error)
|
||||||
// HDel 删除 Hash 字段
|
// HDel 删除 Hash 字段
|
||||||
HDel(ctx context.Context, key string, fields ...string) error
|
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 操作 ====================
|
// ==================== Sorted Set 操作 ====================
|
||||||
// ZAdd 添加 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)
|
ZRangeByScore(ctx context.Context, key string, min, max string, offset, count int64) ([]string, error)
|
||||||
// ZRevRangeByScore 按分数范围获取成员(降序)
|
// ZRevRangeByScore 按分数范围获取成员(降序)
|
||||||
ZRevRangeByScore(ctx context.Context, key string, max, min string, offset, count int64) ([]string, error)
|
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 删除 Sorted Set 成员
|
||||||
ZRem(ctx context.Context, key string, members ...interface{}) error
|
ZRem(ctx context.Context, key string, members ...any) error
|
||||||
// ZCard 获取 Sorted Set 成员数量
|
// ZCard 获取 Sorted Set 成员数量
|
||||||
ZCard(ctx context.Context, key string) (int64, error)
|
ZCard(ctx context.Context, key string) (int64, error)
|
||||||
|
|
||||||
// ==================== 计数器操作 ====================
|
// ==================== 计数器操作 ====================
|
||||||
// Incr 原子递增(返回新值)
|
// Incr 原子递增(返回新值)
|
||||||
Incr(ctx context.Context, key string) (int64, error)
|
Incr(ctx context.Context, key string) (int64, error)
|
||||||
|
// IncrBySeq 原子递增指定值(用于 seq 对齐)
|
||||||
|
IncrBySeq(ctx context.Context, key string, delta int64) (int64, error)
|
||||||
// Expire 设置过期时间
|
// Expire 设置过期时间
|
||||||
Expire(ctx context.Context, key string, ttl time.Duration) error
|
Expire(ctx context.Context, key string, ttl time.Duration) error
|
||||||
}
|
}
|
||||||
@@ -83,7 +99,7 @@ var settings = Settings{
|
|||||||
NullTTL: 5 * time.Second,
|
NullTTL: 5 * time.Second,
|
||||||
JitterRatio: 0.1,
|
JitterRatio: 0.1,
|
||||||
PostListTTL: 30 * time.Second,
|
PostListTTL: 30 * time.Second,
|
||||||
ConversationTTL: 60 * time.Second,
|
ConversationTTL: 120 * time.Second,
|
||||||
UnreadCountTTL: 30 * time.Second,
|
UnreadCountTTL: 30 * time.Second,
|
||||||
GroupMembersTTL: 120 * time.Second,
|
GroupMembersTTL: 120 * time.Second,
|
||||||
DisableFlushDB: true,
|
DisableFlushDB: true,
|
||||||
@@ -123,20 +139,19 @@ func GetSettings() Settings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeKey(key string) string {
|
func normalizeKey(key string) string {
|
||||||
if settings.KeyPrefix == "" {
|
return cmp.Or(settings.KeyPrefix, "") + ":" + key
|
||||||
return key
|
}
|
||||||
}
|
|
||||||
return settings.KeyPrefix + ":" + key
|
// ResolveKey 返回带全局前缀的实际 Redis 键(供需直连 Redis 的组件使用)
|
||||||
|
func ResolveKey(key string) string {
|
||||||
|
return normalizeKey(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePrefix(prefix string) string {
|
func normalizePrefix(prefix string) string {
|
||||||
if settings.KeyPrefix == "" {
|
return cmp.Or(settings.KeyPrefix, "") + ":" + prefix
|
||||||
return prefix
|
|
||||||
}
|
|
||||||
return 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 {
|
if !settings.Enabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
401
internal/cache/conversation_cache.go
vendored
401
internal/cache/conversation_cache.go
vendored
@@ -1,15 +1,45 @@
|
|||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"time"
|
"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 带缓存元数据的会话
|
// CachedConversation 带缓存元数据的会话
|
||||||
type CachedConversation struct {
|
type CachedConversation struct {
|
||||||
Data *model.Conversation // 实际数据
|
Data *model.Conversation // 实际数据
|
||||||
@@ -45,8 +75,6 @@ type MessageCacheData struct {
|
|||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
SystemType string `json:"system_type,omitempty"`
|
SystemType string `json:"system_type,omitempty"`
|
||||||
ExtraData json.RawMessage `json:"extra_data,omitempty"`
|
ExtraData json.RawMessage `json:"extra_data,omitempty"`
|
||||||
MentionUsers string `json:"mention_users"`
|
|
||||||
MentionAll bool `json:"mention_all"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -73,19 +101,23 @@ type ConversationCacheSettings struct {
|
|||||||
MessageListTTL time.Duration // 消息分页列表缓存 (5min)
|
MessageListTTL time.Duration // 消息分页列表缓存 (5min)
|
||||||
MessageIndexTTL time.Duration // 消息索引缓存 (30min)
|
MessageIndexTTL time.Duration // 消息索引缓存 (30min)
|
||||||
MessageCountTTL time.Duration // 消息计数缓存 (30min)
|
MessageCountTTL time.Duration // 消息计数缓存 (30min)
|
||||||
|
|
||||||
|
// Seq 缓存配置
|
||||||
|
SeqTTL time.Duration // Seq 计数器缓存 (24h)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConversationCacheSettings 返回默认配置
|
// DefaultConversationCacheSettings 返回默认配置
|
||||||
func DefaultConversationCacheSettings() *ConversationCacheSettings {
|
func DefaultConversationCacheSettings() *ConversationCacheSettings {
|
||||||
return &ConversationCacheSettings{
|
return &ConversationCacheSettings{
|
||||||
DetailTTL: 5 * time.Minute,
|
DetailTTL: 15 * time.Minute,
|
||||||
ListTTL: 60 * time.Second,
|
ListTTL: 120 * time.Second,
|
||||||
ParticipantTTL: 5 * time.Minute,
|
ParticipantTTL: 15 * time.Minute,
|
||||||
UnreadTTL: 30 * time.Second,
|
UnreadTTL: 30 * time.Second,
|
||||||
MessageDetailTTL: 30 * time.Minute,
|
MessageDetailTTL: 30 * time.Minute,
|
||||||
MessageListTTL: 5 * time.Minute,
|
MessageListTTL: 5 * time.Minute,
|
||||||
MessageIndexTTL: 30 * time.Minute,
|
MessageIndexTTL: 30 * time.Minute,
|
||||||
MessageCountTTL: 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),
|
Category: model.MessageCategory(m.Category),
|
||||||
SystemType: model.SystemMessageType(m.SystemType),
|
SystemType: model.SystemMessageType(m.SystemType),
|
||||||
ExtraData: parseExtraData(m.ExtraData),
|
ExtraData: parseExtraData(m.ExtraData),
|
||||||
MentionUsers: m.MentionUsers,
|
|
||||||
MentionAll: m.MentionAll,
|
|
||||||
CreatedAt: m.CreatedAt,
|
CreatedAt: m.CreatedAt,
|
||||||
UpdatedAt: m.UpdatedAt,
|
UpdatedAt: m.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -146,8 +176,6 @@ func MessageCacheDataFromModel(msg *model.Message) *MessageCacheData {
|
|||||||
Category: string(msg.Category),
|
Category: string(msg.Category),
|
||||||
SystemType: string(msg.SystemType),
|
SystemType: string(msg.SystemType),
|
||||||
ExtraData: serializeExtraData(msg.ExtraData),
|
ExtraData: serializeExtraData(msg.ExtraData),
|
||||||
MentionUsers: msg.MentionUsers,
|
|
||||||
MentionAll: msg.MentionAll,
|
|
||||||
CreatedAt: msg.CreatedAt,
|
CreatedAt: msg.CreatedAt,
|
||||||
UpdatedAt: msg.UpdatedAt,
|
UpdatedAt: msg.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -220,6 +248,7 @@ type MessageRepository interface {
|
|||||||
GetMessages(convID string, page, pageSize int) ([]*model.Message, int64, error)
|
GetMessages(convID string, page, pageSize int) ([]*model.Message, int64, error)
|
||||||
GetMessagesAfterSeq(convID string, afterSeq int64, limit int) ([]*model.Message, error)
|
GetMessagesAfterSeq(convID string, afterSeq int64, limit int) ([]*model.Message, error)
|
||||||
GetMessagesBeforeSeq(convID string, beforeSeq 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 会话缓存管理器
|
// ConversationCache 会话缓存管理器
|
||||||
type ConversationCache struct {
|
type ConversationCache struct {
|
||||||
cache Cache // 底层缓存
|
cache Cache // 底层缓存
|
||||||
settings *ConversationCacheSettings // 配置
|
settings *ConversationCacheSettings // 配置
|
||||||
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
||||||
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
||||||
|
seqBufferMgr *SeqBufferManager // seq 预分配管理器(nil 则使用旧 INCR 逻辑)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConversationCache 创建会话缓存管理器
|
// 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 {
|
if settings == nil {
|
||||||
settings = DefaultConversationCacheSettings()
|
settings = DefaultConversationCacheSettings()
|
||||||
}
|
}
|
||||||
return &ConversationCache{
|
return &ConversationCache{
|
||||||
cache: cache,
|
cache: cache,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
msgRepo: msgRepo,
|
msgRepo: msgRepo,
|
||||||
|
seqBufferMgr: seqBufferMgr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +514,289 @@ func (c *ConversationCache) InvalidateUnreadCount(userID, convID string) {
|
|||||||
c.cache.Delete(UnreadDetailKey(userID, convID))
|
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
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheMessage 缓存单条消息(立即写入缓存)
|
// CacheMessage 缓存单条消息(使用 Redis Pipeline 减少网络往返)
|
||||||
// 写入 Hash、Sorted Set、更新计数
|
|
||||||
func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg *model.Message) error {
|
func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg *model.Message) error {
|
||||||
hashKey := MessageHashKey(convID)
|
hashKey := MessageHashKey(convID)
|
||||||
indexKey := MessageIndexKey(convID)
|
indexKey := MessageIndexKey(convID)
|
||||||
|
countKey := MessageCountKey(convID)
|
||||||
|
|
||||||
msgData := MessageCacheDataFromModel(msg)
|
msgData := MessageCacheDataFromModel(msg)
|
||||||
data, err := json.Marshal(msgData)
|
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)
|
return fmt.Errorf("failed to marshal message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HSET 消息详情
|
hashKey = ResolveKey(hashKey)
|
||||||
if err := c.cache.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
|
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)
|
return fmt.Errorf("failed to set hash: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := c.cache.ZAdd(ctx, MessageIndexKey(convID), float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
|
||||||
// ZADD 消息索引
|
|
||||||
if err := c.cache.ZAdd(ctx, indexKey, float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
|
|
||||||
return fmt.Errorf("failed to add to sorted set: %w", err)
|
return fmt.Errorf("failed to add to sorted set: %w", err)
|
||||||
}
|
}
|
||||||
|
c.cache.Expire(ctx, MessageHashKey(convID), c.settings.MessageDetailTTL)
|
||||||
// 设置 TTL
|
c.cache.Expire(ctx, MessageIndexKey(convID), c.settings.MessageIndexTTL)
|
||||||
c.cache.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
|
|
||||||
c.cache.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
|
|
||||||
|
|
||||||
// INCR 消息计数
|
|
||||||
c.cache.Incr(ctx, MessageCountKey(convID))
|
c.cache.Incr(ctx, MessageCountKey(convID))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,6 +1044,10 @@ func (c *ConversationCache) getMessagesBySeqs(ctx context.Context, convID string
|
|||||||
// asyncCacheMessage 异步缓存单条消息
|
// asyncCacheMessage 异步缓存单条消息
|
||||||
func (c *ConversationCache) asyncCacheMessage(ctx context.Context, convID string, msg *model.Message) {
|
func (c *ConversationCache) asyncCacheMessage(ctx context.Context, convID string, msg *model.Message) {
|
||||||
if err := c.CacheMessage(ctx, convID, msg); err != nil {
|
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"
|
PrefixPostList = "posts:list"
|
||||||
PrefixPost = "posts:detail"
|
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"
|
PrefixConversationList = "conversations:list"
|
||||||
@@ -22,24 +26,52 @@ const (
|
|||||||
PrefixUnreadSystem = "unread:system"
|
PrefixUnreadSystem = "unread:system"
|
||||||
PrefixUnreadConversation = "unread:conversation"
|
PrefixUnreadConversation = "unread:conversation"
|
||||||
PrefixUnreadDetail = "unread:detail"
|
PrefixUnreadDetail = "unread:detail"
|
||||||
|
|
||||||
|
// 已读位置相关(OpenIM 风格:缓存 hasReadSeq,用于 O(1) 未读数计算)
|
||||||
|
PrefixUserReadSeq = "user_read_seq"
|
||||||
|
|
||||||
// 用户相关
|
// 用户相关
|
||||||
PrefixUserInfo = "users:info"
|
PrefixUserInfo = "users:info"
|
||||||
PrefixUserMe = "users:me"
|
PrefixUserMe = "users:me"
|
||||||
|
|
||||||
|
// 频道相关(全量列表一条缓存,供 /channels、帖子 channel 填充共用)
|
||||||
|
PrefixChannelsAllList = "channels:all_list"
|
||||||
|
|
||||||
// 消息缓存相关
|
// 消息缓存相关
|
||||||
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
|
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
|
||||||
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
|
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
|
||||||
keyPrefixMsgCount = "msg_count" // 消息计数
|
keyPrefixMsgCount = "msg_count" // 消息计数
|
||||||
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
||||||
keyPrefixMsgPage = "msg_page" // 分页缓存
|
keyPrefixMsgPage = "msg_page" // 分页缓存
|
||||||
|
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
|
||||||
|
|
||||||
|
// 帖子创建幂等键前缀(clientRequestID -> 已创建的 postID)
|
||||||
|
keyPrefixPostIdempotent = "post_idem"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostListKey 生成帖子列表缓存键
|
// PostListKey 生成帖子列表缓存键
|
||||||
// postType: 帖子类型 (recommend, hot, follow, latest)
|
// postType: 帖子类型 (hot, follow, latest)
|
||||||
// page: 页码
|
// page: 页码
|
||||||
// pageSize: 每页数量
|
// pageSize: 每页数量
|
||||||
// userID: 用户维度(仅在个性化列表如 follow 场景使用)
|
// 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 {
|
func PostListKey(postType string, userID string, page, pageSize int) string {
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
return fmt.Sprintf("%s:%s:%d:%d", PrefixPostList, postType, page, pageSize)
|
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)
|
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 生成群组成员缓存键
|
// GroupMembersKey 生成群组成员缓存键
|
||||||
func GroupMembersKey(groupID string, page, pageSize int) string {
|
func GroupMembersKey(groupID string, page, pageSize int) string {
|
||||||
return fmt.Sprintf("%s:%s:page:%d:size:%d", PrefixGroupMembers, groupID, page, pageSize)
|
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 生成系统消息未读数缓存键
|
// UnreadSystemKey 生成系统消息未读数缓存键
|
||||||
func UnreadSystemKey(userID string) string {
|
func UnreadSystemKey(userID string) string {
|
||||||
@@ -92,14 +112,15 @@ func UnreadDetailKey(userID, conversationID string) string {
|
|||||||
return fmt.Sprintf("%s:%s:%s", PrefixUnreadDetail, userID, conversationID)
|
return fmt.Sprintf("%s:%s:%s", PrefixUnreadDetail, userID, conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserInfoKey 生成用户信息缓存键
|
|
||||||
func UserInfoKey(userID string) string {
|
// ChannelsAllListKey 频道全量列表缓存键(含未启用,供 MapByIDs 解析历史帖子 channel)
|
||||||
return fmt.Sprintf("%s:%s", PrefixUserInfo, userID)
|
func ChannelsAllListKey() string {
|
||||||
|
return PrefixChannelsAllList
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserMeKey 生成当前用户信息缓存键
|
// InvalidateChannelList 失效频道列表缓存(管理端增删改频道后调用)
|
||||||
func UserMeKey(userID string) string {
|
func InvalidateChannelList(c Cache) {
|
||||||
return fmt.Sprintf("%s:%s", PrefixUserMe, userID)
|
c.Delete(ChannelsAllListKey())
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidatePostList 失效帖子列表缓存
|
// InvalidatePostList 失效帖子列表缓存
|
||||||
@@ -117,20 +138,12 @@ func InvalidateConversationList(cache Cache, userID string) {
|
|||||||
cache.DeleteByPrefix(PrefixConversationList + ":" + userID + ":")
|
cache.DeleteByPrefix(PrefixConversationList + ":" + userID + ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateConversationDetail 失效会话详情缓存
|
|
||||||
func InvalidateConversationDetail(cache Cache, conversationID, userID string) {
|
|
||||||
cache.Delete(ConversationDetailKey(conversationID, userID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateGroupMembers 失效群组成员缓存
|
// InvalidateGroupMembers 失效群组成员缓存
|
||||||
func InvalidateGroupMembers(cache Cache, groupID string) {
|
func InvalidateGroupMembers(cache Cache, groupID string) {
|
||||||
cache.DeleteByPrefix(PrefixGroupMembers + ":" + groupID)
|
cache.DeleteByPrefix(PrefixGroupMembers + ":" + groupID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateGroupInfo 失效群组信息缓存
|
|
||||||
func InvalidateGroupInfo(cache Cache, groupID string) {
|
|
||||||
cache.Delete(GroupInfoKey(groupID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateUnreadSystem 失效系统消息未读数缓存
|
// InvalidateUnreadSystem 失效系统消息未读数缓存
|
||||||
func InvalidateUnreadSystem(cache Cache, userID string) {
|
func InvalidateUnreadSystem(cache Cache, userID string) {
|
||||||
@@ -147,11 +160,6 @@ func InvalidateUnreadDetail(cache Cache, userID, conversationID string) {
|
|||||||
cache.Delete(UnreadDetailKey(userID, conversationID))
|
cache.Delete(UnreadDetailKey(userID, conversationID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateUserInfo 失效用户信息缓存
|
|
||||||
func InvalidateUserInfo(cache Cache, userID string) {
|
|
||||||
cache.Delete(UserInfoKey(userID))
|
|
||||||
cache.Delete(UserMeKey(userID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 消息缓存 Key 生成函数
|
// 消息缓存 Key 生成函数
|
||||||
@@ -186,3 +194,20 @@ func MessagePageKey(convID string, page, pageSize int) string {
|
|||||||
func InvalidateMessagePages(cache Cache, conversationID string) {
|
func InvalidateMessagePages(cache Cache, conversationID string) {
|
||||||
cache.DeleteByPrefix(fmt.Sprintf("%s:%s:", keyPrefixMsgPage, conversationID))
|
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 全局缓存指标实例
|
// metrics 全局缓存指标实例
|
||||||
var metrics cacheMetrics
|
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 记录缓存命中
|
// recordHit 记录缓存命中
|
||||||
func 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
|
||||||
"time"
|
"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缓存实现
|
// RedisCache Redis缓存实现
|
||||||
@@ -26,31 +26,40 @@ func NewRedisCache(client *redisPkg.Client) *RedisCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set 设置缓存值
|
// 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)
|
key = normalizeKey(key)
|
||||||
// 将值序列化为JSON
|
// 将值序列化为JSON
|
||||||
data, err := json.Marshal(value)
|
data, err := json.Marshal(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
recordSetError()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.client.Set(c.ctx, key, data, ttl); err != nil {
|
if err := c.client.Set(c.ctx, key, data, ttl); err != nil {
|
||||||
recordSetError()
|
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 获取缓存值
|
// Get 获取缓存值
|
||||||
func (c *RedisCache) Get(key string) (interface{}, bool) {
|
func (c *RedisCache) Get(key string) (any, bool) {
|
||||||
key = normalizeKey(key)
|
key = normalizeKey(key)
|
||||||
data, err := c.client.Get(c.ctx, key)
|
data, err := c.client.Get(c.ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == redis.Nil {
|
if err == redislib.Nil {
|
||||||
return nil, false
|
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
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,27 +72,35 @@ func (c *RedisCache) Delete(key string) {
|
|||||||
key = normalizeKey(key)
|
key = normalizeKey(key)
|
||||||
recordInvalidate()
|
recordInvalidate()
|
||||||
if err := c.client.Del(c.ctx, key); err != nil {
|
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 根据前缀删除缓存
|
// DeleteByPrefix 根据前缀删除缓存
|
||||||
func (c *RedisCache) DeleteByPrefix(prefix string) {
|
func (c *RedisCache) DeleteByPrefix(prefix string) {
|
||||||
prefix = normalizePrefix(prefix)
|
prefix = normalizePrefix(prefix)
|
||||||
// 使用原生客户端执行SCAN命令
|
|
||||||
rdb := c.client.GetClient()
|
rdb := c.client.GetClient()
|
||||||
var cursor uint64
|
var cursor uint64
|
||||||
for {
|
for {
|
||||||
keys, nextCursor, err := rdb.Scan(c.ctx, cursor, prefix+"*", 100).Result()
|
keys, nextCursor, err := rdb.Scan(c.ctx, cursor, prefix+"*", 100).Result()
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(keys) > 0 {
|
if len(keys) > 0 {
|
||||||
recordInvalidateMultiple(int64(len(keys)))
|
recordInvalidateMultiple(int64(len(keys)))
|
||||||
if err := c.client.Del(c.ctx, keys...); err != nil {
|
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 清空所有缓存
|
// Clear 清空所有缓存
|
||||||
func (c *RedisCache) Clear() {
|
func (c *RedisCache) Clear() {
|
||||||
if settings.DisableFlushDB {
|
if settings.DisableFlushDB {
|
||||||
log.Printf("[RedisCache] Skip FlushDB because cache.disable_flushdb=true")
|
zap.L().Debug("Skip FlushDB because cache.disable_flushdb=true")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordInvalidate()
|
recordInvalidate()
|
||||||
rdb := c.client.GetClient()
|
rdb := c.client.GetClient()
|
||||||
if err := rdb.FlushDB(c.ctx).Err(); err != nil {
|
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)
|
key = normalizeKey(key)
|
||||||
n, err := c.client.Exists(c.ctx, key)
|
n, err := c.client.Exists(c.ctx, key)
|
||||||
if err != nil {
|
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 false
|
||||||
}
|
}
|
||||||
return n > 0
|
return n > 0
|
||||||
@@ -129,7 +175,10 @@ func (c *RedisCache) IncrementBy(key string, value int64) int64 {
|
|||||||
rdb := c.client.GetClient()
|
rdb := c.client.GetClient()
|
||||||
result, err := rdb.IncrBy(c.ctx, key, value).Result()
|
result, err := rdb.IncrBy(c.ctx, key, value).Result()
|
||||||
if err != nil {
|
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 0
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -138,13 +187,13 @@ func (c *RedisCache) IncrementBy(key string, value int64) int64 {
|
|||||||
// ==================== RedisCache Hash 操作 ====================
|
// ==================== RedisCache Hash 操作 ====================
|
||||||
|
|
||||||
// HSet 设置 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)
|
key = normalizeKey(key)
|
||||||
return c.client.HSet(ctx, key, field, value)
|
return c.client.HSet(ctx, key, field, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HMSet 批量设置 Hash 字段
|
// 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)
|
key = normalizeKey(key)
|
||||||
return c.client.HMSet(ctx, key, values)
|
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 字段值
|
// 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)
|
key = normalizeKey(key)
|
||||||
return c.client.HMGet(ctx, key, fields...)
|
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...)
|
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 操作 ====================
|
// ==================== RedisCache Sorted Set 操作 ====================
|
||||||
|
|
||||||
// ZAdd 添加 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)
|
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 成员
|
// 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)
|
key = normalizeKey(key)
|
||||||
return c.client.ZRem(ctx, key, members...)
|
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)
|
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 设置过期时间
|
// Expire 设置过期时间
|
||||||
func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
||||||
key = normalizeKey(key)
|
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 (
|
import (
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/repository"
|
"with_you/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ConversationRepositoryAdapter 适配 MessageRepository 到 ConversationRepository 接口
|
// ConversationRepositoryAdapter 适配 MessageRepository 到 ConversationRepository 接口
|
||||||
type ConversationRepositoryAdapter struct {
|
type ConversationRepositoryAdapter struct {
|
||||||
repo *repository.MessageRepository
|
repo repository.MessageRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConversationRepositoryAdapter 创建适配器
|
// NewConversationRepositoryAdapter 创建适配器
|
||||||
func NewConversationRepositoryAdapter(repo *repository.MessageRepository) ConversationRepository {
|
func NewConversationRepositoryAdapter(repo repository.MessageRepository) ConversationRepository {
|
||||||
return &ConversationRepositoryAdapter{repo: repo}
|
return &ConversationRepositoryAdapter{repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +42,11 @@ func (a *ConversationRepositoryAdapter) GetUnreadCount(userID, convID string) (i
|
|||||||
|
|
||||||
// MessageRepositoryAdapter 适配 MessageRepository 到 MessageRepository 接口
|
// MessageRepositoryAdapter 适配 MessageRepository 到 MessageRepository 接口
|
||||||
type MessageRepositoryAdapter struct {
|
type MessageRepositoryAdapter struct {
|
||||||
repo *repository.MessageRepository
|
repo repository.MessageRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessageRepositoryAdapter 创建适配器
|
// NewMessageRepositoryAdapter 创建适配器
|
||||||
func NewMessageRepositoryAdapter(repo *repository.MessageRepository) MessageRepository {
|
func NewMessageRepositoryAdapter(repo repository.MessageRepository) MessageRepository {
|
||||||
return &MessageRepositoryAdapter{repo: repo}
|
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 {
|
func (a *MessageRepositoryAdapter) UpdateConversationLastSeq(convID string, seq int64) error {
|
||||||
return a.repo.UpdateConversationLastSeq(convID, seq)
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,12 +19,26 @@ type Config struct {
|
|||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||||
Upload UploadConfig `mapstructure:"upload"`
|
Upload UploadConfig `mapstructure:"upload"`
|
||||||
Gorse GorseConfig `mapstructure:"gorse"`
|
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
|
||||||
|
DeviceCleanup DeviceCleanupConfig `mapstructure:"device_cleanup"`
|
||||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||||
|
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
||||||
Email EmailConfig `mapstructure:"email"`
|
Email EmailConfig `mapstructure:"email"`
|
||||||
ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"`
|
ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"`
|
||||||
GRPC GRPCConfig `mapstructure:"grpc"`
|
GRPC GRPCConfig `mapstructure:"grpc"`
|
||||||
Casbin CasbinConfig `mapstructure:"casbin"`
|
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 加载配置文件
|
// Load 加载配置文件
|
||||||
@@ -45,13 +57,28 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("server.mode", "debug")
|
viper.SetDefault("server.mode", "debug")
|
||||||
viper.SetDefault("server.host", "0.0.0.0")
|
viper.SetDefault("server.host", "0.0.0.0")
|
||||||
viper.SetDefault("database.type", "sqlite")
|
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_idle_conns", 10)
|
||||||
viper.SetDefault("database.max_open_conns", 100)
|
viper.SetDefault("database.max_open_conns", 100)
|
||||||
viper.SetDefault("database.log_level", "warn")
|
viper.SetDefault("database.log_level", "warn")
|
||||||
viper.SetDefault("database.slow_threshold_ms", 200)
|
viper.SetDefault("database.slow_threshold_ms", 200)
|
||||||
viper.SetDefault("database.ignore_record_not_found", true)
|
viper.SetDefault("database.ignore_record_not_found", true)
|
||||||
viper.SetDefault("database.parameterized_queries", 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.type", "miniredis")
|
||||||
viper.SetDefault("redis.redis.host", "localhost")
|
viper.SetDefault("redis.redis.host", "localhost")
|
||||||
viper.SetDefault("redis.redis.port", 6379)
|
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.conversation_ttl", 60)
|
||||||
viper.SetDefault("cache.modules.unread_count_ttl", 30)
|
viper.SetDefault("cache.modules.unread_count_ttl", 30)
|
||||||
viper.SetDefault("cache.modules.group_members_ttl", 120)
|
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.secret", "your-jwt-secret-key-change-in-production")
|
||||||
viper.SetDefault("jwt.access_token_expire", 86400)
|
viper.SetDefault("jwt.access_token_expire", 86400)
|
||||||
viper.SetDefault("jwt.refresh_token_expire", 604800)
|
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("rate_limit.requests_per_minute", 60)
|
||||||
viper.SetDefault("upload.max_file_size", 10485760)
|
viper.SetDefault("upload.max_file_size", 10485760)
|
||||||
viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/gif", "image/webp"})
|
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.endpoint", "")
|
||||||
viper.SetDefault("s3.access_key", "")
|
viper.SetDefault("s3.access_key", "")
|
||||||
viper.SetDefault("s3.secret_key", "")
|
viper.SetDefault("s3.secret_key", "")
|
||||||
@@ -89,16 +129,12 @@ func Load(configPath string) (*Config, error) {
|
|||||||
viper.SetDefault("s3.domain", "")
|
viper.SetDefault("s3.domain", "")
|
||||||
viper.SetDefault("sensitive.enabled", true)
|
viper.SetDefault("sensitive.enabled", true)
|
||||||
viper.SetDefault("sensitive.replace_str", "***")
|
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.enabled", false)
|
||||||
viper.SetDefault("audit.provider", "local")
|
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.enabled", true)
|
||||||
viper.SetDefault("openai.base_url", "https://api.littlelan.cn/")
|
viper.SetDefault("openai.base_url", "https://api.littlelan.cn/")
|
||||||
viper.SetDefault("openai.api_key", "")
|
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.moderation_max_images_per_request", 1)
|
||||||
viper.SetDefault("openai.request_timeout", 30)
|
viper.SetDefault("openai.request_timeout", 30)
|
||||||
viper.SetDefault("openai.strict_moderation", false)
|
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.enabled", false)
|
||||||
viper.SetDefault("email.host", "")
|
viper.SetDefault("email.host", "")
|
||||||
viper.SetDefault("email.port", 587)
|
viper.SetDefault("email.port", 587)
|
||||||
viper.SetDefault("email.username", "")
|
viper.SetDefault("email.username", "")
|
||||||
viper.SetDefault("email.password", "")
|
viper.SetDefault("email.password", "")
|
||||||
viper.SetDefault("email.from_address", "")
|
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.use_tls", true)
|
||||||
viper.SetDefault("email.insecure_skip_verify", false)
|
viper.SetDefault("email.insecure_skip_verify", false)
|
||||||
viper.SetDefault("email.timeout", 15)
|
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_enabled", false)
|
||||||
viper.SetDefault("grpc.tls_cert_file", "")
|
viper.SetDefault("grpc.tls_cert_file", "")
|
||||||
viper.SetDefault("grpc.tls_key_file", "")
|
viper.SetDefault("grpc.tls_key_file", "")
|
||||||
// Casbin 默认值
|
|
||||||
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
|
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
|
||||||
viper.SetDefault("casbin.auto_save", true)
|
viper.SetDefault("casbin.auto_save", true)
|
||||||
viper.SetDefault("casbin.auto_load", true)
|
viper.SetDefault("casbin.auto_load", true)
|
||||||
viper.SetDefault("casbin.enable_cache", true)
|
viper.SetDefault("casbin.enable_cache", true)
|
||||||
viper.SetDefault("casbin.cache_ttl", 300)
|
viper.SetDefault("casbin.cache_ttl", 300)
|
||||||
viper.SetDefault("casbin.skip_routes", []string{"/health", "/api/v1/auth/login", "/api/v1/auth/register"})
|
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 {
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
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)
|
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runner mode 和 instance_id 空则跟随 WebSocket 配置
|
||||||
|
cfg.Runner.ApplyWSDefaults(cfg.WebSocket)
|
||||||
|
|
||||||
// Convert seconds to duration
|
// Convert seconds to duration
|
||||||
cfg.JWT.AccessTokenExpire = time.Duration(viper.GetInt("jwt.access_token_expire")) * time.Second
|
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.RefreshTokenExpire = time.Duration(viper.GetInt("jwt.refresh_token_expire")) * time.Second
|
||||||
|
|
||||||
// 环境变量覆盖(显式处理敏感配置)
|
// Viper AutomaticEnv 已自动处理所有 APP_ 前缀的环境变量
|
||||||
cfg.JWT.Secret = getEnvOrDefault("APP_JWT_SECRET", cfg.JWT.Secret)
|
|
||||||
cfg.Database.SQLite.Path = getEnvOrDefault("APP_DATABASE_SQLITE_PATH", cfg.Database.SQLite.Path)
|
// 安全检查:生产模式必须设置JWT密钥
|
||||||
cfg.Database.Postgres.Host = getEnvOrDefault("APP_DATABASE_POSTGRES_HOST", cfg.Database.Postgres.Host)
|
if cfg.Server.Mode != "debug" {
|
||||||
cfg.Database.Postgres.Port, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_POSTGRES_PORT", fmt.Sprintf("%d", cfg.Database.Postgres.Port)))
|
if cfg.JWT.Secret == "" || cfg.JWT.Secret == "your-jwt-secret-key-change-in-production" {
|
||||||
cfg.Database.Postgres.User = getEnvOrDefault("APP_DATABASE_POSTGRES_USER", cfg.Database.Postgres.User)
|
return nil, fmt.Errorf("SECURITY ERROR: JWT secret must be set via APP_JWT_SECRET environment variable in production mode")
|
||||||
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)
|
if len(cfg.JWT.Secret) < 32 {
|
||||||
cfg.Database.LogLevel = getEnvOrDefault("APP_DATABASE_LOG_LEVEL", cfg.Database.LogLevel)
|
return nil, fmt.Errorf("SECURITY ERROR: JWT secret must be at least 32 characters long in production mode")
|
||||||
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)))
|
|
||||||
|
|
||||||
return &cfg, nil
|
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 数据库配置
|
// DatabaseConfig 数据库配置
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
Type string `mapstructure:"type"`
|
Type string `mapstructure:"type"`
|
||||||
SQLite SQLiteConfig `mapstructure:"sqlite"`
|
SQLite SQLiteConfig `mapstructure:"sqlite"`
|
||||||
Postgres PostgresConfig `mapstructure:"postgres"`
|
Postgres PostgresConfig `mapstructure:"postgres"`
|
||||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||||
LogLevel string `mapstructure:"log_level"`
|
LogLevel string `mapstructure:"log_level"`
|
||||||
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
|
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
|
||||||
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
|
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
|
||||||
ParameterizedQueries bool `mapstructure:"parameterized_queries"`
|
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 配置
|
// SQLiteConfig SQLite 配置
|
||||||
@@ -20,7 +27,7 @@ type SQLiteConfig struct {
|
|||||||
Path string `mapstructure:"path"`
|
Path string `mapstructure:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostgresConfig PostgreSQL 配置
|
// PostgresConfig PostgreSQL 配置(主库和副本共用)
|
||||||
type PostgresConfig struct {
|
type PostgresConfig struct {
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
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,
|
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
|
package config
|
||||||
|
|
||||||
// GorseConfig Gorse 推荐系统配置
|
// HotRankConfig 热门榜定时重算(仅 Redis ZSET + 分层缓存中的 Top ID 列表,不写库)
|
||||||
type GorseConfig struct {
|
type HotRankConfig struct {
|
||||||
Address string `mapstructure:"address"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
APIKey string `mapstructure:"api_key"`
|
RefreshIntervalSeconds int `mapstructure:"refresh_interval_seconds"`
|
||||||
Enabled bool `mapstructure:"enabled"`
|
// TopN 写入缓存的热门条数(ZSET 与 top_ids 列表长度上限)
|
||||||
Dashboard string `mapstructure:"dashboard"`
|
TopN int `mapstructure:"top_n"`
|
||||||
ImportPassword string `mapstructure:"import_password"`
|
// RecentWindowHours 候选池:该时间窗内新发的帖子 ID 会参与本轮重算
|
||||||
EmbeddingAPIKey string `mapstructure:"embedding_api_key"`
|
RecentWindowHours int `mapstructure:"recent_window_hours"`
|
||||||
EmbeddingURL string `mapstructure:"embedding_url"`
|
// RecentFetchLimit 时间窗内最多拉取的帖子 ID 数(按 created_at 倒序)
|
||||||
EmbeddingModel string `mapstructure:"embedding_model"`
|
RecentFetchLimit int `mapstructure:"recent_fetch_limit"`
|
||||||
|
// CandidateCap 候选 ID 合并后的上限(上届 Top + 新帖去重后截断)
|
||||||
|
CandidateCap int `mapstructure:"candidate_cap"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAIConfig OpenAI 配置
|
// OpenAIConfig OpenAI 配置
|
||||||
@@ -22,3 +24,13 @@ type OpenAIConfig struct {
|
|||||||
RequestTimeout int `mapstructure:"request_timeout"`
|
RequestTimeout int `mapstructure:"request_timeout"`
|
||||||
StrictModeration bool `mapstructure:"strict_moderation"`
|
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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RedisConfig Redis 配置
|
// RedisConfig Redis 配置
|
||||||
@@ -17,13 +14,22 @@ type RedisConfig struct {
|
|||||||
|
|
||||||
// CacheConfig 缓存配置
|
// CacheConfig 缓存配置
|
||||||
type CacheConfig struct {
|
type CacheConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
KeyPrefix string `mapstructure:"key_prefix"`
|
KeyPrefix string `mapstructure:"key_prefix"`
|
||||||
DefaultTTL int `mapstructure:"default_ttl"`
|
DefaultTTL int `mapstructure:"default_ttl"`
|
||||||
NullTTL int `mapstructure:"null_ttl"`
|
NullTTL int `mapstructure:"null_ttl"`
|
||||||
JitterRatio float64 `mapstructure:"jitter_ratio"`
|
JitterRatio float64 `mapstructure:"jitter_ratio"`
|
||||||
DisableFlushDB bool `mapstructure:"disable_flushdb"`
|
DisableFlushDB bool `mapstructure:"disable_flushdb"`
|
||||||
Modules CacheModuleTTL `mapstructure:"modules"`
|
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 配置
|
// CacheModuleTTL 缓存模块 TTL 配置
|
||||||
@@ -53,19 +59,3 @@ type MiniredisConfig struct {
|
|||||||
Port int `mapstructure:"port"`
|
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)
|
||||||
|
}
|
||||||
@@ -61,3 +61,19 @@ type UploadConfig struct {
|
|||||||
MaxFileSize int64 `mapstructure:"max_file_size"`
|
MaxFileSize int64 `mapstructure:"max_file_size"`
|
||||||
AllowedTypes []string `mapstructure:"allowed_types"`
|
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
|
package config
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/minio/minio-go/v7"
|
|
||||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
||||||
)
|
|
||||||
|
|
||||||
// S3Config S3 存储配置
|
// S3Config S3 存储配置
|
||||||
type S3Config struct {
|
type S3Config struct {
|
||||||
Endpoint string `mapstructure:"endpoint"`
|
Endpoint string `mapstructure:"endpoint"`
|
||||||
@@ -19,32 +10,3 @@ type S3Config struct {
|
|||||||
Region string `mapstructure:"region"`
|
Region string `mapstructure:"region"`
|
||||||
Domain string `mapstructure:"domain"` // 自定义域名,如 s3.carrot.skin
|
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 (
|
import (
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Group 转换 ====================
|
// ==================== 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
|
// GroupAnnouncementToResponse 将GroupAnnouncement转换为GroupAnnouncementResponse
|
||||||
func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse {
|
func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse {
|
||||||
@@ -94,3 +74,30 @@ func GroupAnnouncementsToResponse(announcements []model.GroupAnnouncement) []*Gr
|
|||||||
}
|
}
|
||||||
return result
|
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 (
|
import (
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Message 转换 ====================
|
// ==================== Message 转换 ====================
|
||||||
|
|
||||||
// ConvertMessageToResponse 将Message转换为MessageResponse
|
// ConvertMessageToResponse 将Message转换为MessageResponse
|
||||||
func ConvertMessageToResponse(message *model.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 {
|
if message == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -15,6 +22,19 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
|||||||
// 直接使用 segments,不需要解析
|
// 直接使用 segments,不需要解析
|
||||||
segments := make(model.MessageSegments, len(message.Segments))
|
segments := make(model.MessageSegments, len(message.Segments))
|
||||||
for i, seg := range 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{
|
segments[i] = model.MessageSegment{
|
||||||
Type: seg.Type,
|
Type: seg.Type,
|
||||||
Data: seg.Data,
|
Data: seg.Data,
|
||||||
@@ -38,7 +58,7 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
|||||||
// participants: 会话参与者列表(用户信息)
|
// participants: 会话参与者列表(用户信息)
|
||||||
// unreadCount: 当前用户的未读消息数
|
// unreadCount: 当前用户的未读消息数
|
||||||
// lastMessage: 最后一条消息
|
// 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 {
|
if conv == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -55,22 +75,23 @@ func ConvertConversationToResponse(conv *model.Conversation, participants []*mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &ConversationResponse{
|
return &ConversationResponse{
|
||||||
ID: conv.ID,
|
ID: conv.ID,
|
||||||
Type: string(conv.Type),
|
Type: string(conv.Type),
|
||||||
IsPinned: isPinned,
|
IsPinned: isPinned,
|
||||||
Group: groupResponse,
|
NotificationMuted: notificationMuted,
|
||||||
LastSeq: conv.LastSeq,
|
Group: groupResponse,
|
||||||
LastMessage: ConvertMessageToResponse(lastMessage),
|
LastSeq: conv.LastSeq,
|
||||||
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
LastMessage: ConvertMessageToResponse(lastMessage),
|
||||||
UnreadCount: unreadCount,
|
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
||||||
Participants: participantResponses,
|
UnreadCount: unreadCount,
|
||||||
CreatedAt: FormatTime(conv.CreatedAt),
|
Participants: participantResponses,
|
||||||
UpdatedAt: FormatTime(conv.UpdatedAt),
|
CreatedAt: FormatTime(conv.CreatedAt),
|
||||||
|
UpdatedAt: FormatTime(conv.UpdatedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertConversationToDetailResponse 将Conversation转换为ConversationDetailResponse
|
// 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 {
|
if conv == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -81,18 +102,19 @@ func ConvertConversationToDetailResponse(conv *model.Conversation, participants
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &ConversationDetailResponse{
|
return &ConversationDetailResponse{
|
||||||
ID: conv.ID,
|
ID: conv.ID,
|
||||||
Type: string(conv.Type),
|
Type: string(conv.Type),
|
||||||
IsPinned: isPinned,
|
IsPinned: isPinned,
|
||||||
LastSeq: conv.LastSeq,
|
NotificationMuted: notificationMuted,
|
||||||
LastMessage: ConvertMessageToResponse(lastMessage),
|
LastSeq: conv.LastSeq,
|
||||||
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
LastMessage: ConvertMessageToResponse(lastMessage),
|
||||||
UnreadCount: unreadCount,
|
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
||||||
Participants: participantResponses,
|
UnreadCount: unreadCount,
|
||||||
MyLastReadSeq: myLastReadSeq,
|
Participants: participantResponses,
|
||||||
OtherLastReadSeq: otherLastReadSeq,
|
MyLastReadSeq: myLastReadSeq,
|
||||||
CreatedAt: FormatTime(conv.CreatedAt),
|
OtherLastReadSeq: otherLastReadSeq,
|
||||||
UpdatedAt: FormatTime(conv.UpdatedAt),
|
CreatedAt: FormatTime(conv.CreatedAt),
|
||||||
|
UpdatedAt: FormatTime(conv.UpdatedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,15 +127,16 @@ func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertConversationsToResponse 将Conversation列表转换为响应列表
|
// ConvertMessagesToResponseWithExpiry 将Message列表转换为响应列表,并标记过期文件
|
||||||
func ConvertConversationsToResponse(convs []*model.Conversation) []*ConversationResponse {
|
func ConvertMessagesToResponseWithExpiry(messages []*model.Message, expiredURLSet map[string]struct{}) []*MessageResponse {
|
||||||
result := make([]*ConversationResponse, 0, len(convs))
|
result := make([]*MessageResponse, 0, len(messages))
|
||||||
for _, conv := range convs {
|
for _, msg := range messages {
|
||||||
result = append(result, ConvertConversationToResponse(conv, nil, 0, nil, false))
|
result = append(result, ConvertMessageToResponseWithExpiry(msg, expiredURLSet))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==================== PushRecord 转换 ====================
|
// ==================== PushRecord 转换 ====================
|
||||||
|
|
||||||
// PushRecordToResponse 将PushRecord转换为PushRecordResponse
|
// PushRecordToResponse 将PushRecord转换为PushRecordResponse
|
||||||
@@ -176,3 +199,34 @@ func DeviceTokensToResponse(tokens []*model.DeviceToken) []*DeviceTokenResponse
|
|||||||
}
|
}
|
||||||
return result
|
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 (
|
import (
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,49 +33,6 @@ func ConvertNotificationsToResponse(notifications []*model.Notification) []*Noti
|
|||||||
return result
|
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
|
// SystemNotificationToResponse 将SystemNotification转换为SystemMessageResponse
|
||||||
func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse {
|
func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse {
|
||||||
if n == nil {
|
if n == nil {
|
||||||
@@ -92,7 +49,7 @@ func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageRes
|
|||||||
CreatedAt: n.CreatedAt,
|
CreatedAt: n.CreatedAt,
|
||||||
}
|
}
|
||||||
if n.ExtraData != nil {
|
if n.ExtraData != nil {
|
||||||
resp.ExtraData = map[string]interface{}{
|
resp.ExtraData = map[string]any{
|
||||||
"actor_id": n.ExtraData.ActorID,
|
"actor_id": n.ExtraData.ActorID,
|
||||||
"actor_id_str": n.ExtraData.ActorIDStr,
|
"actor_id_str": n.ExtraData.ActorIDStr,
|
||||||
"actor_name": n.ExtraData.ActorName,
|
"actor_name": n.ExtraData.ActorName,
|
||||||
@@ -118,11 +75,3 @@ func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageRes
|
|||||||
return resp
|
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
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrot_bbs/internal/model"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"with_you/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================== Post 转换 ====================
|
// ==================== Post 转换 ====================
|
||||||
@@ -14,11 +14,13 @@ func ConvertPostImageToResponse(img *model.PostImage) PostImageResponse {
|
|||||||
return PostImageResponse{}
|
return PostImageResponse{}
|
||||||
}
|
}
|
||||||
return PostImageResponse{
|
return PostImageResponse{
|
||||||
ID: img.ID,
|
ID: img.ID,
|
||||||
URL: img.URL,
|
URL: img.URL,
|
||||||
ThumbnailURL: img.ThumbnailURL,
|
ThumbnailURL: img.ThumbnailURL,
|
||||||
Width: img.Width,
|
PreviewURL: img.PreviewURL,
|
||||||
Height: img.Height,
|
PreviewURLLarge: img.PreviewURLLarge,
|
||||||
|
Width: img.Width,
|
||||||
|
Height: img.Height,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,8 +33,56 @@ func ConvertPostImagesToResponse(images []model.PostImage) []PostImageResponse {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertPostToResponse 将Post转换为PostResponse(列表用)
|
func postChannelBrief(post *model.Post, channelByID map[string]*model.Channel) *PostChannelBrief {
|
||||||
func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse {
|
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 {
|
if post == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -48,69 +98,34 @@ func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &PostResponse{
|
return &PostResponse{
|
||||||
ID: post.ID,
|
ID: post.ID,
|
||||||
UserID: post.UserID,
|
UserID: post.UserID,
|
||||||
Title: post.Title,
|
ChannelID: post.ChannelID,
|
||||||
Content: post.Content,
|
Title: post.Title,
|
||||||
Images: images,
|
Content: post.Content,
|
||||||
Status: string(post.Status),
|
Segments: SegmentsOrDefault(post.Segments, post.Content),
|
||||||
LikesCount: post.LikesCount,
|
Images: images,
|
||||||
CommentsCount: post.CommentsCount,
|
Status: string(post.Status),
|
||||||
FavoritesCount: post.FavoritesCount,
|
LikesCount: post.LikesCount,
|
||||||
SharesCount: post.SharesCount,
|
CommentsCount: post.CommentsCount,
|
||||||
ViewsCount: post.ViewsCount,
|
FavoritesCount: post.FavoritesCount,
|
||||||
IsPinned: post.IsPinned,
|
SharesCount: post.SharesCount,
|
||||||
IsLocked: post.IsLocked,
|
ViewsCount: post.ViewsCount,
|
||||||
IsVote: post.IsVote,
|
IsPinned: post.IsPinned,
|
||||||
CreatedAt: FormatTime(post.CreatedAt),
|
IsLocked: post.IsLocked,
|
||||||
UpdatedAt: FormatTime(post.UpdatedAt),
|
IsVote: post.IsVote,
|
||||||
Author: author,
|
CreatedAt: FormatTime(post.CreatedAt),
|
||||||
IsLiked: isLiked,
|
UpdatedAt: FormatTime(post.UpdatedAt),
|
||||||
IsFavorited: isFavorited,
|
ContentEditedAt: FormatTimePointer(post.ContentEditedAt),
|
||||||
|
Author: author,
|
||||||
|
IsLiked: isLiked,
|
||||||
|
IsFavorited: isFavorited,
|
||||||
|
Channel: postChannelBrief(post, channelByID),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertPostToDetailResponse 将Post转换为PostDetailResponse
|
// ConvertPostsToResponse 将Post列表转换为响应列表;channelByID 可为 nil
|
||||||
func ConvertPostToDetailResponse(post *model.Post, isLiked, isFavorited bool) *PostDetailResponse {
|
func ConvertPostsToResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||||
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 {
|
|
||||||
result := make([]*PostResponse, 0, len(posts))
|
result := make([]*PostResponse, 0, len(posts))
|
||||||
for _, post := range posts {
|
for _, post := range posts {
|
||||||
isLiked := false
|
isLiked := false
|
||||||
@@ -121,21 +136,21 @@ func ConvertPostsToResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[
|
|||||||
if isFavoritedMap != nil {
|
if isFavoritedMap != nil {
|
||||||
isFavorited = isFavoritedMap[post.ID]
|
isFavorited = isFavoritedMap[post.ID]
|
||||||
}
|
}
|
||||||
result = append(result, ConvertPostToResponse(post, isLiked, isFavorited))
|
result = append(result, ConvertPostToResponse(post, channelByID, isLiked, isFavorited))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildPostResponse 构建单个帖子响应(包含交互状态)
|
// BuildPostResponse 构建单个帖子响应(包含交互状态)
|
||||||
// 这是一个语义化的辅助函数,便于 Handler 层调用
|
// 这是一个语义化的辅助函数,便于 Handler 层调用
|
||||||
func BuildPostResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse {
|
func BuildPostResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostResponse {
|
||||||
return ConvertPostToResponse(post, isLiked, isFavorited)
|
return ConvertPostToResponse(post, channelByID, isLiked, isFavorited)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildPostsWithInteractionResponse 批量构建帖子响应(包含交互状态)
|
// BuildPostsWithInteractionResponse 批量构建帖子响应(包含交互状态)
|
||||||
// 这是一个语义化的辅助函数,便于 Handler 层调用
|
// 这是一个语义化的辅助函数,便于 Handler 层调用
|
||||||
func BuildPostsWithInteractionResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
func BuildPostsWithInteractionResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||||
return ConvertPostsToResponse(posts, isLikedMap, isFavoritedMap)
|
return ConvertPostsToResponse(posts, channelByID, isLikedMap, isFavoritedMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Comment 转换 ====================
|
// ==================== Comment 转换 ====================
|
||||||
@@ -185,6 +200,7 @@ func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResp
|
|||||||
ParentID: comment.ParentID,
|
ParentID: comment.ParentID,
|
||||||
RootID: comment.RootID,
|
RootID: comment.RootID,
|
||||||
Content: comment.Content,
|
Content: comment.Content,
|
||||||
|
Segments: SegmentsOrDefault(comment.Segments, comment.Content),
|
||||||
Images: images,
|
Images: images,
|
||||||
LikesCount: comment.LikesCount,
|
LikesCount: comment.LikesCount,
|
||||||
RepliesCount: comment.RepliesCount,
|
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 点赞状态检查器接口
|
// IsLikedChecker 点赞状态检查器接口
|
||||||
type IsLikedChecker interface {
|
type IsLikedChecker interface {
|
||||||
@@ -261,6 +269,7 @@ func ConvertCommentToResponseWithUser(comment *model.Comment, userID string, che
|
|||||||
ParentID: comment.ParentID,
|
ParentID: comment.ParentID,
|
||||||
RootID: comment.RootID,
|
RootID: comment.RootID,
|
||||||
Content: comment.Content,
|
Content: comment.Content,
|
||||||
|
Segments: SegmentsOrDefault(comment.Segments, comment.Content),
|
||||||
Images: images,
|
Images: images,
|
||||||
LikesCount: comment.LikesCount,
|
LikesCount: comment.LikesCount,
|
||||||
RepliesCount: comment.RepliesCount,
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ConvertScheduleCourseToResponse(course *model.ScheduleCourse, weeks []int) *ScheduleCourseResponse {
|
func ConvertScheduleCourseToResponse(course *model.ScheduleCourse, weeks []int) *ScheduleCourseResponse {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ type ScheduleCourseResponse struct {
|
|||||||
Teacher string `json:"teacher,omitempty"`
|
Teacher string `json:"teacher,omitempty"`
|
||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
DayOfWeek int `json:"day_of_week"`
|
DayOfWeek int `json:"day_of_week"`
|
||||||
StartSection int `json:"start_section"`
|
StartSection int `json:"start_section,omitzero"`
|
||||||
EndSection int `json:"end_section"`
|
EndSection int `json:"end_section,omitzero"`
|
||||||
Weeks []int `json:"weeks"`
|
Weeks []int `json:"weeks"`
|
||||||
Color string `json:"color,omitempty"`
|
Color string `json:"color,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,161 +3,40 @@ package dto
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"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
|
// NewTextSegment 创建文本Segment
|
||||||
func NewTextSegment(content string) model.MessageSegment {
|
func NewTextSegment(content string) model.MessageSegment {
|
||||||
return model.MessageSegment{
|
return model.MessageSegment{
|
||||||
Type: string(SegmentTypeText),
|
Type: string(SegmentTypeText),
|
||||||
Data: map[string]interface{}{"text": content},
|
Data: map[string]any{"text": content},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewImageSegment 创建图片Segment
|
// ExtractVoteSegmentData 从消息链中提取投票Segment数据
|
||||||
func NewImageSegment(url string, width, height int, thumbnailURL string) model.MessageSegment {
|
func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData {
|
||||||
return model.MessageSegment{
|
for _, segment := range segments {
|
||||||
Type: string(SegmentTypeImage),
|
if segment.Type == string(SegmentTypeVote) {
|
||||||
Data: map[string]interface{}{
|
dataBytes, err := json.Marshal(segment.Data)
|
||||||
"url": url,
|
if err != nil {
|
||||||
"width": width,
|
continue
|
||||||
"height": height,
|
}
|
||||||
"thumbnail_url": thumbnailURL,
|
var voteData VoteSegmentData
|
||||||
},
|
if err := json.Unmarshal(dataBytes, &voteData); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &voteData
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewImageSegmentWithSize 创建带文件大小的图片Segment
|
// HasVoteSegment 检查消息链中是否包含投票Segment
|
||||||
func NewImageSegmentWithSize(url string, width, height int, thumbnailURL string, fileSize int64) model.MessageSegment {
|
func HasVoteSegment(segments model.MessageSegments) bool {
|
||||||
return model.MessageSegment{
|
return HasSegmentType(segments, SegmentTypeVote)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractTextContentFromModel 从 model.MessageSegments 中提取纯文本内容
|
// ExtractTextContentFromModel 从 model.MessageSegments 中提取纯文本内容
|
||||||
@@ -201,6 +80,14 @@ func ExtractTextContentFromModel(segments model.MessageSegments) string {
|
|||||||
} else {
|
} else {
|
||||||
result += "[链接]"
|
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
|
return result
|
||||||
@@ -230,133 +117,27 @@ func ExtractMentionedUsers(segments model.MessageSegments) []string {
|
|||||||
return userIDs
|
return userIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAtAll 检查消息是否@了所有人
|
// ExtractReferencedPosts 从消息链中提取所有被引用的帖子ID
|
||||||
func IsAtAll(segments model.MessageSegments) bool {
|
func ExtractReferencedPosts(segments model.MessageSegments) []string {
|
||||||
|
var postIDs []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
for _, segment := range segments {
|
for _, segment := range segments {
|
||||||
if segment.Type == string(SegmentTypeAt) {
|
if segment.Type == string(SegmentTypePostRef) {
|
||||||
if userID, ok := segment.Data["user_id"].(string); ok && userID == "all" {
|
postID, _ := segment.Data["post_id"].(string)
|
||||||
return true
|
if postID != "" && !seen[postID] {
|
||||||
|
postIDs = append(postIDs, postID)
|
||||||
|
seen[postID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return postIDs
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasSegmentType 检查消息链中是否包含指定类型的segment
|
// HasSegmentType 检查消息链中是否包含指定类型的segment
|
||||||
func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool {
|
func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) bool {
|
||||||
for _, segment := range segments {
|
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
||||||
if segment.Type == string(segmentType) {
|
return segment.Type == string(segmentType)
|
||||||
return true
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (
|
import (
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/pkg/utils"
|
"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
|
// getAvatarOrDefault 获取头像URL,如果为空则返回在线头像生成服务的URL
|
||||||
func getAvatarOrDefault(user *model.User) string {
|
func getAvatarOrDefault(user *model.User) string {
|
||||||
return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar)
|
return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertUserToResponse 将User转换为UserResponse
|
// publicVerificationFields 返回认证信息:
|
||||||
func ConvertUserToResponse(user *model.User) *UserResponse {
|
// - 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 {
|
if user == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
identity, vs := publicVerificationFields(user)
|
||||||
return &UserResponse{
|
return &UserResponse{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Nickname: user.Nickname,
|
Nickname: user.Nickname,
|
||||||
Email: user.Email,
|
EmailVerified: user.EmailVerified,
|
||||||
Phone: user.Phone,
|
Avatar: getAvatarOrDefault(user),
|
||||||
EmailVerified: user.EmailVerified,
|
CoverURL: user.CoverURL,
|
||||||
Avatar: getAvatarOrDefault(user),
|
Bio: user.Bio,
|
||||||
CoverURL: user.CoverURL,
|
Website: user.Website,
|
||||||
Bio: user.Bio,
|
Location: user.Location,
|
||||||
Website: user.Website,
|
PostsCount: user.PostsCount,
|
||||||
Location: user.Location,
|
FollowersCount: user.FollowersCount,
|
||||||
PostsCount: user.PostsCount,
|
FollowingCount: user.FollowingCount,
|
||||||
FollowersCount: user.FollowersCount,
|
CreatedAt: FormatTime(user.CreatedAt),
|
||||||
FollowingCount: user.FollowingCount,
|
Identity: identity,
|
||||||
CreatedAt: FormatTime(user.CreatedAt),
|
VerificationStatus: vs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertUserToResponseWithFollowing 将User转换为UserResponse(包含关注状态)
|
// ConvertUserToResponse 将User转换为UserResponse(公开信息,不包含敏感数据)
|
||||||
func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *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 {
|
if user == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &UserResponse{
|
return &UserSelfResponse{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Nickname: user.Nickname,
|
Nickname: user.Nickname,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Phone: user.Phone,
|
Phone: user.Phone,
|
||||||
EmailVerified: user.EmailVerified,
|
EmailVerified: user.EmailVerified,
|
||||||
Avatar: getAvatarOrDefault(user),
|
Avatar: getAvatarOrDefault(user),
|
||||||
CoverURL: user.CoverURL,
|
CoverURL: user.CoverURL,
|
||||||
Bio: user.Bio,
|
Bio: user.Bio,
|
||||||
Website: user.Website,
|
Website: user.Website,
|
||||||
Location: user.Location,
|
Location: user.Location,
|
||||||
PostsCount: user.PostsCount,
|
PostsCount: postsCount,
|
||||||
FollowersCount: user.FollowersCount,
|
FollowersCount: user.FollowersCount,
|
||||||
FollowingCount: user.FollowingCount,
|
FollowingCount: user.FollowingCount,
|
||||||
IsFollowing: isFollowing,
|
IsVerified: user.IsVerified,
|
||||||
IsFollowingMe: false, // 默认false,需要单独计算
|
CreatedAt: FormatTime(user.CreatedAt),
|
||||||
CreatedAt: FormatTime(user.CreatedAt),
|
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
|
||||||
|
Identity: user.Identity,
|
||||||
|
VerificationStatus: user.VerificationStatus,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertUserToResponseWithPostsCount 将User转换为UserResponse(使用实时计算的帖子数量)
|
// ConvertUserToDetailResponseWithPostsCount 将User转换为UserDetailResponse(使用实时计算的帖子数量,仅本人可见)
|
||||||
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(使用实时计算的帖子数量)
|
|
||||||
func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) *UserDetailResponse {
|
func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int) *UserDetailResponse {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &UserDetailResponse{
|
return &UserDetailResponse{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Nickname: user.Nickname,
|
Nickname: user.Nickname,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
EmailVerified: user.EmailVerified,
|
EmailVerified: user.EmailVerified,
|
||||||
Phone: user.Phone, // 仅当前用户自己可见
|
Phone: user.Phone,
|
||||||
Avatar: getAvatarOrDefault(user),
|
Avatar: getAvatarOrDefault(user),
|
||||||
CoverURL: user.CoverURL,
|
CoverURL: user.CoverURL,
|
||||||
Bio: user.Bio,
|
Bio: user.Bio,
|
||||||
Website: user.Website,
|
Website: user.Website,
|
||||||
Location: user.Location,
|
Location: user.Location,
|
||||||
PostsCount: postsCount,
|
PostsCount: postsCount,
|
||||||
FollowersCount: user.FollowersCount,
|
FollowersCount: user.FollowersCount,
|
||||||
FollowingCount: user.FollowingCount,
|
FollowingCount: user.FollowingCount,
|
||||||
IsVerified: user.IsVerified,
|
IsVerified: user.IsVerified,
|
||||||
CreatedAt: FormatTime(user.CreatedAt),
|
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列表转换为响应列表
|
// ConvertUsersToResponse 将User列表转换为响应列表
|
||||||
func ConvertUsersToResponse(users []*model.User) []*UserResponse {
|
func ConvertUsersToResponse(users []*model.User, opts ...UserResponseOption) []*UserResponse {
|
||||||
result := make([]*UserResponse, 0, len(users))
|
result := make([]*UserResponse, 0, len(users))
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
result = append(result, ConvertUserToResponse(user))
|
result = append(result, ConvertUserToResponse(user, opts...))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertUsersToResponseWithMutualFollow 将User列表转换为响应列表(包含双向关注状态)
|
// ConvertUsersToResponseWithMutualFollowAndPostsCount 将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列表转换为响应列表(包含双向关注状态和实时计算的帖子数量)
|
|
||||||
// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe]
|
// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe]
|
||||||
// postsCountMap: key是用户ID,value是帖子数量
|
// postsCountMap: key是用户ID,value是帖子数量
|
||||||
func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse {
|
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 {
|
for _, user := range users {
|
||||||
status, hasStatus := followingStatusMap[user.ID]
|
status, hasStatus := followingStatusMap[user.ID]
|
||||||
postsCount, hasPostsCount := postsCountMap[user.ID]
|
postsCount, hasPostsCount := postsCountMap[user.ID]
|
||||||
|
|
||||||
// 如果没有帖子数量,使用数据库中的值
|
|
||||||
if !hasPostsCount {
|
if !hasPostsCount {
|
||||||
postsCount = int64(user.PostsCount)
|
postsCount = int64(user.PostsCount)
|
||||||
}
|
}
|
||||||
|
var opts []UserResponseOption
|
||||||
if hasStatus {
|
if hasStatus {
|
||||||
result = append(result, ConvertUserToResponseWithMutualFollowAndPostsCount(user, status[0], status[1], int(postsCount)))
|
opts = append(opts, WithFollowing(status[0], status[1]))
|
||||||
} else {
|
|
||||||
result = append(result, ConvertUserToResponseWithPostsCount(user, int(postsCount)))
|
|
||||||
}
|
}
|
||||||
|
opts = append(opts, WithPostsCount(int(postsCount)))
|
||||||
|
result = append(result, ConvertUserToResponse(user, opts...))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -99,6 +99,59 @@ var (
|
|||||||
ErrCannotModifyOwnRole = &AppError{Code: "CANNOT_MODIFY_OWN_ROLE", Message: "不能修改自己的角色"}
|
ErrCannotModifyOwnRole = &AppError{Code: "CANNOT_MODIFY_OWN_ROLE", Message: "不能修改自己的角色"}
|
||||||
ErrCannotModifySuperAdmin = &AppError{Code: "CANNOT_MODIFY_SUPER_ADMIN", Message: "不能修改超级管理员角色"}
|
ErrCannotModifySuperAdmin = &AppError{Code: "CANNOT_MODIFY_SUPER_ADMIN", Message: "不能修改超级管理员角色"}
|
||||||
ErrCasbinInternal = &AppError{Code: "CASBIN_INTERNAL_ERROR", 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 包装错误
|
// 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
|
// As 将错误转换为 AppError
|
||||||
func As(err error) (*AppError, bool) {
|
func As(err error) (*AppError, bool) {
|
||||||
var appErr *AppError
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"carrot_bbs/proto/runner"
|
"with_you/proto/runner"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -26,22 +26,20 @@ type RunnerConnection struct {
|
|||||||
type RunnerHub struct {
|
type RunnerHub struct {
|
||||||
runner.UnimplementedRunnerHubServer // 嵌入以实现 gRPC 接口
|
runner.UnimplementedRunnerHubServer // 嵌入以实现 gRPC 接口
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
runners map[string]*RunnerConnection
|
runners map[string]*RunnerConnection
|
||||||
taskManager *TaskManager
|
taskManager *TaskManager
|
||||||
registerChan chan *RunnerConnection
|
bus *TaskBus
|
||||||
unregisterChan chan string
|
registry *RunnerRegistry
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunnerHub 创建新的 RunnerHub
|
// NewRunnerHub 创建新的 RunnerHub
|
||||||
func NewRunnerHub(taskManager *TaskManager, logger *zap.Logger) *RunnerHub {
|
func NewRunnerHub(taskManager *TaskManager, logger *zap.Logger) *RunnerHub {
|
||||||
return &RunnerHub{
|
return &RunnerHub{
|
||||||
runners: make(map[string]*RunnerConnection),
|
runners: make(map[string]*RunnerConnection),
|
||||||
taskManager: taskManager,
|
taskManager: taskManager,
|
||||||
registerChan: make(chan *RunnerConnection, 100),
|
logger: logger,
|
||||||
unregisterChan: make(chan string, 100),
|
|
||||||
logger: logger,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +135,15 @@ func (h *RunnerHub) handleRegister(req *runner.RegisterRequest, stream runner.Ru
|
|||||||
h.runners[req.RunnerId] = conn
|
h.runners[req.RunnerId] = conn
|
||||||
h.mu.Unlock()
|
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{
|
if err := conn.send(&runner.StreamMessage{
|
||||||
Message: &runner.StreamMessage_RegisterResponse{
|
Message: &runner.StreamMessage_RegisterResponse{
|
||||||
@@ -161,6 +168,15 @@ func (h *RunnerHub) handleHeartbeat(conn *RunnerConnection, hb *runner.Heartbeat
|
|||||||
}
|
}
|
||||||
conn.LastSeen = time.Now()
|
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{
|
if err := conn.send(&runner.StreamMessage{
|
||||||
Message: &runner.StreamMessage_HeartbeatAck{
|
Message: &runner.StreamMessage_HeartbeatAck{
|
||||||
HeartbeatAck: &runner.HeartbeatAck{
|
HeartbeatAck: &runner.HeartbeatAck{
|
||||||
@@ -179,6 +195,12 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
|
|||||||
zap.String("task_id", result.TaskId),
|
zap.String("task_id", result.TaskId),
|
||||||
zap.String("status", result.Status.String()))
|
zap.String("status", result.Status.String()))
|
||||||
|
|
||||||
|
// 如果有 Bus,委托给 Bus 处理(可能需要转发到远程实例)
|
||||||
|
if h.bus != nil {
|
||||||
|
h.bus.HandleResult(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if h.taskManager != nil {
|
if h.taskManager != nil {
|
||||||
h.taskManager.HandleResult(result)
|
h.taskManager.HandleResult(result)
|
||||||
}
|
}
|
||||||
@@ -187,12 +209,19 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
|
|||||||
// unregisterRunner 注销 Runner
|
// unregisterRunner 注销 Runner
|
||||||
func (h *RunnerHub) unregisterRunner(runnerID string) {
|
func (h *RunnerHub) unregisterRunner(runnerID string) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
var capabilities map[string]string
|
||||||
if conn, ok := h.runners[runnerID]; ok {
|
if conn, ok := h.runners[runnerID]; ok {
|
||||||
|
capabilities = conn.Capabilities
|
||||||
close(conn.sendChan)
|
close(conn.sendChan)
|
||||||
delete(h.runners, runnerID)
|
delete(h.runners, runnerID)
|
||||||
}
|
}
|
||||||
h.mu.Unlock()
|
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))
|
h.logger.Info("Runner unregistered", zap.String("runner_id", runnerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,13 +265,13 @@ func (h *RunnerHub) GetAvailableRunner(taskType runner.TaskType) (*RunnerConnect
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAllRunners 获取所有已连接的 Runner 信息
|
// GetAllRunners 获取所有已连接的 Runner 信息
|
||||||
func (h *RunnerHub) GetAllRunners() []map[string]interface{} {
|
func (h *RunnerHub) GetAllRunners() []map[string]any {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
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 {
|
for id, conn := range h.runners {
|
||||||
result = append(result, map[string]interface{}{
|
result = append(result, map[string]any{
|
||||||
"id": id,
|
"id": id,
|
||||||
"version": conn.Version,
|
"version": conn.Version,
|
||||||
"capabilities": conn.Capabilities,
|
"capabilities": conn.Capabilities,
|
||||||
@@ -257,6 +286,37 @@ func (h *RunnerHub) SetTaskManager(tm *TaskManager) {
|
|||||||
h.taskManager = tm
|
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 发送消息
|
// send 向 RunnerConnection 发送消息
|
||||||
func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
|
func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
|
||||||
select {
|
select {
|
||||||
@@ -270,8 +330,11 @@ func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
|
|||||||
// CheckStaleRunners 检查并清理超时的 Runner 连接
|
// CheckStaleRunners 检查并清理超时的 Runner 连接
|
||||||
func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
|
func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
|
var toRemove []struct {
|
||||||
|
id string
|
||||||
|
capabilities map[string]string
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for id, conn := range h.runners {
|
for id, conn := range h.runners {
|
||||||
if now.Sub(conn.LastSeen) > timeout {
|
if now.Sub(conn.LastSeen) > timeout {
|
||||||
@@ -280,6 +343,18 @@ func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
|
|||||||
zap.Time("last_seen", conn.LastSeen))
|
zap.Time("last_seen", conn.LastSeen))
|
||||||
close(conn.sendChan)
|
close(conn.sendChan)
|
||||||
delete(h.runners, id)
|
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 (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"carrot_bbs/internal/config"
|
"with_you/internal/config"
|
||||||
"carrot_bbs/proto/runner"
|
"with_you/proto/runner"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials"
|
"google.golang.org/grpc/credentials"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server gRPC 服务器
|
// Server gRPC 服务器
|
||||||
@@ -23,13 +21,8 @@ type Server struct {
|
|||||||
grpcSrv *grpc.Server
|
grpcSrv *grpc.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer 创建新的 gRPC 服务器
|
// NewServerWithDeps 使用外部依赖创建 gRPC 服务器(Wire DI 使用)
|
||||||
func NewServer(cfg *config.GRPCConfig, logger *zap.Logger) *Server {
|
func NewServerWithDeps(cfg *config.GRPCConfig, logger *zap.Logger, hub *RunnerHub) *Server {
|
||||||
// 创建 TaskManager 和 RunnerHub,解决循环依赖
|
|
||||||
taskManager := NewTaskManager(nil, 60*time.Second, logger)
|
|
||||||
hub := NewRunnerHub(taskManager, logger)
|
|
||||||
taskManager.SetHub(hub)
|
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
logger: logger,
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"carrot_bbs/proto/runner"
|
"with_you/proto/runner"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -16,6 +16,11 @@ import (
|
|||||||
// TaskCallback 任务完成回调函数类型
|
// TaskCallback 任务完成回调函数类型
|
||||||
type TaskCallback func(result *runner.TaskResult)
|
type TaskCallback func(result *runner.TaskResult)
|
||||||
|
|
||||||
|
// TaskDispatcher 任务分发接口
|
||||||
|
type TaskDispatcher interface {
|
||||||
|
DispatchTask(task *runner.Task) error
|
||||||
|
}
|
||||||
|
|
||||||
// PendingTask 表示一个等待处理的任务
|
// PendingTask 表示一个等待处理的任务
|
||||||
type PendingTask struct {
|
type PendingTask struct {
|
||||||
Task *runner.Task
|
Task *runner.Task
|
||||||
@@ -25,25 +30,25 @@ type PendingTask struct {
|
|||||||
|
|
||||||
// TaskManager 管理任务的生命周期
|
// TaskManager 管理任务的生命周期
|
||||||
type TaskManager struct {
|
type TaskManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
pending map[string]*PendingTask
|
pending map[string]*PendingTask
|
||||||
hub *RunnerHub
|
dispatcher TaskDispatcher
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTaskManager 创建新的任务管理器
|
// 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{
|
return &TaskManager{
|
||||||
pending: make(map[string]*PendingTask),
|
pending: make(map[string]*PendingTask),
|
||||||
hub: hub,
|
dispatcher: dispatcher,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubmitTask 提交任务并等待结果
|
// 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)
|
payloadBytes, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal payload: %w", err)
|
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("task_id", task.TaskId),
|
||||||
zap.String("type", taskType.String()))
|
zap.String("type", taskType.String()))
|
||||||
|
|
||||||
// 获取可用 Runner
|
// 通过 dispatcher 分发任务
|
||||||
conn, err := tm.hub.GetAvailableRunner(taskType)
|
if err := tm.dispatcher.DispatchTask(task); err != nil {
|
||||||
if err != nil {
|
|
||||||
tm.cleanupTask(task.TaskId)
|
tm.cleanupTask(task.TaskId)
|
||||||
return nil, fmt.Errorf("no available runner: %w", err)
|
return nil, fmt.Errorf("failed to dispatch task: %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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动超时检查
|
// 启动超时检查
|
||||||
@@ -89,7 +87,7 @@ func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SubmitTaskSync 同步提交任务并等待结果
|
// 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)
|
resultChan := make(chan *runner.TaskResult, 1)
|
||||||
|
|
||||||
task, err := tm.SubmitTask(ctx, taskType, payload, func(result *runner.TaskResult) {
|
task, err := tm.SubmitTask(ctx, taskType, payload, func(result *runner.TaskResult) {
|
||||||
@@ -203,9 +201,9 @@ func (tm *TaskManager) GetPendingTaskCount() int {
|
|||||||
return len(tm.pending)
|
return len(tm.pending)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHub 设置 RunnerHub(用于解决循环依赖)
|
// SetDispatcher 设置任务分发器(用于解决循环依赖)
|
||||||
func (tm *TaskManager) SetHub(hub *RunnerHub) {
|
func (tm *TaskManager) SetDispatcher(dispatcher TaskDispatcher) {
|
||||||
tm.hub = hub
|
tm.dispatcher = dispatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupStaleTasks 清理超时的任务
|
// CleanupStaleTasks 清理超时的任务
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"carrot_bbs/internal/dto"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -121,6 +121,58 @@ func (h *AdminCommentHandler) BatchDeleteComments(c *gin.Context) {
|
|||||||
response.Success(c, result)
|
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 获取帖子的评论列表
|
// GetPostComments 获取帖子的评论列表
|
||||||
// GET /api/v1/admin/posts/:postId/comments
|
// GET /api/v1/admin/posts/:postId/comments
|
||||||
func (h *AdminCommentHandler) GetPostComments(c *gin.Context) {
|
func (h *AdminCommentHandler) GetPostComments(c *gin.Context) {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -36,14 +36,9 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
|
|||||||
// GetUserActivityTrend 获取用户活跃度趋势
|
// GetUserActivityTrend 获取用户活跃度趋势
|
||||||
// GET /api/v1/admin/dashboard/user-activity
|
// GET /api/v1/admin/dashboard/user-activity
|
||||||
func (h *AdminDashboardHandler) GetUserActivityTrend(c *gin.Context) {
|
func (h *AdminDashboardHandler) GetUserActivityTrend(c *gin.Context) {
|
||||||
// 解析天数参数,默认7天
|
// 解析天数参数,默认7天,限制在1-30天
|
||||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
|
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
|
||||||
if days <= 0 {
|
days = min(max(days, 1), 30)
|
||||||
days = 7
|
|
||||||
}
|
|
||||||
if days > 30 {
|
|
||||||
days = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
trend, err := h.dashboardService.GetUserActivityTrend(c.Request.Context(), days)
|
trend, err := h.dashboardService.GetUserActivityTrend(c.Request.Context(), days)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,14 +64,9 @@ func (h *AdminDashboardHandler) GetContentStats(c *gin.Context) {
|
|||||||
// GetPendingContent 获取待审核内容
|
// GetPendingContent 获取待审核内容
|
||||||
// GET /api/v1/admin/dashboard/pending-content
|
// GET /api/v1/admin/dashboard/pending-content
|
||||||
func (h *AdminDashboardHandler) GetPendingContent(c *gin.Context) {
|
func (h *AdminDashboardHandler) GetPendingContent(c *gin.Context) {
|
||||||
// 解析limit参数,默认10条
|
// 解析limit参数,默认10条,限制在1-50条
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||||
if limit <= 0 {
|
limit = min(max(limit, 1), 50)
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
if limit > 50 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := h.dashboardService.GetPendingContent(c.Request.Context(), limit)
|
content, err := h.dashboardService.GetPendingContent(c.Request.Context(), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"carrot_bbs/internal/dto"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/repository"
|
"with_you/internal/query"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -37,7 +37,7 @@ func (h *AdminLogHandler) GetOperationLogs(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
filters := repository.LogFilter{
|
filters := query.LogFilter{
|
||||||
UserID: c.Query("user_id"),
|
UserID: c.Query("user_id"),
|
||||||
Operation: c.Query("operation"),
|
Operation: c.Query("operation"),
|
||||||
TargetType: c.Query("target_type"),
|
TargetType: c.Query("target_type"),
|
||||||
@@ -70,7 +70,7 @@ func (h *AdminLogHandler) GetLoginLogs(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
filters := repository.LoginFilter{
|
filters := query.LoginFilter{
|
||||||
UserID: c.Query("user_id"),
|
UserID: c.Query("user_id"),
|
||||||
Event: c.Query("event"),
|
Event: c.Query("event"),
|
||||||
Result: c.Query("result"),
|
Result: c.Query("result"),
|
||||||
@@ -113,7 +113,7 @@ func (h *AdminLogHandler) GetDataChangeLogs(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
filters := repository.DataChangeFilter{
|
filters := query.DataChangeFilter{
|
||||||
UserID: c.Query("user_id"),
|
UserID: c.Query("user_id"),
|
||||||
OperatorID: c.Query("operator_id"),
|
OperatorID: c.Query("operator_id"),
|
||||||
ChangeType: c.Query("change_type"),
|
ChangeType: c.Query("change_type"),
|
||||||
@@ -190,7 +190,7 @@ func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
|
|||||||
"logs": logs,
|
"logs": logs,
|
||||||
})
|
})
|
||||||
case "login":
|
case "login":
|
||||||
logs, _, err := h.loginLogService.GetLoginLogs(c.Request.Context(), repository.LoginFilter{}, 1, 10000)
|
logs, _, err := h.loginLogService.GetLoginLogs(c.Request.Context(), query.LoginFilter{}, 1, 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.HandleError(c, err, "failed to export login logs")
|
response.HandleError(c, err, "failed to export login logs")
|
||||||
return
|
return
|
||||||
@@ -200,7 +200,7 @@ func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
|
|||||||
"log": logs,
|
"log": logs,
|
||||||
})
|
})
|
||||||
case "data_change":
|
case "data_change":
|
||||||
logs, _, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), repository.DataChangeFilter{}, 1, 10000)
|
logs, _, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), query.DataChangeFilter{}, 1, 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.HandleError(c, err, "failed to export data change logs")
|
response.HandleError(c, err, "failed to export data change logs")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"carrot_bbs/internal/dto"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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 (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"carrot_bbs/internal/dto"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -14,12 +14,14 @@ import (
|
|||||||
// AdminUserHandler 管理端用户处理器
|
// AdminUserHandler 管理端用户处理器
|
||||||
type AdminUserHandler struct {
|
type AdminUserHandler struct {
|
||||||
adminUserService service.AdminUserService
|
adminUserService service.AdminUserService
|
||||||
|
pushService service.PushService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminUserHandler 创建管理端用户处理器
|
// NewAdminUserHandler 创建管理端用户处理器
|
||||||
func NewAdminUserHandler(adminUserService service.AdminUserService) *AdminUserHandler {
|
func NewAdminUserHandler(adminUserService service.AdminUserService, pushService service.PushService) *AdminUserHandler {
|
||||||
return &AdminUserHandler{
|
return &AdminUserHandler{
|
||||||
adminUserService: adminUserService,
|
adminUserService: adminUserService,
|
||||||
|
pushService: pushService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,3 +105,22 @@ func (h *AdminUserHandler) UpdateUserStatus(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, userDetail)
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"carrot_bbs/internal/dto"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/pkg/cursor"
|
||||||
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CommentHandler 评论处理器
|
// CommentHandler 评论处理器
|
||||||
type CommentHandler struct {
|
type CommentHandler struct {
|
||||||
commentService *service.CommentService
|
commentService service.CommentService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCommentHandler 创建评论处理器
|
// NewCommentHandler 创建评论处理器
|
||||||
func NewCommentHandler(commentService *service.CommentService) *CommentHandler {
|
func NewCommentHandler(commentService service.CommentService) *CommentHandler {
|
||||||
return &CommentHandler{
|
return &CommentHandler{
|
||||||
commentService: commentService,
|
commentService: commentService,
|
||||||
}
|
}
|
||||||
@@ -33,10 +34,11 @@ func (h *CommentHandler) Create(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateRequest struct {
|
type CreateRequest struct {
|
||||||
PostID string `json:"post_id" binding:"required"`
|
PostID string `json:"post_id" binding:"required"`
|
||||||
Content string `json:"content"` // 内容可选,允许只发图片
|
Content string `json:"content"`
|
||||||
ParentID *string `json:"parent_id"`
|
Segments model.MessageSegments `json:"segments"`
|
||||||
Images []string `json:"images"` // 图片URL列表
|
ParentID *string `json:"parent_id"`
|
||||||
|
Images []string `json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var req CreateRequest
|
var req CreateRequest
|
||||||
@@ -45,12 +47,18 @@ func (h *CommentHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证:评论必须有内容或图片
|
// 验证:评论必须有内容或图片或segments
|
||||||
if req.Content == "" && len(req.Images) == 0 {
|
if req.Content == "" && len(req.Images) == 0 && len(req.Segments) == 0 {
|
||||||
response.BadRequest(c, "评论内容或图片不能同时为空")
|
response.BadRequest(c, "评论内容或图片不能同时为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果没有content但有segments,从segments提取文本
|
||||||
|
content := req.Content
|
||||||
|
if content == "" && len(req.Segments) > 0 {
|
||||||
|
content = dto.ExtractTextContent(req.Segments)
|
||||||
|
}
|
||||||
|
|
||||||
// 将图片列表转换为JSON字符串
|
// 将图片列表转换为JSON字符串
|
||||||
var imagesJSON string
|
var imagesJSON string
|
||||||
if len(req.Images) > 0 {
|
if len(req.Images) > 0 {
|
||||||
@@ -58,13 +66,8 @@ func (h *CommentHandler) Create(c *gin.Context) {
|
|||||||
imagesJSON = string(imagesBytes)
|
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 {
|
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")
|
response.InternalServerError(c, "failed to create comment")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -175,9 +178,9 @@ func (h *CommentHandler) Update(c *gin.Context) {
|
|||||||
|
|
||||||
comment.Content = req.Content
|
comment.Content = req.Content
|
||||||
|
|
||||||
err = h.commentService.Update(c.Request.Context(), comment)
|
err = h.commentService.Update(c.Request.Context(), userID, comment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to update comment")
|
response.HandleError(c, err, "failed to update comment")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,9 +208,9 @@ func (h *CommentHandler) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.commentService.Delete(c.Request.Context(), id)
|
err = h.commentService.Delete(c.Request.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to delete comment")
|
response.HandleError(c, err, "failed to delete comment")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,3 +254,70 @@ func (h *CommentHandler) Unlike(c *gin.Context) {
|
|||||||
|
|
||||||
response.SuccessWithMessage(c, "unliked", nil)
|
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
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"carrot_bbs/internal/dto"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/cursor"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GroupHandler 群组处理器
|
// GroupHandler 群组处理器
|
||||||
@@ -89,7 +91,7 @@ func (h *GroupHandler) GetGroup(c *gin.Context) {
|
|||||||
|
|
||||||
group, err := h.groupService.GetGroupByID(groupID)
|
group, err := h.groupService.GetGroupByID(groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -128,7 +130,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updates := make(map[string]interface{})
|
updates := make(map[string]any)
|
||||||
if req.Name != "" {
|
if req.Name != "" {
|
||||||
updates["name"] = req.Name
|
updates["name"] = req.Name
|
||||||
}
|
}
|
||||||
@@ -144,7 +146,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
|||||||
response.Forbidden(c, "没有权限修改群组信息")
|
response.Forbidden(c, "没有权限修改群组信息")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -177,7 +179,7 @@ func (h *GroupHandler) DissolveGroup(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主可以解散群组")
|
response.Forbidden(c, "只有群主可以解散群组")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -214,7 +216,7 @@ func (h *GroupHandler) TransferOwner(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主可以转让群主")
|
response.Forbidden(c, "只有群主可以转让群主")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -278,7 +280,7 @@ func (h *GroupHandler) InviteMembers(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群成员可以邀请他人")
|
response.Forbidden(c, "只有群成员可以邀请他人")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -313,7 +315,7 @@ func (h *GroupHandler) JoinGroup(c *gin.Context) {
|
|||||||
response.Forbidden(c, "该群不允许加入")
|
response.Forbidden(c, "该群不允许加入")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -352,7 +354,7 @@ func (h *GroupHandler) LeaveGroup(c *gin.Context) {
|
|||||||
response.BadRequest(c, "不是群成员")
|
response.BadRequest(c, "不是群成员")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -389,7 +391,7 @@ func (h *GroupHandler) RemoveMember(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主或管理员可以移除成员")
|
response.Forbidden(c, "只有群主或管理员可以移除成员")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -428,7 +430,7 @@ func (h *GroupHandler) GetMembers(c *gin.Context) {
|
|||||||
|
|
||||||
members, total, err := h.groupService.GetMembers(groupID, page, pageSize)
|
members, total, err := h.groupService.GetMembers(groupID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -516,7 +518,7 @@ func (h *GroupHandler) HandleGetMyMemberInfo(c *gin.Context) {
|
|||||||
// 获取群组信息
|
// 获取群组信息
|
||||||
group, err := h.groupService.GetGroupByID(groupID)
|
group, err := h.groupService.GetGroupByID(groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
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,
|
"member": memberResp,
|
||||||
"mute_all": group.MuteAll,
|
"mute_all": group.MuteAll,
|
||||||
"is_muted": member.Muted || group.MuteAll,
|
"is_muted": member.Muted || group.MuteAll,
|
||||||
@@ -569,7 +571,7 @@ func (h *GroupHandler) HandleDissolveGroup(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主可以解散群组")
|
response.Forbidden(c, "只有群主可以解散群组")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -611,7 +613,7 @@ func (h *GroupHandler) HandleTransferOwner(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主可以转让群主")
|
response.Forbidden(c, "只有群主可以转让群主")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -656,7 +658,7 @@ func (h *GroupHandler) HandleInviteMembers(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主或管理员可以邀请他人")
|
response.Forbidden(c, "只有群主或管理员可以邀请他人")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -695,7 +697,7 @@ func (h *GroupHandler) HandleJoinGroup(c *gin.Context) {
|
|||||||
response.Forbidden(c, "该群不允许加入")
|
response.Forbidden(c, "该群不允许加入")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -740,7 +742,7 @@ func (h *GroupHandler) HandleSetNickname(c *gin.Context) {
|
|||||||
response.BadRequest(c, "不是群成员")
|
response.BadRequest(c, "不是群成员")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -777,7 +779,7 @@ func (h *GroupHandler) HandleSetJoinType(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主可以设置加群方式")
|
response.Forbidden(c, "只有群主可以设置加群方式")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -823,7 +825,7 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主或管理员可以发布公告")
|
response.Forbidden(c, "只有群主或管理员可以发布公告")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -836,6 +838,8 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
|
|||||||
|
|
||||||
// HandleGetAnnouncements 获取群公告列表
|
// HandleGetAnnouncements 获取群公告列表
|
||||||
// GET /api/v1/groups/:id/announcements
|
// GET /api/v1/groups/:id/announcements
|
||||||
|
//
|
||||||
|
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||||
func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -849,12 +853,18 @@ func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 成员校验:公告仅群成员可见。
|
||||||
|
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||||
|
response.Forbidden(c, "不是群成员")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
announcements, total, err := h.groupService.GetAnnouncements(groupID, page, pageSize)
|
announcements, total, err := h.groupService.GetAnnouncements(groupID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -915,7 +925,7 @@ func (h *GroupHandler) GetMyMemberInfo(c *gin.Context) {
|
|||||||
// 获取群组信息
|
// 获取群组信息
|
||||||
group, err := h.groupService.GetGroupByID(groupID)
|
group, err := h.groupService.GetGroupByID(groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
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,
|
"member": memberResp,
|
||||||
"mute_all": group.MuteAll,
|
"mute_all": group.MuteAll,
|
||||||
"is_muted": member.Muted || group.MuteAll,
|
"is_muted": member.Muted || group.MuteAll,
|
||||||
@@ -980,7 +990,7 @@ func (h *GroupHandler) SetMemberRole(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主可以设置成员角色")
|
response.Forbidden(c, "只有群主可以设置成员角色")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1021,7 +1031,7 @@ func (h *GroupHandler) SetNickname(c *gin.Context) {
|
|||||||
response.BadRequest(c, "不是群成员")
|
response.BadRequest(c, "不是群成员")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1064,7 +1074,7 @@ func (h *GroupHandler) MuteMember(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主或管理员可以禁言成员")
|
response.Forbidden(c, "只有群主或管理员可以禁言成员")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1115,7 +1125,7 @@ func (h *GroupHandler) SetMuteAll(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主可以设置全员禁言")
|
response.Forbidden(c, "只有群主可以设置全员禁言")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1156,7 +1166,7 @@ func (h *GroupHandler) SetJoinType(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主可以设置加群方式")
|
response.Forbidden(c, "只有群主可以设置加群方式")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1204,7 +1214,7 @@ func (h *GroupHandler) CreateAnnouncement(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主或管理员可以发布公告")
|
response.Forbidden(c, "只有群主或管理员可以发布公告")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1235,7 +1245,7 @@ func (h *GroupHandler) GetAnnouncements(c *gin.Context) {
|
|||||||
|
|
||||||
announcements, total, err := h.groupService.GetAnnouncements(groupID, page, pageSize)
|
announcements, total, err := h.groupService.GetAnnouncements(groupID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1279,6 +1289,171 @@ func (h *GroupHandler) DeleteAnnouncement(c *gin.Context) {
|
|||||||
response.SuccessWithMessage(c, "公告已删除", nil)
|
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 端点 ====================
|
// ==================== RESTful Action 端点 ====================
|
||||||
|
|
||||||
// HandleSetGroupKick 群组踢人
|
// HandleSetGroupKick 群组踢人
|
||||||
@@ -1314,7 +1489,7 @@ func (h *GroupHandler) HandleSetGroupKick(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主或管理员可以移除成员")
|
response.Forbidden(c, "只有群主或管理员可以移除成员")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1361,19 +1536,31 @@ func (h *GroupHandler) HandleSetGroupBan(c *gin.Context) {
|
|||||||
|
|
||||||
// duration > 0 或 duration = -1 表示禁言,duration = 0 表示解除禁言
|
// duration > 0 或 duration = -1 表示禁言,duration = 0 表示解除禁言
|
||||||
muted := params.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)
|
err := h.groupService.MuteMember(userID, groupID, params.UserID, muted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[HandleSetGroupBan] 禁言操作失败: %v", err)
|
zap.L().Warn("禁言操作失败",
|
||||||
|
zap.String("component", "HandleSetGroupBan"),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[HandleSetGroupBan] 禁言操作成功")
|
zap.L().Info("禁言操作成功",
|
||||||
|
zap.String("component", "HandleSetGroupBan"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrNotGroupAdmin {
|
if err == service.ErrNotGroupAdmin {
|
||||||
response.Forbidden(c, "只有群主或管理员可以禁言成员")
|
response.Forbidden(c, "只有群主或管理员可以禁言成员")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1423,7 +1610,7 @@ func (h *GroupHandler) HandleSetGroupWholeBan(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主可以设置全员禁言")
|
response.Forbidden(c, "只有群主可以设置全员禁言")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1476,7 +1663,7 @@ func (h *GroupHandler) HandleSetGroupAdmin(c *gin.Context) {
|
|||||||
response.Forbidden(c, "只有群主可以设置管理员")
|
response.Forbidden(c, "只有群主可以设置管理员")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1521,7 +1708,7 @@ func (h *GroupHandler) HandleSetGroupName(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
updates := map[string]any{
|
||||||
"name": params.GroupName,
|
"name": params.GroupName,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1531,7 +1718,7 @@ func (h *GroupHandler) HandleSetGroupName(c *gin.Context) {
|
|||||||
response.Forbidden(c, "没有权限修改群组信息")
|
response.Forbidden(c, "没有权限修改群组信息")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1570,7 +1757,7 @@ func (h *GroupHandler) HandleSetGroupAvatar(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
updates := map[string]any{
|
||||||
"avatar": params.Avatar,
|
"avatar": params.Avatar,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1580,7 +1767,51 @@ func (h *GroupHandler) HandleSetGroupAvatar(c *gin.Context) {
|
|||||||
response.Forbidden(c, "没有权限修改群组信息")
|
response.Forbidden(c, "没有权限修改群组信息")
|
||||||
return
|
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, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1614,7 +1845,7 @@ func (h *GroupHandler) HandleSetGroupLeave(c *gin.Context) {
|
|||||||
response.BadRequest(c, "不是群成员")
|
response.BadRequest(c, "不是群成员")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1722,6 +1953,8 @@ func (h *GroupHandler) HandleRespondInvite(c *gin.Context) {
|
|||||||
|
|
||||||
// HandleGetGroupInfo 获取群信息
|
// HandleGetGroupInfo 获取群信息
|
||||||
// GET /api/v1/groups/:id
|
// GET /api/v1/groups/:id
|
||||||
|
//
|
||||||
|
// 可见性策略:成员返回完整详情;非成员仅返回公开字段(name/avatar/description/member_count)。
|
||||||
func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -1737,7 +1970,7 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
|||||||
|
|
||||||
group, err := h.groupService.GetGroupByID(groupID)
|
group, err := h.groupService.GetGroupByID(groupID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1752,11 +1985,26 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
|||||||
resp := dto.GroupToResponse(group)
|
resp := dto.GroupToResponse(group)
|
||||||
resp.MemberCount = memberCount
|
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)
|
response.Success(c, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleGetGroupMemberList 获取群成员列表
|
// HandleGetGroupMemberList 获取群成员列表
|
||||||
// GET /api/v1/groups/:id/members
|
// GET /api/v1/groups/:id/members
|
||||||
|
//
|
||||||
|
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||||
func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
||||||
userID := parseUserID(c)
|
userID := parseUserID(c)
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -1773,9 +2021,14 @@ func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
|||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
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 != nil {
|
||||||
if err == service.ErrGroupNotFound {
|
if errors.Is(err, service.ErrNotGroupMember) {
|
||||||
|
response.Forbidden(c, "不是群成员")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrGroupNotFound) {
|
||||||
response.NotFound(c, "群组不存在")
|
response.NotFound(c, "群组不存在")
|
||||||
return
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"carrot_bbs/internal/dto"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/cursor"
|
||||||
"carrot_bbs/internal/pkg/sse"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/service"
|
"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 消息处理器
|
// MessageHandler 消息处理器
|
||||||
type MessageHandler struct {
|
type MessageHandler struct {
|
||||||
chatService service.ChatService
|
chatService service.ChatService
|
||||||
messageService *service.MessageService
|
messageService service.MessageService
|
||||||
userService service.UserService
|
userService service.UserService
|
||||||
groupService service.GroupService
|
groupService service.GroupService
|
||||||
sseHub *sse.Hub
|
uploadService service.UploadService
|
||||||
|
wsPublisher ws.MessagePublisher
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessageHandler 创建消息处理器
|
// 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{
|
return &MessageHandler{
|
||||||
chatService: chatService,
|
chatService: chatService,
|
||||||
messageService: messageService,
|
messageService: messageService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
groupService: groupService,
|
groupService: groupService,
|
||||||
sseHub: sseHub,
|
uploadService: uploadService,
|
||||||
|
wsPublisher: wsPublisher,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleSSE 实时消息订阅(SSE)
|
// convertMessagesWithExpiry 将消息列表转换为响应,并对已过期的文件 segment 注入 expired 标记。
|
||||||
// GET /api/v1/realtime/sse
|
// 若 uploadService 不可用或查询失败,降级为不带过期标记的普通转换。
|
||||||
func (h *MessageHandler) HandleSSE(c *gin.Context) {
|
func (h *MessageHandler) convertMessagesWithExpiry(ctx context.Context, messages []*model.Message) []*dto.MessageResponse {
|
||||||
userID := c.GetString("user_id")
|
var expiredSet map[string]struct{}
|
||||||
if userID == "" {
|
if h.uploadService != nil {
|
||||||
response.Unauthorized(c, "")
|
if es, err := h.uploadService.GetExpiredURLSet(ctx); err == nil {
|
||||||
return
|
expiredSet = es
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(expiredSet) == 0 {
|
||||||
heartbeat := time.NewTicker(25 * time.Second)
|
return dto.ConvertMessagesToResponse(messages)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return dto.ConvertMessagesToResponseWithExpiry(messages, expiredSet)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleTyping 输入状态上报
|
// HandleTyping 输入状态上报
|
||||||
@@ -154,36 +167,8 @@ func (h *MessageHandler) GetConversations(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 批量填充会话数据(解决 N+1 问题)
|
||||||
result := make([]*dto.ConversationResponse, len(filteredConvs))
|
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 total 为过滤后的数量
|
// 更新 total 为过滤后的数量
|
||||||
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
||||||
@@ -227,8 +212,9 @@ func (h *MessageHandler) CreateConversation(c *gin.Context) {
|
|||||||
participants := []*model.User{targetUser}
|
participants := []*model.User{targetUser}
|
||||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
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 获取会话详情
|
// GetConversationByID 获取会话详情
|
||||||
@@ -262,18 +248,26 @@ func (h *MessageHandler) GetConversationByID(c *gin.Context) {
|
|||||||
// 获取当前用户的已读位置
|
// 获取当前用户的已读位置
|
||||||
myLastReadSeq := int64(0)
|
myLastReadSeq := int64(0)
|
||||||
isPinned := false
|
isPinned := false
|
||||||
|
notificationMuted := false
|
||||||
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
|
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
|
||||||
for _, p := range allParticipants {
|
for _, p := range allParticipants {
|
||||||
if p.UserID == userID {
|
if p.UserID == userID {
|
||||||
myLastReadSeq = p.LastReadSeq
|
myLastReadSeq = p.LastReadSeq
|
||||||
isPinned = p.IsPinned
|
isPinned = p.IsPinned
|
||||||
|
notificationMuted = p.NotificationMuted
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取对方用户的已读位置
|
// 获取对方用户的已读位置
|
||||||
otherLastReadSeq := int64(0)
|
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 获取消息列表
|
// GetMessages 获取消息列表
|
||||||
@@ -310,8 +304,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -338,8 +332,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -358,12 +352,43 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Paginated(c, result, total, page, pageSize)
|
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 发送消息
|
// SendMessage 发送消息
|
||||||
// POST /api/conversations/:id/messages
|
// POST /api/conversations/:id/messages
|
||||||
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
||||||
@@ -386,8 +411,7 @@ func (h *MessageHandler) SendMessage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接使用 segments
|
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
|
||||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
@@ -437,13 +461,14 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
|
|||||||
|
|
||||||
// 构建 WSEventResponse 格式响应
|
// 构建 WSEventResponse 格式响应
|
||||||
wsResponse := dto.WSEventResponse{
|
wsResponse := dto.WSEventResponse{
|
||||||
ID: msg.ID,
|
ID: msg.ID,
|
||||||
Time: msg.CreatedAt.UnixMilli(),
|
Time: msg.CreatedAt.UnixMilli(),
|
||||||
Type: "message",
|
Type: "message",
|
||||||
DetailType: params.DetailType,
|
DetailType: params.DetailType,
|
||||||
Seq: strconv.FormatInt(msg.Seq, 10),
|
ConversationID: conversationID,
|
||||||
Segments: params.Segments,
|
Seq: msg.Seq,
|
||||||
SenderID: userID,
|
Segments: params.Segments,
|
||||||
|
SenderID: userID,
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, wsResponse)
|
response.Success(c, wsResponse)
|
||||||
@@ -507,36 +532,8 @@ func (h *MessageHandler) HandleGetConversationList(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 批量填充会话数据(解决 N+1 问题)
|
||||||
result := make([]*dto.ConversationResponse, len(filteredConvs))
|
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
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 获取单个会话的未读数
|
// GetConversationUnreadCount 获取单个会话的未读数
|
||||||
// GET /api/conversations/:id/unread/count
|
// GET /api/conversations/:id/unread/count
|
||||||
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
|
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
|
||||||
@@ -684,16 +732,6 @@ func (h *MessageHandler) DeleteMessage(c *gin.Context) {
|
|||||||
response.SuccessWithMessage(c, "message deleted", nil)
|
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) {
|
func (h *MessageHandler) getConversationParticipants(ctx context.Context, conversationID string, currentUserID string) ([]*model.User, error) {
|
||||||
// 从repository获取参与者列表
|
// 从repository获取参与者列表
|
||||||
@@ -777,8 +815,9 @@ func (h *MessageHandler) HandleCreateConversation(c *gin.Context) {
|
|||||||
participants := []*model.User{targetUser}
|
participants := []*model.User{targetUser}
|
||||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
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 获取会话详情
|
// HandleGetConversation 获取会话详情
|
||||||
@@ -808,21 +847,29 @@ func (h *MessageHandler) HandleGetConversation(c *gin.Context) {
|
|||||||
// 获取参与者信息
|
// 获取参与者信息
|
||||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID)
|
participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID)
|
||||||
|
|
||||||
// 获取当前用户的已读位置
|
// 获取当前用户的已读位置(优先从 Redis 缓存读取)
|
||||||
myLastReadSeq := int64(0)
|
myLastReadSeq := int64(0)
|
||||||
isPinned := false
|
isPinned := false
|
||||||
|
notificationMuted := false
|
||||||
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
|
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
|
||||||
for _, p := range allParticipants {
|
for _, p := range allParticipants {
|
||||||
if p.UserID == userID {
|
if p.UserID == userID {
|
||||||
myLastReadSeq = p.LastReadSeq
|
myLastReadSeq = p.LastReadSeq
|
||||||
isPinned = p.IsPinned
|
isPinned = p.IsPinned
|
||||||
|
notificationMuted = p.NotificationMuted
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取对方用户的已读位置
|
// 获取对方用户的已读位置(优先从 Redis 缓存读取)
|
||||||
otherLastReadSeq := int64(0)
|
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 获取会话消息
|
// HandleGetMessages 获取会话消息
|
||||||
@@ -858,8 +905,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -886,8 +933,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Success(c, &dto.MessageSyncResponse{
|
response.Success(c, &dto.MessageSyncResponse{
|
||||||
Messages: result,
|
Messages: result,
|
||||||
@@ -906,8 +953,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换为响应格式
|
// 转换为响应格式(标记已过期文件)
|
||||||
result := dto.ConvertMessagesToResponse(messages)
|
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||||
|
|
||||||
response.Paginated(c, result, total, page, pageSize)
|
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)
|
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 设置会话置顶
|
// HandleSetConversationPinned 设置会话置顶
|
||||||
// PUT /api/v1/conversations/:id/pinned
|
// PUT /api/v1/conversations/:id/pinned
|
||||||
func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
||||||
@@ -975,3 +1049,129 @@ func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
|||||||
"is_pinned": req.IsPinned,
|
"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"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/pkg/cursor"
|
||||||
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NotificationHandler 通知处理器
|
// NotificationHandler 通知处理器
|
||||||
type NotificationHandler struct {
|
type NotificationHandler struct {
|
||||||
notificationService *service.NotificationService
|
notificationService service.NotificationService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNotificationHandler 创建通知处理器
|
// NewNotificationHandler 创建通知处理器
|
||||||
func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler {
|
func NewNotificationHandler(notificationService service.NotificationService) *NotificationHandler {
|
||||||
return &NotificationHandler{
|
return &NotificationHandler{
|
||||||
notificationService: notificationService,
|
notificationService: notificationService,
|
||||||
}
|
}
|
||||||
@@ -54,7 +56,7 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
|||||||
|
|
||||||
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
|
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to mark as read")
|
response.HandleError(c, err, "failed to mark as read")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
|
|||||||
|
|
||||||
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
|
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to delete notification")
|
response.HandleError(c, err, "failed to delete notification")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,3 +132,46 @@ func (h *NotificationHandler) ClearAllNotifications(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, gin.H{"success": true})
|
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
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"carrot_bbs/internal/dto"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/cursor"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/pkg/response"
|
||||||
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostHandler 帖子处理器
|
// PostHandler 帖子处理器
|
||||||
type PostHandler struct {
|
type PostHandler struct {
|
||||||
postService service.PostService
|
postService service.PostService
|
||||||
userService service.UserService
|
postRefService service.PostRefService
|
||||||
|
userService service.UserService
|
||||||
|
channelService service.ChannelService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPostHandler 创建帖子处理器
|
// 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{
|
return &PostHandler{
|
||||||
postService: postService,
|
postService: postService,
|
||||||
userService: userService,
|
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 创建帖子
|
// Create 创建帖子
|
||||||
func (h *PostHandler) Create(c *gin.Context) {
|
func (h *PostHandler) Create(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
@@ -35,30 +92,42 @@ func (h *PostHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateRequest struct {
|
var req dto.CreatePostWithSegmentsRequest
|
||||||
Title string `json:"title" binding:"required"`
|
|
||||||
Content string `json:"content" binding:"required"`
|
|
||||||
Images []string `json:"images"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CreateRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
var moderationErr *service.PostModerationRejectedError
|
// 幂等命中"进行中"占位:提示客户端稍候,避免重复提交
|
||||||
if errors.As(err, &moderationErr) {
|
if errors.Is(err, service.ErrDuplicatePostRequest) {
|
||||||
response.BadRequest(c, moderationErr.UserMessage())
|
response.ErrorWithStatusCode(c, http.StatusTooManyRequests, 429, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.InternalServerError(c, "failed to create post")
|
response.InternalServerError(c, "failed to create post")
|
||||||
return
|
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 获取帖子(不增加浏览量)
|
// GetByID 获取帖子(不增加浏览量)
|
||||||
@@ -80,44 +149,60 @@ func (h *PostHandler) GetByID(c *gin.Context) {
|
|||||||
|
|
||||||
// 注意:不再自动增加浏览量,浏览量通过 RecordView 端点单独记录
|
// 注意:不再自动增加浏览量,浏览量通过 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)
|
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, currentUserID)
|
||||||
|
|
||||||
// 如果有当前用户,检查与帖子作者的相互关注状态
|
|
||||||
var authorWithFollowStatus *dto.UserResponse
|
var authorWithFollowStatus *dto.UserResponse
|
||||||
if currentUserID != "" && post.User != nil {
|
if post.User != nil {
|
||||||
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
|
if currentUserID != "" {
|
||||||
if err == nil {
|
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
|
||||||
authorWithFollowStatus = dto.ConvertUserToResponseWithMutualFollow(post.User, isFollowing, isFollowingMe)
|
if err == nil {
|
||||||
|
authorWithFollowStatus = dto.ConvertUserToResponse(post.User, dto.WithFollowing(isFollowing, isFollowingMe))
|
||||||
|
} else {
|
||||||
|
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果出错,使用默认的author
|
|
||||||
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
|
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建响应
|
// 构建响应
|
||||||
responseData := &dto.PostResponse{
|
responseData := &dto.PostResponse{
|
||||||
ID: post.ID,
|
ID: post.ID,
|
||||||
UserID: post.UserID,
|
UserID: post.UserID,
|
||||||
Title: post.Title,
|
ChannelID: post.ChannelID,
|
||||||
Content: post.Content,
|
Title: post.Title,
|
||||||
Images: dto.ConvertPostImagesToResponse(post.Images),
|
Content: post.Content,
|
||||||
Status: string(post.Status),
|
Segments: enrichedSegments,
|
||||||
LikesCount: post.LikesCount,
|
Images: dto.ConvertPostImagesToResponse(post.Images),
|
||||||
CommentsCount: post.CommentsCount,
|
Status: string(post.Status),
|
||||||
FavoritesCount: post.FavoritesCount,
|
LikesCount: post.LikesCount,
|
||||||
SharesCount: post.SharesCount,
|
CommentsCount: post.CommentsCount,
|
||||||
ViewsCount: post.ViewsCount,
|
FavoritesCount: post.FavoritesCount,
|
||||||
IsPinned: post.IsPinned,
|
SharesCount: post.SharesCount,
|
||||||
IsLocked: post.IsLocked,
|
ViewsCount: post.ViewsCount,
|
||||||
IsVote: post.IsVote,
|
IsPinned: post.IsPinned,
|
||||||
CreatedAt: dto.FormatTime(post.CreatedAt),
|
IsLocked: post.IsLocked,
|
||||||
UpdatedAt: dto.FormatTime(post.UpdatedAt),
|
IsVote: post.IsVote,
|
||||||
Author: authorWithFollowStatus,
|
CreatedAt: dto.FormatTime(post.CreatedAt),
|
||||||
IsLiked: isLiked,
|
UpdatedAt: dto.FormatTime(post.UpdatedAt),
|
||||||
IsFavorited: isFavorited,
|
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)
|
response.Success(c, responseData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,12 +228,61 @@ func (h *PostHandler) RecordView(c *gin.Context) {
|
|||||||
response.Success(c, gin.H{"success": true})
|
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) {
|
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"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
page, pageSize = normalizePagination(page, pageSize)
|
||||||
userID := c.Query("user_id")
|
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
|
// 获取当前用户ID
|
||||||
currentUserID := c.GetString("user_id")
|
currentUserID := c.GetString("user_id")
|
||||||
@@ -160,31 +294,24 @@ func (h *PostHandler) List(c *gin.Context) {
|
|||||||
// 根据 tab 参数选择不同的获取方式
|
// 根据 tab 参数选择不同的获取方式
|
||||||
switch tab {
|
switch tab {
|
||||||
case "follow":
|
case "follow":
|
||||||
// 获取关注用户的帖子,需要登录
|
|
||||||
if currentUserID == "" {
|
if currentUserID == "" {
|
||||||
response.Unauthorized(c, "请先登录")
|
response.Unauthorized(c, "请先登录")
|
||||||
return
|
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":
|
case "hot":
|
||||||
// 获取热门帖子
|
posts, total, err = h.postService.GetHotPosts(c.Request.Context(), page, pageSize, channelID)
|
||||||
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)
|
|
||||||
case "latest":
|
case "latest":
|
||||||
// 最新帖子
|
|
||||||
if userID != "" && userID == currentUserID {
|
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 {
|
} 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:
|
default:
|
||||||
// 默认获取最新帖子
|
|
||||||
if userID != "" && userID == currentUserID {
|
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 {
|
} 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
|
||||||
|
|
||||||
// 批量获取交互状态
|
// 批量获取交互状态
|
||||||
postIDs := make([]string, len(posts))
|
postIDs := make([]string, len(posts))
|
||||||
for i, post := range 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)
|
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||||
|
|
||||||
// 转换为响应结构
|
chMap := h.channelMapForPosts(posts)
|
||||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||||
|
|
||||||
response.Paginated(c, postResponses, total, page, pageSize)
|
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 更新帖子
|
// Update 更新帖子
|
||||||
func (h *PostHandler) Update(c *gin.Context) {
|
func (h *PostHandler) Update(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
@@ -228,9 +422,10 @@ func (h *PostHandler) Update(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateRequest struct {
|
type UpdateRequest struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Images *[]string `json:"images"`
|
Segments model.MessageSegments `json:"segments"`
|
||||||
|
Images *[]string `json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var req UpdateRequest
|
var req UpdateRequest
|
||||||
@@ -245,6 +440,12 @@ func (h *PostHandler) Update(c *gin.Context) {
|
|||||||
if req.Content != "" {
|
if req.Content != "" {
|
||||||
post.Content = 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)
|
err = h.postService.UpdateWithImages(c.Request.Context(), post, req.Images)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -252,6 +453,15 @@ func (h *PostHandler) Update(c *gin.Context) {
|
|||||||
return
|
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)
|
post, err = h.postService.GetByID(c.Request.Context(), post.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to get updated post")
|
response.InternalServerError(c, "failed to get updated post")
|
||||||
@@ -262,7 +472,8 @@ func (h *PostHandler) Update(c *gin.Context) {
|
|||||||
currentUserID := c.GetString("user_id")
|
currentUserID := c.GetString("user_id")
|
||||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), post.ID, currentUserID)
|
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 删除帖子
|
// Delete 删除帖子
|
||||||
@@ -286,12 +497,21 @@ func (h *PostHandler) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.postService.Delete(c.Request.Context(), id)
|
err = h.postService.Delete(c.Request.Context(), userID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to delete post")
|
response.HandleError(c, err, "failed to delete post")
|
||||||
return
|
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)
|
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)
|
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 取消点赞
|
// Unlike 取消点赞
|
||||||
@@ -350,7 +571,8 @@ func (h *PostHandler) Unlike(c *gin.Context) {
|
|||||||
// 获取交互状态
|
// 获取交互状态
|
||||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
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 收藏帖子
|
// Favorite 收藏帖子
|
||||||
@@ -379,7 +601,8 @@ func (h *PostHandler) Favorite(c *gin.Context) {
|
|||||||
// 获取交互状态
|
// 获取交互状态
|
||||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
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 取消收藏
|
// Unfavorite 取消收藏
|
||||||
@@ -408,16 +631,63 @@ func (h *PostHandler) Unfavorite(c *gin.Context) {
|
|||||||
// 获取交互状态
|
// 获取交互状态
|
||||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
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) {
|
func (h *PostHandler) GetUserPosts(c *gin.Context) {
|
||||||
userID := c.Param("id")
|
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"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
page, pageSize = normalizePagination(page, pageSize)
|
||||||
|
|
||||||
currentUserID := c.GetString("user_id")
|
|
||||||
includePending := currentUserID != "" && currentUserID == userID
|
includePending := currentUserID != "" && currentUserID == userID
|
||||||
posts, total, err := h.postService.GetUserPosts(c.Request.Context(), userID, page, pageSize, includePending)
|
posts, total, err := h.postService.GetUserPosts(c.Request.Context(), userID, page, pageSize, includePending)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -432,17 +702,69 @@ func (h *PostHandler) GetUserPosts(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||||
|
|
||||||
// 转换为响应结构
|
chMap := h.channelMapForPosts(posts)
|
||||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||||
|
|
||||||
response.Paginated(c, postResponses, total, page, pageSize)
|
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 获取收藏列表
|
// GetFavorites 获取收藏列表
|
||||||
func (h *PostHandler) GetFavorites(c *gin.Context) {
|
func (h *PostHandler) GetFavorites(c *gin.Context) {
|
||||||
userID := c.Param("id")
|
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"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
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)
|
posts, total, err := h.postService.GetFavorites(c.Request.Context(), userID, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -450,16 +772,14 @@ func (h *PostHandler) GetFavorites(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量获取交互状态
|
|
||||||
currentUserID := c.GetString("user_id")
|
|
||||||
postIDs := make([]string, len(posts))
|
postIDs := make([]string, len(posts))
|
||||||
for i, post := range posts {
|
for i, post := range posts {
|
||||||
postIDs[i] = post.ID
|
postIDs[i] = post.ID
|
||||||
}
|
}
|
||||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||||
|
|
||||||
// 转换为响应结构
|
chMap := h.channelMapForPosts(posts)
|
||||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||||
|
|
||||||
response.Paginated(c, postResponses, total, page, pageSize)
|
response.Paginated(c, postResponses, total, page, pageSize)
|
||||||
}
|
}
|
||||||
@@ -467,8 +787,18 @@ func (h *PostHandler) GetFavorites(c *gin.Context) {
|
|||||||
// Search 搜索帖子
|
// Search 搜索帖子
|
||||||
func (h *PostHandler) Search(c *gin.Context) {
|
func (h *PostHandler) Search(c *gin.Context) {
|
||||||
keyword := c.Query("keyword")
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
|
// 检查是否使用游标分页
|
||||||
|
cursorStr := c.Query("cursor")
|
||||||
|
if cursorStr != "" {
|
||||||
|
h.SearchByCursor(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 偏移分页(向后兼容)
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
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)
|
posts, total, err := h.postService.Search(c.Request.Context(), keyword, page, pageSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -478,14 +808,172 @@ func (h *PostHandler) Search(c *gin.Context) {
|
|||||||
|
|
||||||
// 批量获取交互状态
|
// 批量获取交互状态
|
||||||
currentUserID := c.GetString("user_id")
|
currentUserID := c.GetString("user_id")
|
||||||
|
|
||||||
|
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
|
||||||
|
|
||||||
postIDs := make([]string, len(posts))
|
postIDs := make([]string, len(posts))
|
||||||
for i, post := range posts {
|
for i, post := range posts {
|
||||||
postIDs[i] = post.ID
|
postIDs[i] = post.ID
|
||||||
}
|
}
|
||||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||||
|
|
||||||
// 转换为响应结构
|
chMap := h.channelMapForPosts(posts)
|
||||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||||
|
|
||||||
response.Paginated(c, postResponses, total, page, pageSize)
|
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
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"carrot_bbs/internal/dto"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -67,7 +67,7 @@ func (h *PushHandler) UnregisterDevice(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.pushService.UnregisterDevice(c.Request.Context(), deviceID)
|
err := h.pushService.UnregisterDevice(c.Request.Context(), userID, deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to unregister device")
|
response.InternalServerError(c, "failed to unregister device")
|
||||||
return
|
return
|
||||||
@@ -85,12 +85,13 @@ func (h *PushHandler) GetMyDevices(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这里需要从DeviceTokenRepository获取设备列表
|
devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
|
||||||
// 由于PushService接口没有提供获取设备列表的方法,我们暂时返回空列表
|
if err != nil {
|
||||||
// TODO: 在PushService接口中添加GetUserDevices方法
|
response.InternalServerError(c, "failed to get devices")
|
||||||
_ = userID // 避免未使用变量警告
|
return
|
||||||
|
}
|
||||||
|
|
||||||
response.Success(c, []*dto.DeviceTokenResponse{})
|
response.Success(c, dto.DeviceTokensToResponse(devices))
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPushRecords 获取推送记录
|
// GetPushRecords 获取推送记录
|
||||||
@@ -140,14 +141,14 @@ func (h *PushHandler) UpdateDeviceToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
PushToken string `json:"push_token" binding:"required"`
|
PushToken string `json:"push_token"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to update device token")
|
response.InternalServerError(c, "failed to update device token")
|
||||||
return
|
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"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
apperrors "carrot_bbs/internal/errors"
|
apperrors "with_you/internal/errors"
|
||||||
"carrot_bbs/internal/model"
|
"with_you/internal/model"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ScheduleHandler struct {
|
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
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/service"
|
"with_you/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StickerHandler 自定义表情处理器
|
// StickerHandler 自定义表情处理器
|
||||||
@@ -81,11 +82,11 @@ func (h *StickerHandler) AddSticker(c *gin.Context) {
|
|||||||
|
|
||||||
sticker, err := h.stickerService.AddSticker(userID, req.URL, req.Width, req.Height)
|
sticker, err := h.stickerService.AddSticker(userID, req.URL, req.Width, req.Height)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrStickerAlreadyExists {
|
if errors.Is(err, service.ErrStickerAlreadyExists) {
|
||||||
response.Error(c, http.StatusConflict, "sticker already exists")
|
response.Error(c, http.StatusConflict, "sticker already exists")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err == service.ErrInvalidStickerURL {
|
if errors.Is(err, service.ErrInvalidStickerURL) {
|
||||||
response.BadRequest(c, "invalid sticker url, only http/https is allowed")
|
response.BadRequest(c, "invalid sticker url, only http/https is allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,33 +3,24 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"carrot_bbs/internal/cache"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"carrot_bbs/internal/dto"
|
"with_you/internal/dto"
|
||||||
"carrot_bbs/internal/pkg/response"
|
"with_you/internal/pkg/response"
|
||||||
"carrot_bbs/internal/repository"
|
"with_you/internal/service"
|
||||||
"carrot_bbs/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SystemMessageHandler 系统消息处理器
|
// SystemMessageHandler 系统消息处理器
|
||||||
type SystemMessageHandler struct {
|
type SystemMessageHandler struct {
|
||||||
systemMsgService service.SystemMessageService
|
systemMsgService service.SystemMessageService
|
||||||
notifyRepo *repository.SystemNotificationRepository
|
|
||||||
cache cache.Cache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSystemMessageHandler 创建系统消息处理器
|
// NewSystemMessageHandler 创建系统消息处理器
|
||||||
func NewSystemMessageHandler(
|
func NewSystemMessageHandler(
|
||||||
systemMsgService service.SystemMessageService,
|
systemMsgService service.SystemMessageService,
|
||||||
notifyRepo *repository.SystemNotificationRepository,
|
|
||||||
cacheBackend cache.Cache,
|
|
||||||
) *SystemMessageHandler {
|
) *SystemMessageHandler {
|
||||||
return &SystemMessageHandler{
|
return &SystemMessageHandler{
|
||||||
systemMsgService: systemMsgService,
|
systemMsgService: systemMsgService,
|
||||||
notifyRepo: notifyRepo,
|
|
||||||
cache: cacheBackend,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +33,19 @@ func (h *SystemMessageHandler) GetSystemMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否使用游标分页
|
||||||
|
_, cursorExists := c.GetQuery("cursor")
|
||||||
|
if cursorExists {
|
||||||
|
h.GetSystemMessagesByCursor(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 偏移分页(向后兼容)
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
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 {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to get system messages")
|
response.InternalServerError(c, "failed to get system messages")
|
||||||
return
|
return
|
||||||
@@ -62,6 +61,43 @@ func (h *SystemMessageHandler) GetSystemMessages(c *gin.Context) {
|
|||||||
response.Paginated(c, result, total, page, pageSize)
|
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 获取系统消息未读数
|
// GetUnreadCount 获取系统消息未读数
|
||||||
// GET /api/v1/messages/system/unread-count
|
// GET /api/v1/messages/system/unread-count
|
||||||
func (h *SystemMessageHandler) GetUnreadCount(c *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to get unread count")
|
response.InternalServerError(c, "failed to get unread count")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to mark as read")
|
response.InternalServerError(c, "failed to mark as read")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cache.InvalidateUnreadSystem(h.cache, userID)
|
|
||||||
|
|
||||||
response.SuccessWithMessage(c, "marked as read", nil)
|
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 {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to mark all as read")
|
response.InternalServerError(c, "failed to mark all as read")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cache.InvalidateUnreadSystem(h.cache, userID)
|
|
||||||
|
|
||||||
response.SuccessWithMessage(c, "all messages marked as read", nil)
|
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 {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to delete notification")
|
response.InternalServerError(c, "failed to delete notification")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cache.InvalidateUnreadSystem(h.cache, userID)
|
|
||||||
|
|
||||||
response.SuccessWithMessage(c, "notification deleted", nil)
|
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})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user