Compare commits
118 Commits
fc0d6ff19d
...
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 |
@@ -11,7 +11,8 @@ on:
|
||||
- dev
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.25'
|
||||
GO_VERSION: '1.26'
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -24,7 +25,7 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
cache: false
|
||||
|
||||
- name: Go cache info
|
||||
run: |
|
||||
@@ -34,7 +35,15 @@ jobs:
|
||||
go env GOMODCACHE
|
||||
|
||||
- name: Install build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y gcc
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Deploy
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -25,9 +25,9 @@ on:
|
||||
env:
|
||||
REGISTRY: code.littlelan.cn
|
||||
IMAGE_NAME: carrot_bbs/backend
|
||||
SERVER_HOST: 111.170.19.33
|
||||
SERVER_USER: root
|
||||
CONTAINER_NAME: carrot_bbs
|
||||
SERVER_HOST: 124.222.83.58
|
||||
SERVER_USER: ubuntu
|
||||
CONTAINER_NAME: with_you
|
||||
CONTAINER_NETWORK: 1panel-network
|
||||
|
||||
jobs:
|
||||
@@ -51,35 +51,35 @@ jobs:
|
||||
NETWORK="${{ env.CONTAINER_NETWORK }}"
|
||||
|
||||
echo "=== Pulling image: ${IMAGE} ==="
|
||||
docker pull ${IMAGE}
|
||||
sudo docker pull ${IMAGE}
|
||||
|
||||
echo "=== Stopping old container ==="
|
||||
docker stop ${CONTAINER} 2>/dev/null || true
|
||||
docker rm ${CONTAINER} 2>/dev/null || true
|
||||
sudo docker stop ${CONTAINER} 2>/dev/null || true
|
||||
sudo docker rm ${CONTAINER} 2>/dev/null || true
|
||||
|
||||
echo "=== Starting new container ==="
|
||||
docker run -d \
|
||||
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-t0g7 \
|
||||
-e APP_DATABASE_POSTGRES_HOST=1Panel-postgresql-Mjd1 \
|
||||
-e APP_DATABASE_POSTGRES_PORT=5432 \
|
||||
-e APP_DATABASE_POSTGRES_USER=carrot_bbs \
|
||||
-e APP_DATABASE_POSTGRES_USER=with_you \
|
||||
-e "APP_DATABASE_POSTGRES_PASSWORD=${{ secrets.DB_PASSWORD }}" \
|
||||
-e APP_DATABASE_POSTGRES_DBNAME=carrot_bbs \
|
||||
-e APP_DATABASE_POSTGRES_DBNAME=with_you \
|
||||
-e APP_REDIS_TYPE=redis \
|
||||
-e APP_REDIS_REDIS_HOST=1Panel-redis-dfmM \
|
||||
-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=files.littlelan.cn \
|
||||
-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=test \
|
||||
-e APP_S3_DOMAIN=files.littlelan.cn \
|
||||
-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 \
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
-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.5-plus \
|
||||
-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 \
|
||||
@@ -104,12 +104,29 @@ jobs:
|
||||
-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=Carrot_BBS \
|
||||
-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 ==="
|
||||
@@ -126,6 +143,7 @@ jobs:
|
||||
done
|
||||
|
||||
echo "=== Container logs ==="
|
||||
docker logs ${CONTAINER} --tail 20
|
||||
sudo docker logs ${CONTAINER} --tail 20
|
||||
|
||||
echo "=== Deployment completed ==="
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Build artifacts
|
||||
/server
|
||||
*.exe
|
||||
*.tar
|
||||
|
||||
# Runtime files
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,674 +0,0 @@
|
||||
---
|
||||
name: golang-patterns
|
||||
description: 用于构建健壮、高效且可维护的Go应用程序的惯用Go模式、最佳实践和约定。
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Go 开发模式
|
||||
|
||||
用于构建健壮、高效和可维护应用程序的惯用 Go 模式与最佳实践。
|
||||
|
||||
## 何时激活
|
||||
|
||||
* 编写新的 Go 代码时
|
||||
* 审查 Go 代码时
|
||||
* 重构现有 Go 代码时
|
||||
* 设计 Go 包/模块时
|
||||
|
||||
## 核心原则
|
||||
|
||||
### 1. 简洁与清晰
|
||||
|
||||
Go 推崇简洁而非精巧。代码应该显而易见且易于阅读。
|
||||
|
||||
```go
|
||||
// Good: Clear and direct
|
||||
func GetUser(id string) (*User, error) {
|
||||
user, err := db.FindUser(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user %s: %w", id, err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Bad: Overly clever
|
||||
func GetUser(id string) (*User, error) {
|
||||
return func() (*User, error) {
|
||||
if u, e := db.FindUser(id); e == nil {
|
||||
return u, nil
|
||||
} else {
|
||||
return nil, e
|
||||
}
|
||||
}()
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 让零值变得有用
|
||||
|
||||
设计类型时,应使其零值无需初始化即可立即使用。
|
||||
|
||||
```go
|
||||
// Good: Zero value is useful
|
||||
type Counter struct {
|
||||
mu sync.Mutex
|
||||
count int // zero value is 0, ready to use
|
||||
}
|
||||
|
||||
func (c *Counter) Inc() {
|
||||
c.mu.Lock()
|
||||
c.count++
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Good: bytes.Buffer works with zero value
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("hello")
|
||||
|
||||
// Bad: Requires initialization
|
||||
type BadCounter struct {
|
||||
counts map[string]int // nil map will panic
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 接受接口,返回结构体
|
||||
|
||||
函数应该接受接口参数并返回具体类型。
|
||||
|
||||
```go
|
||||
// Good: Accepts interface, returns concrete type
|
||||
func ProcessData(r io.Reader) (*Result, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Result{Data: data}, nil
|
||||
}
|
||||
|
||||
// Bad: Returns interface (hides implementation details unnecessarily)
|
||||
func ProcessData(r io.Reader) (io.Reader, error) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理模式
|
||||
|
||||
### 带上下文的错误包装
|
||||
|
||||
```go
|
||||
// Good: Wrap errors with context
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load config %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义错误类型
|
||||
|
||||
```go
|
||||
// Define domain-specific errors
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// Sentinel errors for common cases
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
```
|
||||
|
||||
### 使用 errors.Is 和 errors.As 检查错误
|
||||
|
||||
```go
|
||||
func HandleError(err error) {
|
||||
// Check for specific error
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Println("No records found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for error type
|
||||
var validationErr *ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
log.Printf("Validation error on field %s: %s",
|
||||
validationErr.Field, validationErr.Message)
|
||||
return
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
log.Printf("Unexpected error: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### 永不忽略错误
|
||||
|
||||
```go
|
||||
// Bad: Ignoring error with blank identifier
|
||||
result, _ := doSomething()
|
||||
|
||||
// Good: Handle or explicitly document why it's safe to ignore
|
||||
result, err := doSomething()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Acceptable: When error truly doesn't matter (rare)
|
||||
_ = writer.Close() // Best-effort cleanup, error logged elsewhere
|
||||
```
|
||||
|
||||
## 并发模式
|
||||
|
||||
### 工作池
|
||||
|
||||
```go
|
||||
func WorkerPool(jobs <-chan Job, results chan<- Result, numWorkers int) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for job := range jobs {
|
||||
results <- process(job)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}
|
||||
```
|
||||
|
||||
### 用于取消和超时的 Context
|
||||
|
||||
```go
|
||||
func FetchWithTimeout(ctx context.Context, url string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
```
|
||||
|
||||
### 优雅关闭
|
||||
|
||||
```go
|
||||
func GracefulShutdown(server *http.Server) {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
<-quit
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server exited")
|
||||
}
|
||||
```
|
||||
|
||||
### 用于协调 Goroutine 的 errgroup
|
||||
|
||||
```go
|
||||
import "golang.org/x/sync/errgroup"
|
||||
|
||||
func FetchAll(ctx context.Context, urls []string) ([][]byte, error) {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
results := make([][]byte, len(urls))
|
||||
|
||||
for i, url := range urls {
|
||||
i, url := i, url // Capture loop variables
|
||||
g.Go(func() error {
|
||||
data, err := FetchWithTimeout(ctx, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results[i] = data
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 避免 Goroutine 泄漏
|
||||
|
||||
```go
|
||||
// Bad: Goroutine leak if context is cancelled
|
||||
func leakyFetch(ctx context.Context, url string) <-chan []byte {
|
||||
ch := make(chan []byte)
|
||||
go func() {
|
||||
data, _ := fetch(url)
|
||||
ch <- data // Blocks forever if no receiver
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Good: Properly handles cancellation
|
||||
func safeFetch(ctx context.Context, url string) <-chan []byte {
|
||||
ch := make(chan []byte, 1) // Buffered channel
|
||||
go func() {
|
||||
data, err := fetch(url)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case ch <- data:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
```
|
||||
|
||||
## 接口设计
|
||||
|
||||
### 小而专注的接口
|
||||
|
||||
```go
|
||||
// Good: Single-method interfaces
|
||||
type Reader interface {
|
||||
Read(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
type Writer interface {
|
||||
Write(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
type Closer interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Compose interfaces as needed
|
||||
type ReadWriteCloser interface {
|
||||
Reader
|
||||
Writer
|
||||
Closer
|
||||
}
|
||||
```
|
||||
|
||||
### 在接口使用处定义接口
|
||||
|
||||
```go
|
||||
// In the consumer package, not the provider
|
||||
package service
|
||||
|
||||
// UserStore defines what this service needs
|
||||
type UserStore interface {
|
||||
GetUser(id string) (*User, error)
|
||||
SaveUser(user *User) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store UserStore
|
||||
}
|
||||
|
||||
// Concrete implementation can be in another package
|
||||
// It doesn't need to know about this interface
|
||||
```
|
||||
|
||||
### 使用类型断言实现可选行为
|
||||
|
||||
```go
|
||||
type Flusher interface {
|
||||
Flush() error
|
||||
}
|
||||
|
||||
func WriteAndFlush(w io.Writer, data []byte) error {
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Flush if supported
|
||||
if f, ok := w.(Flusher); ok {
|
||||
return f.Flush()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## 包组织
|
||||
|
||||
### 标准项目布局
|
||||
|
||||
```text
|
||||
myproject/
|
||||
├── cmd/
|
||||
│ └── myapp/
|
||||
│ └── main.go # Entry point
|
||||
├── internal/
|
||||
│ ├── handler/ # HTTP handlers
|
||||
│ ├── service/ # Business logic
|
||||
│ ├── repository/ # Data access
|
||||
│ └── config/ # Configuration
|
||||
├── pkg/
|
||||
│ └── client/ # Public API client
|
||||
├── api/
|
||||
│ └── v1/ # API definitions (proto, OpenAPI)
|
||||
├── testdata/ # Test fixtures
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
### 包命名
|
||||
|
||||
```go
|
||||
// Good: Short, lowercase, no underscores
|
||||
package http
|
||||
package json
|
||||
package user
|
||||
|
||||
// Bad: Verbose, mixed case, or redundant
|
||||
package httpHandler
|
||||
package json_parser
|
||||
package userService // Redundant 'Service' suffix
|
||||
```
|
||||
|
||||
### 避免包级状态
|
||||
|
||||
```go
|
||||
// Bad: Global mutable state
|
||||
var db *sql.DB
|
||||
|
||||
func init() {
|
||||
db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
|
||||
}
|
||||
|
||||
// Good: Dependency injection
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewServer(db *sql.DB) *Server {
|
||||
return &Server{db: db}
|
||||
}
|
||||
```
|
||||
|
||||
## 结构体设计
|
||||
|
||||
### 函数式选项模式
|
||||
|
||||
```go
|
||||
type Server struct {
|
||||
addr string
|
||||
timeout time.Duration
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
type Option func(*Server)
|
||||
|
||||
func WithTimeout(d time.Duration) Option {
|
||||
return func(s *Server) {
|
||||
s.timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogger(l *log.Logger) Option {
|
||||
return func(s *Server) {
|
||||
s.logger = l
|
||||
}
|
||||
}
|
||||
|
||||
func NewServer(addr string, opts ...Option) *Server {
|
||||
s := &Server{
|
||||
addr: addr,
|
||||
timeout: 30 * time.Second, // default
|
||||
logger: log.Default(), // default
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Usage
|
||||
server := NewServer(":8080",
|
||||
WithTimeout(60*time.Second),
|
||||
WithLogger(customLogger),
|
||||
)
|
||||
```
|
||||
|
||||
### 使用嵌入实现组合
|
||||
|
||||
```go
|
||||
type Logger struct {
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (l *Logger) Log(msg string) {
|
||||
fmt.Printf("[%s] %s\n", l.prefix, msg)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
*Logger // Embedding - Server gets Log method
|
||||
addr string
|
||||
}
|
||||
|
||||
func NewServer(addr string) *Server {
|
||||
return &Server{
|
||||
Logger: &Logger{prefix: "SERVER"},
|
||||
addr: addr,
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
s := NewServer(":8080")
|
||||
s.Log("Starting...") // Calls embedded Logger.Log
|
||||
```
|
||||
|
||||
## 内存与性能
|
||||
|
||||
### 当大小已知时预分配切片
|
||||
|
||||
```go
|
||||
// Bad: Grows slice multiple times
|
||||
func processItems(items []Item) []Result {
|
||||
var results []Result
|
||||
for _, item := range items {
|
||||
results = append(results, process(item))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Good: Single allocation
|
||||
func processItems(items []Item) []Result {
|
||||
results := make([]Result, 0, len(items))
|
||||
for _, item := range items {
|
||||
results = append(results, process(item))
|
||||
}
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
### 为频繁分配使用 sync.Pool
|
||||
|
||||
```go
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
func ProcessRequest(data []byte) []byte {
|
||||
buf := bufferPool.Get().(*bytes.Buffer)
|
||||
defer func() {
|
||||
buf.Reset()
|
||||
bufferPool.Put(buf)
|
||||
}()
|
||||
|
||||
buf.Write(data)
|
||||
// Process...
|
||||
return buf.Bytes()
|
||||
}
|
||||
```
|
||||
|
||||
### 避免在循环中进行字符串拼接
|
||||
|
||||
```go
|
||||
// Bad: Creates many string allocations
|
||||
func join(parts []string) string {
|
||||
var result string
|
||||
for _, p := range parts {
|
||||
result += p + ","
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Good: Single allocation with strings.Builder
|
||||
func join(parts []string) string {
|
||||
var sb strings.Builder
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString(p)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Best: Use standard library
|
||||
func join(parts []string) string {
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
```
|
||||
|
||||
## Go 工具集成
|
||||
|
||||
### 基本命令
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
go build ./...
|
||||
go run ./cmd/myapp
|
||||
|
||||
# Testing
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go test -cover ./...
|
||||
|
||||
# Static analysis
|
||||
go vet ./...
|
||||
staticcheck ./...
|
||||
golangci-lint run
|
||||
|
||||
# Module management
|
||||
go mod tidy
|
||||
go mod verify
|
||||
|
||||
# Formatting
|
||||
gofmt -w .
|
||||
goimports -w .
|
||||
```
|
||||
|
||||
### 推荐的 Linter 配置 (.golangci.yml)
|
||||
|
||||
```yaml
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- gofmt
|
||||
- goimports
|
||||
- misspell
|
||||
- unconvert
|
||||
- unparam
|
||||
|
||||
linters-settings:
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
govet:
|
||||
check-shadowing: true
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
```
|
||||
|
||||
## 快速参考:Go 惯用法
|
||||
|
||||
| 惯用法 | 描述 |
|
||||
|-------|-------------|
|
||||
| 接受接口,返回结构体 | 函数接受接口参数,返回具体类型 |
|
||||
| 错误即值 | 将错误视为一等值,而非异常 |
|
||||
| 不要通过共享内存来通信 | 使用通道在 goroutine 之间进行协调 |
|
||||
| 让零值变得有用 | 类型应无需显式初始化即可工作 |
|
||||
| 少量复制优于少量依赖 | 避免不必要的外部依赖 |
|
||||
| 清晰优于精巧 | 优先考虑可读性而非精巧性 |
|
||||
| gofmt 虽非最爱,但却是每个人的朋友 | 始终使用 gofmt/goimports 格式化代码 |
|
||||
| 提前返回 | 先处理错误,保持主逻辑路径无缩进 |
|
||||
|
||||
## 应避免的反模式
|
||||
|
||||
```go
|
||||
// Bad: Naked returns in long functions
|
||||
func process() (result int, err error) {
|
||||
// ... 50 lines ...
|
||||
return // What is being returned?
|
||||
}
|
||||
|
||||
// Bad: Using panic for control flow
|
||||
func GetUser(id string) *User {
|
||||
user, err := db.Find(id)
|
||||
if err != nil {
|
||||
panic(err) // Don't do this
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// Bad: Passing context in struct
|
||||
type Request struct {
|
||||
ctx context.Context // Context should be first param
|
||||
ID string
|
||||
}
|
||||
|
||||
// Good: Context as first parameter
|
||||
func ProcessRequest(ctx context.Context, id string) error {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Bad: Mixing value and pointer receivers
|
||||
type Counter struct{ n int }
|
||||
func (c Counter) Value() int { return c.n } // Value receiver
|
||||
func (c *Counter) Increment() { c.n++ } // Pointer receiver
|
||||
// Pick one style and be consistent
|
||||
```
|
||||
|
||||
**记住**:Go 代码应该以最好的方式显得“乏味”——可预测、一致且易于理解。如有疑问,保持简单。
|
||||
@@ -1,720 +0,0 @@
|
||||
---
|
||||
name: golang-testing
|
||||
description: Go testing patterns including table-driven tests, subtests, benchmarks, fuzzing, and test coverage. Follows TDD methodology with idiomatic Go practices.
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Go Testing Patterns
|
||||
|
||||
Comprehensive Go testing patterns for writing reliable, maintainable tests following TDD methodology.
|
||||
|
||||
## When to Activate
|
||||
|
||||
- Writing new Go functions or methods
|
||||
- Adding test coverage to existing code
|
||||
- Creating benchmarks for performance-critical code
|
||||
- Implementing fuzz tests for input validation
|
||||
- Following TDD workflow in Go projects
|
||||
|
||||
## TDD Workflow for Go
|
||||
|
||||
### The RED-GREEN-REFACTOR Cycle
|
||||
|
||||
```
|
||||
RED → Write a failing test first
|
||||
GREEN → Write minimal code to pass the test
|
||||
REFACTOR → Improve code while keeping tests green
|
||||
REPEAT → Continue with next requirement
|
||||
```
|
||||
|
||||
### Step-by-Step TDD in Go
|
||||
|
||||
```go
|
||||
// Step 1: Define the interface/signature
|
||||
// calculator.go
|
||||
package calculator
|
||||
|
||||
func Add(a, b int) int {
|
||||
panic("not implemented") // Placeholder
|
||||
}
|
||||
|
||||
// Step 2: Write failing test (RED)
|
||||
// calculator_test.go
|
||||
package calculator
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAdd(t *testing.T) {
|
||||
got := Add(2, 3)
|
||||
want := 5
|
||||
if got != want {
|
||||
t.Errorf("Add(2, 3) = %d; want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Run test - verify FAIL
|
||||
// $ go test
|
||||
// --- FAIL: TestAdd (0.00s)
|
||||
// panic: not implemented
|
||||
|
||||
// Step 4: Implement minimal code (GREEN)
|
||||
func Add(a, b int) int {
|
||||
return a + b
|
||||
}
|
||||
|
||||
// Step 5: Run test - verify PASS
|
||||
// $ go test
|
||||
// PASS
|
||||
|
||||
// Step 6: Refactor if needed, verify tests still pass
|
||||
```
|
||||
|
||||
## Table-Driven Tests
|
||||
|
||||
The standard pattern for Go tests. Enables comprehensive coverage with minimal code.
|
||||
|
||||
```go
|
||||
func TestAdd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b int
|
||||
expected int
|
||||
}{
|
||||
{"positive numbers", 2, 3, 5},
|
||||
{"negative numbers", -1, -2, -3},
|
||||
{"zero values", 0, 0, 0},
|
||||
{"mixed signs", -1, 1, 0},
|
||||
{"large numbers", 1000000, 2000000, 3000000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Add(tt.a, tt.b)
|
||||
if got != tt.expected {
|
||||
t.Errorf("Add(%d, %d) = %d; want %d",
|
||||
tt.a, tt.b, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Table-Driven Tests with Error Cases
|
||||
|
||||
```go
|
||||
func TestParseConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
input: `{"host": "localhost", "port": 8080}`,
|
||||
want: &Config{Host: "localhost", Port: 8080},
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
input: `{invalid}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "minimal config",
|
||||
input: `{}`,
|
||||
want: &Config{}, // Zero value config
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseConfig(tt.input)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("got %+v; want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Subtests and Sub-benchmarks
|
||||
|
||||
### Organizing Related Tests
|
||||
|
||||
```go
|
||||
func TestUser(t *testing.T) {
|
||||
// Setup shared by all subtests
|
||||
db := setupTestDB(t)
|
||||
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
user := &User{Name: "Alice"}
|
||||
err := db.CreateUser(user)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
if user.ID == "" {
|
||||
t.Error("expected user ID to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get", func(t *testing.T) {
|
||||
user, err := db.GetUser("alice-id")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser failed: %v", err)
|
||||
}
|
||||
if user.Name != "Alice" {
|
||||
t.Errorf("got name %q; want %q", user.Name, "Alice")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
// ...
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
// ...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Parallel Subtests
|
||||
|
||||
```go
|
||||
func TestParallel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"case1", "input1"},
|
||||
{"case2", "input2"},
|
||||
{"case3", "input3"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt // Capture range variable
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel() // Run subtests in parallel
|
||||
result := Process(tt.input)
|
||||
// assertions...
|
||||
_ = result
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Helpers
|
||||
|
||||
### Helper Functions
|
||||
|
||||
```go
|
||||
func setupTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper() // Marks this as a helper function
|
||||
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open database: %v", err)
|
||||
}
|
||||
|
||||
// Cleanup when test finishes
|
||||
t.Cleanup(func() {
|
||||
db.Close()
|
||||
})
|
||||
|
||||
// Run migrations
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
t.Fatalf("failed to create schema: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func assertNoError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual[T comparable](t *testing.T, got, want T) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
t.Errorf("got %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Temporary Files and Directories
|
||||
|
||||
```go
|
||||
func TestFileProcessing(t *testing.T) {
|
||||
// Create temp directory - automatically cleaned up
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create test file
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
err := os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Run test
|
||||
result, err := ProcessFile(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessFile failed: %v", err)
|
||||
}
|
||||
|
||||
// Assert...
|
||||
_ = result
|
||||
}
|
||||
```
|
||||
|
||||
## Golden Files
|
||||
|
||||
Testing against expected output files stored in `testdata/`.
|
||||
|
||||
```go
|
||||
var update = flag.Bool("update", false, "update golden files")
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input Template
|
||||
}{
|
||||
{"simple", Template{Name: "test"}},
|
||||
{"complex", Template{Name: "test", Items: []string{"a", "b"}}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Render(tt.input)
|
||||
|
||||
golden := filepath.Join("testdata", tt.name+".golden")
|
||||
|
||||
if *update {
|
||||
// Update golden file: go test -update
|
||||
err := os.WriteFile(golden, got, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update golden file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
want, err := os.ReadFile(golden)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read golden file: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("output mismatch:\ngot:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mocking with Interfaces
|
||||
|
||||
### Interface-Based Mocking
|
||||
|
||||
```go
|
||||
// Define interface for dependencies
|
||||
type UserRepository interface {
|
||||
GetUser(id string) (*User, error)
|
||||
SaveUser(user *User) error
|
||||
}
|
||||
|
||||
// Production implementation
|
||||
type PostgresUserRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (r *PostgresUserRepository) GetUser(id string) (*User, error) {
|
||||
// Real database query
|
||||
}
|
||||
|
||||
// Mock implementation for tests
|
||||
type MockUserRepository struct {
|
||||
GetUserFunc func(id string) (*User, error)
|
||||
SaveUserFunc func(user *User) error
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) GetUser(id string) (*User, error) {
|
||||
return m.GetUserFunc(id)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) SaveUser(user *User) error {
|
||||
return m.SaveUserFunc(user)
|
||||
}
|
||||
|
||||
// Test using mock
|
||||
func TestUserService(t *testing.T) {
|
||||
mock := &MockUserRepository{
|
||||
GetUserFunc: func(id string) (*User, error) {
|
||||
if id == "123" {
|
||||
return &User{ID: "123", Name: "Alice"}, nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
},
|
||||
}
|
||||
|
||||
service := NewUserService(mock)
|
||||
|
||||
user, err := service.GetUserProfile("123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if user.Name != "Alice" {
|
||||
t.Errorf("got name %q; want %q", user.Name, "Alice")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Benchmarks
|
||||
|
||||
### Basic Benchmarks
|
||||
|
||||
```go
|
||||
func BenchmarkProcess(b *testing.B) {
|
||||
data := generateTestData(1000)
|
||||
b.ResetTimer() // Don't count setup time
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
Process(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Run: go test -bench=BenchmarkProcess -benchmem
|
||||
// Output: BenchmarkProcess-8 10000 105234 ns/op 4096 B/op 10 allocs/op
|
||||
```
|
||||
|
||||
### Benchmark with Different Sizes
|
||||
|
||||
```go
|
||||
func BenchmarkSort(b *testing.B) {
|
||||
sizes := []int{100, 1000, 10000, 100000}
|
||||
|
||||
for _, size := range sizes {
|
||||
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
|
||||
data := generateRandomSlice(size)
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Make a copy to avoid sorting already sorted data
|
||||
tmp := make([]int, len(data))
|
||||
copy(tmp, data)
|
||||
sort.Ints(tmp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Memory Allocation Benchmarks
|
||||
|
||||
```go
|
||||
func BenchmarkStringConcat(b *testing.B) {
|
||||
parts := []string{"hello", "world", "foo", "bar", "baz"}
|
||||
|
||||
b.Run("plus", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
var s string
|
||||
for _, p := range parts {
|
||||
s += p
|
||||
}
|
||||
_ = s
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("builder", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
var sb strings.Builder
|
||||
for _, p := range parts {
|
||||
sb.WriteString(p)
|
||||
}
|
||||
_ = sb.String()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("join", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = strings.Join(parts, "")
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Fuzzing (Go 1.18+)
|
||||
|
||||
### Basic Fuzz Test
|
||||
|
||||
```go
|
||||
func FuzzParseJSON(f *testing.F) {
|
||||
// Add seed corpus
|
||||
f.Add(`{"name": "test"}`)
|
||||
f.Add(`{"count": 123}`)
|
||||
f.Add(`[]`)
|
||||
f.Add(`""`)
|
||||
|
||||
f.Fuzz(func(t *testing.T, input string) {
|
||||
var result map[string]interface{}
|
||||
err := json.Unmarshal([]byte(input), &result)
|
||||
|
||||
if err != nil {
|
||||
// Invalid JSON is expected for random input
|
||||
return
|
||||
}
|
||||
|
||||
// If parsing succeeded, re-encoding should work
|
||||
_, err = json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Errorf("Marshal failed after successful Unmarshal: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Run: go test -fuzz=FuzzParseJSON -fuzztime=30s
|
||||
```
|
||||
|
||||
### Fuzz Test with Multiple Inputs
|
||||
|
||||
```go
|
||||
func FuzzCompare(f *testing.F) {
|
||||
f.Add("hello", "world")
|
||||
f.Add("", "")
|
||||
f.Add("abc", "abc")
|
||||
|
||||
f.Fuzz(func(t *testing.T, a, b string) {
|
||||
result := Compare(a, b)
|
||||
|
||||
// Property: Compare(a, a) should always equal 0
|
||||
if a == b && result != 0 {
|
||||
t.Errorf("Compare(%q, %q) = %d; want 0", a, b, result)
|
||||
}
|
||||
|
||||
// Property: Compare(a, b) and Compare(b, a) should have opposite signs
|
||||
reverse := Compare(b, a)
|
||||
if (result > 0 && reverse >= 0) || (result < 0 && reverse <= 0) {
|
||||
if result != 0 || reverse != 0 {
|
||||
t.Errorf("Compare(%q, %q) = %d, Compare(%q, %q) = %d; inconsistent",
|
||||
a, b, result, b, a, reverse)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Running Coverage
|
||||
|
||||
```bash
|
||||
# Basic coverage
|
||||
go test -cover ./...
|
||||
|
||||
# Generate coverage profile
|
||||
go test -coverprofile=coverage.out ./...
|
||||
|
||||
# View coverage in browser
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
# View coverage by function
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
# Coverage with race detection
|
||||
go test -race -coverprofile=coverage.out ./...
|
||||
```
|
||||
|
||||
### Coverage Targets
|
||||
|
||||
| Code Type | Target |
|
||||
|-----------|--------|
|
||||
| Critical business logic | 100% |
|
||||
| Public APIs | 90%+ |
|
||||
| General code | 80%+ |
|
||||
| Generated code | Exclude |
|
||||
|
||||
### Excluding Generated Code from Coverage
|
||||
|
||||
```go
|
||||
//go:generate mockgen -source=interface.go -destination=mock_interface.go
|
||||
|
||||
// In coverage profile, exclude with build tags:
|
||||
// go test -cover -tags=!generate ./...
|
||||
```
|
||||
|
||||
## HTTP Handler Testing
|
||||
|
||||
```go
|
||||
func TestHealthHandler(t *testing.T) {
|
||||
// Create request
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Call handler
|
||||
HealthHandler(w, req)
|
||||
|
||||
// Check response
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("got status %d; want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if string(body) != "OK" {
|
||||
t.Errorf("got body %q; want %q", body, "OK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "get user",
|
||||
method: http.MethodGet,
|
||||
path: "/users/123",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `{"id":"123","name":"Alice"}`,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
method: http.MethodGet,
|
||||
path: "/users/999",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "create user",
|
||||
method: http.MethodPost,
|
||||
path: "/users",
|
||||
body: `{"name":"Bob"}`,
|
||||
wantStatus: http.StatusCreated,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewAPIHandler()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var body io.Reader
|
||||
if tt.body != "" {
|
||||
body = strings.NewReader(tt.body)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(tt.method, tt.path, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("got status %d; want %d", w.Code, tt.wantStatus)
|
||||
}
|
||||
|
||||
if tt.wantBody != "" && w.Body.String() != tt.wantBody {
|
||||
t.Errorf("got body %q; want %q", w.Body.String(), tt.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run tests with verbose output
|
||||
go test -v ./...
|
||||
|
||||
# Run specific test
|
||||
go test -run TestAdd ./...
|
||||
|
||||
# Run tests matching pattern
|
||||
go test -run "TestUser/Create" ./...
|
||||
|
||||
# Run tests with race detector
|
||||
go test -race ./...
|
||||
|
||||
# Run tests with coverage
|
||||
go test -cover -coverprofile=coverage.out ./...
|
||||
|
||||
# Run short tests only
|
||||
go test -short ./...
|
||||
|
||||
# Run tests with timeout
|
||||
go test -timeout 30s ./...
|
||||
|
||||
# Run benchmarks
|
||||
go test -bench=. -benchmem ./...
|
||||
|
||||
# Run fuzzing
|
||||
go test -fuzz=FuzzParse -fuzztime=30s ./...
|
||||
|
||||
# Count test runs (for flaky test detection)
|
||||
go test -count=10 ./...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**DO:**
|
||||
- Write tests FIRST (TDD)
|
||||
- Use table-driven tests for comprehensive coverage
|
||||
- Test behavior, not implementation
|
||||
- Use `t.Helper()` in helper functions
|
||||
- Use `t.Parallel()` for independent tests
|
||||
- Clean up resources with `t.Cleanup()`
|
||||
- Use meaningful test names that describe the scenario
|
||||
|
||||
**DON'T:**
|
||||
- Test private functions directly (test through public API)
|
||||
- Use `time.Sleep()` in tests (use channels or conditions)
|
||||
- Ignore flaky tests (fix or remove them)
|
||||
- Mock everything (prefer integration tests when possible)
|
||||
- Skip error path testing
|
||||
|
||||
## Integration with CI/CD
|
||||
|
||||
```yaml
|
||||
# GitHub Actions example
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- name: Run tests
|
||||
run: go test -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Check coverage
|
||||
run: |
|
||||
go tool cover -func=coverage.out | grep total | awk '{print $3}' | \
|
||||
awk -F'%' '{if ($1 < 80) exit 1}'
|
||||
```
|
||||
|
||||
**Remember**: Tests are documentation. They show how your code is meant to be used. Write them clearly and keep them up to date.
|
||||
@@ -1,291 +0,0 @@
|
||||
---
|
||||
name: use-modern-go
|
||||
description: Apply modern Go syntax guidelines based on project's Go version. Use when user ask for modern Go code guidelines.
|
||||
---
|
||||
|
||||
# Modern Go Guidelines
|
||||
|
||||
## Detected Go Version
|
||||
|
||||
!`grep -rh "^go " --include="go.mod" . 2>/dev/null | cut -d' ' -f2 | sort | uniq -c | sort -nr | head -1 | xargs | cut -d' ' -f2 | grep . || echo unknown`
|
||||
|
||||
## How to Use This Skill
|
||||
|
||||
DO NOT search for go.mod files or try to detect the version yourself. Use ONLY the version shown above.
|
||||
|
||||
**If version detected (not "unknown"):**
|
||||
- Say: "This project is using Go X.XX, so I’ll stick to modern Go best practices and freely use language features up to and including this version. If you’d prefer a different target version, just let me know."
|
||||
- Do NOT list features, do NOT ask for confirmation
|
||||
|
||||
**If version is "unknown":**
|
||||
- Say: "Could not detect Go version in this repository"
|
||||
- Use AskUserQuestion: "Which Go version should I target?" → [1.23] / [1.24] / [1.25] / [1.26]
|
||||
|
||||
**When writing Go code**, use ALL features from this document up to the target version:
|
||||
- Prefer modern built-ins and packages (`slices`, `maps`, `cmp`) over legacy patterns
|
||||
- Never use features from newer Go versions than the target
|
||||
- Never use outdated patterns when a modern alternative is available
|
||||
|
||||
---
|
||||
|
||||
## Features by Go Version
|
||||
|
||||
### Go 1.0+
|
||||
|
||||
- `time.Since`: `time.Since(start)` instead of `time.Now().Sub(start)`
|
||||
|
||||
### Go 1.8+
|
||||
|
||||
- `time.Until`: `time.Until(deadline)` instead of `deadline.Sub(time.Now())`
|
||||
|
||||
### Go 1.13+
|
||||
|
||||
- `errors.Is`: `errors.Is(err, target)` instead of `err == target` (works with wrapped errors)
|
||||
|
||||
### Go 1.18+
|
||||
|
||||
- `any`: Use `any` instead of `interface{}`
|
||||
- `bytes.Cut`: `before, after, found := bytes.Cut(b, sep)` instead of Index+slice
|
||||
- `strings.Cut`: `before, after, found := strings.Cut(s, sep)`
|
||||
|
||||
### Go 1.19+
|
||||
|
||||
- `fmt.Appendf`: `buf = fmt.Appendf(buf, "x=%d", x)` instead of `[]byte(fmt.Sprintf(...))`
|
||||
- `atomic.Bool`/`atomic.Int64`/`atomic.Pointer[T]`: Type-safe atomics instead of `atomic.StoreInt32`
|
||||
|
||||
```go
|
||||
var flag atomic.Bool
|
||||
flag.Store(true)
|
||||
if flag.Load() { ... }
|
||||
|
||||
var ptr atomic.Pointer[Config]
|
||||
ptr.Store(cfg)
|
||||
```
|
||||
|
||||
### Go 1.20+
|
||||
|
||||
- `strings.Clone`: `strings.Clone(s)` to copy string without sharing memory
|
||||
- `bytes.Clone`: `bytes.Clone(b)` to copy byte slice
|
||||
- `strings.CutPrefix/CutSuffix`: `if rest, ok := strings.CutPrefix(s, "pre:"); ok { ... }`
|
||||
- `errors.Join`: `errors.Join(err1, err2)` to combine multiple errors
|
||||
- `context.WithCancelCause`: `ctx, cancel := context.WithCancelCause(parent)` then `cancel(err)`
|
||||
- `context.Cause`: `context.Cause(ctx)` to get the error that caused cancellation
|
||||
|
||||
### Go 1.21+
|
||||
|
||||
**Built-ins:**
|
||||
- `min`/`max`: `max(a, b)` instead of if/else comparisons
|
||||
- `clear`: `clear(m)` to delete all map entries, `clear(s)` to zero slice elements
|
||||
|
||||
**slices package:**
|
||||
- `slices.Contains`: `slices.Contains(items, x)` instead of manual loops
|
||||
- `slices.Index`: `slices.Index(items, x)` returns index (-1 if not found)
|
||||
- `slices.IndexFunc`: `slices.IndexFunc(items, func(item T) bool { return item.ID == id })`
|
||||
- `slices.SortFunc`: `slices.SortFunc(items, func(a, b T) int { return cmp.Compare(a.X, b.X) })`
|
||||
- `slices.Sort`: `slices.Sort(items)` for ordered types
|
||||
- `slices.Max`/`slices.Min`: `slices.Max(items)` instead of manual loop
|
||||
- `slices.Reverse`: `slices.Reverse(items)` instead of manual swap loop
|
||||
- `slices.Compact`: `slices.Compact(items)` removes consecutive duplicates in-place
|
||||
- `slices.Clip`: `slices.Clip(s)` removes unused capacity
|
||||
- `slices.Clone`: `slices.Clone(s)` creates a copy
|
||||
|
||||
**maps package:**
|
||||
- `maps.Clone`: `maps.Clone(m)` instead of manual map iteration
|
||||
- `maps.Copy`: `maps.Copy(dst, src)` copies entries from src to dst
|
||||
- `maps.DeleteFunc`: `maps.DeleteFunc(m, func(k K, v V) bool { return condition })`
|
||||
|
||||
**sync package:**
|
||||
- `sync.OnceFunc`: `f := sync.OnceFunc(func() { ... })` instead of `sync.Once` + wrapper
|
||||
- `sync.OnceValue`: `getter := sync.OnceValue(func() T { return computeValue() })`
|
||||
|
||||
**context package:**
|
||||
- `context.AfterFunc`: `stop := context.AfterFunc(ctx, cleanup)` runs cleanup on cancellation
|
||||
- `context.WithTimeoutCause`: `ctx, cancel := context.WithTimeoutCause(parent, d, err)`
|
||||
- `context.WithDeadlineCause`: Similar with deadline instead of duration
|
||||
|
||||
### Go 1.22+
|
||||
|
||||
**Loops:**
|
||||
- `for i := range n`: `for i := range len(items)` instead of `for i := 0; i < len(items); i++`
|
||||
- Loop variables are now safe to capture in goroutines (each iteration has its own copy)
|
||||
|
||||
**cmp package:**
|
||||
- `cmp.Or`: `cmp.Or(flag, env, config, "default")` returns first non-zero value
|
||||
|
||||
```go
|
||||
// Instead of:
|
||||
name := os.Getenv("NAME")
|
||||
if name == "" {
|
||||
name = "default"
|
||||
}
|
||||
// Use:
|
||||
name := cmp.Or(os.Getenv("NAME"), "default")
|
||||
```
|
||||
|
||||
**reflect package:**
|
||||
- `reflect.TypeFor`: `reflect.TypeFor[T]()` instead of `reflect.TypeOf((*T)(nil)).Elem()`
|
||||
|
||||
**net/http:**
|
||||
- Enhanced `http.ServeMux` patterns: `mux.HandleFunc("GET /api/{id}", handler)` with method and path params
|
||||
- `r.PathValue("id")` to get path parameters
|
||||
|
||||
### Go 1.23+
|
||||
|
||||
- `maps.Keys(m)` / `maps.Values(m)` return iterators
|
||||
- `slices.Collect(iter)` not manual loop to build slice from iterator
|
||||
- `slices.Sorted(iter)` to collect and sort in one step
|
||||
|
||||
```go
|
||||
keys := slices.Collect(maps.Keys(m)) // not: for k := range m { keys = append(keys, k) }
|
||||
sortedKeys := slices.Sorted(maps.Keys(m)) // collect + sort
|
||||
for k := range maps.Keys(m) { process(k) } // iterate directly
|
||||
```
|
||||
|
||||
**time package**
|
||||
|
||||
- `time.Tick`: Use `time.Tick` freely — as of Go 1.23, the garbage collector can recover unreferenced tickers, even if they haven't been stopped. The Stop method is no longer necessary to help the garbage collector. There is no longer any reason to prefer NewTicker when Tick will do.
|
||||
|
||||
### Go 1.24+
|
||||
|
||||
- `t.Context()` not `context.WithCancel(context.Background())` in tests.
|
||||
ALWAYS use t.Context() when a test function needs a context.
|
||||
|
||||
Before:
|
||||
```go
|
||||
func TestFoo(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
result := doSomething(ctx)
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
func TestFoo(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
result := doSomething(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
- `omitzero` not `omitempty` in JSON struct tags.
|
||||
ALWAYS use omitzero for time.Duration, time.Time, structs, slices, maps.
|
||||
|
||||
Before:
|
||||
```go
|
||||
type Config struct {
|
||||
Timeout time.Duration `json:"timeout,omitempty"` // doesn't work for Duration!
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
type Config struct {
|
||||
Timeout time.Duration `json:"timeout,omitzero"`
|
||||
}
|
||||
```
|
||||
|
||||
- `b.Loop()` not `for i := 0; i < b.N; i++` in benchmarks.
|
||||
ALWAYS use b.Loop() for the main loop in benchmark functions.
|
||||
|
||||
Before:
|
||||
```go
|
||||
func BenchmarkFoo(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
doWork()
|
||||
}
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
func BenchmarkFoo(b *testing.B) {
|
||||
for b.Loop() {
|
||||
doWork()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `strings.SplitSeq` not `strings.Split` when iterating.
|
||||
ALWAYS use SplitSeq/FieldsSeq when iterating over split results in a for-range loop.
|
||||
|
||||
Before:
|
||||
```go
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
process(part)
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
for part := range strings.SplitSeq(s, ",") {
|
||||
process(part)
|
||||
}
|
||||
```
|
||||
Also: `strings.FieldsSeq`, `bytes.SplitSeq`, `bytes.FieldsSeq`.
|
||||
|
||||
### Go 1.25+
|
||||
|
||||
- `wg.Go(fn)` not `wg.Add(1)` + `go func() { defer wg.Done(); ... }()`.
|
||||
ALWAYS use wg.Go() when spawning goroutines with sync.WaitGroup.
|
||||
|
||||
Before:
|
||||
```go
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range items {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
process(item)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
After:
|
||||
```go
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range items {
|
||||
wg.Go(func() {
|
||||
process(item)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
### Go 1.26+
|
||||
|
||||
- `new(val)` not `x := val; &x` — returns pointer to any value.
|
||||
Go 1.26 extends new() to accept expressions, not just types.
|
||||
Type is inferred: new(0) → *int, new("s") → *string, new(T{}) → *T.
|
||||
DO NOT use `x := val; &x` pattern — always use new(val) directly.
|
||||
DO NOT use redundant casts like new(int(0)) — just write new(0).
|
||||
Common use case: struct fields with pointer types.
|
||||
|
||||
Before:
|
||||
```go
|
||||
timeout := 30
|
||||
debug := true
|
||||
cfg := Config{
|
||||
Timeout: &timeout,
|
||||
Debug: &debug,
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
cfg := Config{
|
||||
Timeout: new(30), // *int
|
||||
Debug: new(true), // *bool
|
||||
}
|
||||
```
|
||||
|
||||
- `errors.AsType[T](err)` not `errors.As(err, &target)`.
|
||||
ALWAYS use errors.AsType when checking if error matches a specific type.
|
||||
|
||||
Before:
|
||||
```go
|
||||
var pathErr *os.PathError
|
||||
if errors.As(err, &pathErr) {
|
||||
handle(pathErr)
|
||||
}
|
||||
```
|
||||
After:
|
||||
```go
|
||||
if pathErr, ok := errors.AsType[*os.PathError](err); ok {
|
||||
handle(pathErr)
|
||||
}
|
||||
```
|
||||
@@ -18,8 +18,9 @@ COPY server ./carrot_bbs
|
||||
# 复制配置文件
|
||||
COPY configs ./configs
|
||||
|
||||
# 给可执行文件添加执行权限
|
||||
RUN chmod +x ./carrot_bbs
|
||||
# 给可执行文件添加执行权限,并创建运行时所需目录
|
||||
RUN chmod +x ./carrot_bbs \
|
||||
&& mkdir -p logs data
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package main
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/config"
|
||||
"carrot_bbs/internal/grpc/runner"
|
||||
"carrot_bbs/internal/router"
|
||||
"carrot_bbs/internal/service"
|
||||
"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"
|
||||
@@ -16,14 +18,19 @@ import (
|
||||
|
||||
// App 应用程序结构体
|
||||
type App struct {
|
||||
Config *config.Config
|
||||
DB *gorm.DB
|
||||
Router *router.Router
|
||||
PushService service.PushService
|
||||
HotRankWorker *service.HotRankWorker
|
||||
GRPCServer *runner.Server
|
||||
Server *http.Server
|
||||
Logger *zap.Logger
|
||||
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 创建应用程序
|
||||
@@ -33,17 +40,27 @@ func NewApp(
|
||||
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,
|
||||
GRPCServer: grpcServer,
|
||||
Logger: logger,
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
Router: r,
|
||||
PushService: pushService,
|
||||
HotRankWorker: hotRankWorker,
|
||||
FileCleanupWorker: fileCleanupWorker,
|
||||
DeviceCleanupWorker: deviceCleanupWorker,
|
||||
GRPCServer: grpcServer,
|
||||
Publisher: publisher,
|
||||
TaskDispatcher: taskDispatcher,
|
||||
Logger: logger,
|
||||
PushWorker: pushWorker,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +82,21 @@ func (a *App) Start(ctx context.Context) error {
|
||||
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",
|
||||
@@ -72,8 +104,12 @@ func (a *App) Start(ctx context.Context) error {
|
||||
)
|
||||
|
||||
a.Server = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: a.Router.Engine(),
|
||||
Addr: addr,
|
||||
Handler: a.Router.Engine(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 4. 启动 HTTP 服务器
|
||||
@@ -92,7 +128,18 @@ func (a *App) Start(ctx context.Context) error {
|
||||
func (a *App) Stop(ctx context.Context) error {
|
||||
a.Logger.Info("Shutting down server")
|
||||
|
||||
// 1. 停止 HTTP 服务器
|
||||
// 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 {
|
||||
@@ -102,13 +149,21 @@ func (a *App) Stop(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 停止 gRPC 服务器
|
||||
// 4. 停止 gRPC 服务器
|
||||
if a.GRPCServer != nil {
|
||||
a.Logger.Info("Stopping gRPC server")
|
||||
a.GRPCServer.Stop()
|
||||
}
|
||||
|
||||
// 3. 停止推送 Worker
|
||||
// 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()
|
||||
|
||||
@@ -117,7 +172,23 @@ func (a *App) Stop(ctx context.Context) error {
|
||||
a.HotRankWorker.Stop()
|
||||
}
|
||||
|
||||
// 4. 关闭数据库连接
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -14,9 +15,8 @@ func main() {
|
||||
// 初始化应用程序(Wire 自动生成)
|
||||
app, err := InitializeApp()
|
||||
if err != nil {
|
||||
zap.L().Fatal("failed to initialize app",
|
||||
zap.Error(err),
|
||||
)
|
||||
fmt.Fprintf(os.Stderr, "failed to initialize app: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 启动应用程序
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/handler"
|
||||
"carrot_bbs/internal/router"
|
||||
"carrot_bbs/internal/service"
|
||||
appwire "carrot_bbs/internal/wire"
|
||||
|
||||
"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 初始化应用程序
|
||||
@@ -24,13 +27,17 @@ func InitializeApp() (*App, error) {
|
||||
|
||||
// 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,
|
||||
jwtService service.JWTService,
|
||||
sessionService service.SessionService,
|
||||
identityProvider middleware.IdentityProvider,
|
||||
cache cache.Cache,
|
||||
pushHandler *handler.PushHandler,
|
||||
systemMessageHandler *handler.SystemMessageHandler,
|
||||
groupHandler *handler.GroupHandler,
|
||||
@@ -38,6 +45,9 @@ func ProvideRouter(
|
||||
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,
|
||||
@@ -46,35 +56,66 @@ func ProvideRouter(
|
||||
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(
|
||||
userHandler,
|
||||
postHandler,
|
||||
commentHandler,
|
||||
messageHandler,
|
||||
notificationHandler,
|
||||
uploadHandler,
|
||||
jwtService,
|
||||
pushHandler,
|
||||
systemMessageHandler,
|
||||
groupHandler,
|
||||
stickerHandler,
|
||||
voteHandler,
|
||||
channelHandler,
|
||||
scheduleHandler,
|
||||
roleHandler,
|
||||
adminUserHandler,
|
||||
adminPostHandler,
|
||||
adminCommentHandler,
|
||||
adminGroupHandler,
|
||||
adminDashboardHandler,
|
||||
adminLogHandler,
|
||||
qrcodeHandler,
|
||||
logService,
|
||||
activityService,
|
||||
casbinService,
|
||||
)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/handler"
|
||||
"carrot_bbs/internal/repository"
|
||||
"carrot_bbs/internal/router"
|
||||
"carrot_bbs/internal/service"
|
||||
"carrot_bbs/internal/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"
|
||||
"with_you/internal/wire"
|
||||
)
|
||||
|
||||
// Injectors from wire.go:
|
||||
@@ -31,92 +33,148 @@ func InitializeApp() (*App, error) {
|
||||
pushRecordRepository := repository.NewPushRecordRepository(db)
|
||||
deviceTokenRepository := repository.NewDeviceTokenRepository(db)
|
||||
messageRepository := repository.NewMessageRepository(db)
|
||||
hub := wire.ProvideSSEHub()
|
||||
pushService := wire.ProvidePushService(pushRecordRepository, deviceTokenRepository, messageRepository, hub)
|
||||
postRepository := repository.NewPostRepository(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)
|
||||
userService := wire.ProvideUserService(userRepository, systemMessageService, emailService, cache)
|
||||
userActivityRepository := wire.ProvideUserActivityRepository(db, cache)
|
||||
userActivityService := wire.ProvideUserActivityService(userActivityRepository)
|
||||
openaiClient := wire.ProvideOpenAIClient(config)
|
||||
postAIService := wire.ProvidePostAIService(openaiClient)
|
||||
transactionManager := wire.ProvideTransactionManager(db)
|
||||
manager := wire.ProvideHookManager()
|
||||
commentRepository := repository.NewCommentRepository(db)
|
||||
moderationHooks := wire.ProvideModerationHooks(manager, postAIService, postRepository, commentRepository, config)
|
||||
builtinHooks := wire.ProvideBuiltinHooks(manager)
|
||||
postService := wire.ProvidePostService(postRepository, systemMessageService, postAIService, cache, transactionManager, manager, moderationHooks, builtinHooks)
|
||||
commentService := wire.ProvideCommentService(commentRepository, postRepository, systemMessageService, postAIService, cache, manager)
|
||||
operationLogRepository := repository.NewOperationLogRepository(db)
|
||||
loginLogRepository := repository.NewLoginLogRepository(db)
|
||||
dataChangeLogRepository := repository.NewDataChangeLogRepository(db)
|
||||
logger := wire.ProvideLogger()
|
||||
asyncLogManager := wire.ProvideAsyncLogManager(db, operationLogRepository, loginLogRepository, dataChangeLogRepository, logger, config)
|
||||
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)
|
||||
userHandler := wire.ProvideUserHandler(userService, userActivityService, postService, commentService, logService)
|
||||
postHandler := wire.ProvidePostHandler(postService, userService, logService)
|
||||
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)
|
||||
chatService := wire.ProvideChatService(db, messageRepository, userRepository, hub, cache)
|
||||
messageService := wire.ProvideMessageService(db, messageRepository, cache)
|
||||
groupRepository := repository.NewGroupRepository(db)
|
||||
groupService := wire.ProvideGroupService(db, groupRepository, userRepository, messageRepository, hub, cache)
|
||||
messageHandler := wire.ProvideMessageHandler(chatService, messageService, userService, groupService, hub)
|
||||
notificationRepository := repository.NewNotificationRepository(db)
|
||||
notificationService := wire.ProvideNotificationService(notificationRepository, cache)
|
||||
notificationHandler := handler.NewNotificationHandler(notificationService)
|
||||
s3Client, err := wire.ProvideS3Client(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uploadService := wire.ProvideUploadService(s3Client, userService)
|
||||
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)
|
||||
jwtService := wire.ProvideJWTService(config)
|
||||
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, systemNotificationRepository, cache)
|
||||
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)
|
||||
voteService := wire.ProvideVoteService(voteRepository, postRepository, cache, postAIService, systemMessageService, manager, logService)
|
||||
voteHandler := handler.NewVoteHandler(voteService, postService)
|
||||
channelRepository := repository.NewChannelRepository(db)
|
||||
channelService := wire.ProvideChannelService(channelRepository)
|
||||
channelHandler := handler.NewChannelHandler(channelService)
|
||||
scheduleRepository := repository.NewScheduleRepository(db)
|
||||
scheduleService := wire.ProvideScheduleService(scheduleRepository)
|
||||
server := wire.ProvideGRPCServer(config, logger)
|
||||
taskManager := wire.ProvideTaskManager(server)
|
||||
runnerHub := wire.ProvideRunnerHub(logger)
|
||||
taskManager := wire.ProvideTaskManager(runnerHub, logger)
|
||||
scheduleSyncService := wire.ProvideScheduleSyncService(taskManager, scheduleRepository, logger)
|
||||
scheduleHandler := wire.ProvideScheduleHandler(scheduleService, scheduleSyncService)
|
||||
roleRepository := wire.ProvideRoleRepository(db)
|
||||
enforcer := wire.ProvideCasbinEnforcer(config, db)
|
||||
casbinService := wire.ProvideCasbinService(enforcer, cache, roleRepository, config)
|
||||
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)
|
||||
adminUserHandler := handler.NewAdminUserHandler(adminUserService)
|
||||
adminPostService := wire.ProvideAdminPostService(postRepository, cache)
|
||||
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)
|
||||
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, hub, jwtService, userService, userActivityService, logService)
|
||||
qrCodeLoginService := wire.ProvideQRCodeLoginService(client, messagePublisher, jwtService, userService, userActivityService, logService)
|
||||
qrCodeHandler := handler.NewQRCodeHandler(qrCodeLoginService)
|
||||
router := ProvideRouter(userHandler, postHandler, commentHandler, messageHandler, notificationHandler, uploadHandler, jwtService, pushHandler, systemMessageHandler, groupHandler, stickerHandler, voteHandler, channelHandler, scheduleHandler, roleHandler, adminUserHandler, adminPostHandler, adminCommentHandler, adminGroupHandler, adminDashboardHandler, adminLogHandler, qrCodeHandler, logService, userActivityService, casbinService)
|
||||
hotRankWorker := wire.ProvideHotRankWorker(config, postRepository, cache)
|
||||
app := NewApp(config, db, router, pushService, hotRankWorker, server, logger)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -124,13 +182,17 @@ func InitializeApp() (*App, error) {
|
||||
|
||||
// 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,
|
||||
jwtService service.JWTService,
|
||||
sessionService service.SessionService,
|
||||
identityProvider middleware.IdentityProvider, cache2 cache.Cache,
|
||||
|
||||
pushHandler *handler.PushHandler,
|
||||
systemMessageHandler *handler.SystemMessageHandler,
|
||||
groupHandler *handler.GroupHandler,
|
||||
@@ -138,6 +200,9 @@ func ProvideRouter(
|
||||
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,
|
||||
@@ -146,35 +211,66 @@ func ProvideRouter(
|
||||
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(
|
||||
userHandler,
|
||||
postHandler,
|
||||
commentHandler,
|
||||
messageHandler,
|
||||
notificationHandler,
|
||||
uploadHandler,
|
||||
jwtService,
|
||||
pushHandler,
|
||||
systemMessageHandler,
|
||||
groupHandler,
|
||||
stickerHandler,
|
||||
voteHandler,
|
||||
channelHandler,
|
||||
scheduleHandler,
|
||||
roleHandler,
|
||||
adminUserHandler,
|
||||
adminPostHandler,
|
||||
adminCommentHandler,
|
||||
adminGroupHandler,
|
||||
adminDashboardHandler,
|
||||
adminLogHandler,
|
||||
qrcodeHandler,
|
||||
logService,
|
||||
activityService,
|
||||
casbinService,
|
||||
)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,5 +10,14 @@ g = _, _
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
# 真源策略:user_roles 表为用户-角色唯一真源;Casbin 仅存 p, role, resource, action。
|
||||
# EnforceForUser 由 CasbinService 先查 DB 角色再对每个角色调 Enforce(role, obj, act),
|
||||
# 因此 matcher 直接匹配 r.sub == p.sub(角色直接匹配),不再依赖 g 分组。
|
||||
#
|
||||
# matcher 使用 globMatch(doublestar 语义):
|
||||
# - '* 匹配单层资源段(不跨 '/');
|
||||
# - '**' 跨多层(如 admin/** 命中 admin/users/roles/write)。
|
||||
# 资源命名采用路径式 admin/<domain>[/<sub>],避免 keyMatch2 把 '*' 当作正则通配
|
||||
# 跨 '.' 而误匹配;也避免 globMatch 在点号分隔下 '*' 仍跨 '.' 的过宽问题。
|
||||
[matchers]
|
||||
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && (r.act == p.act || p.act == "*")
|
||||
m = r.sub == p.sub && globMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 服务器配置
|
||||
# 服务器配置
|
||||
# 环境变量: APP_SERVER_HOST, APP_SERVER_PORT, APP_SERVER_MODE
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
@@ -13,14 +13,38 @@ server:
|
||||
database:
|
||||
type: sqlite # sqlite 或 postgres
|
||||
sqlite:
|
||||
path: ./data/carrot_bbs.db
|
||||
path: ./data/with_you.db
|
||||
postgres:
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: postgres
|
||||
password: postgres
|
||||
dbname: carrot_bbs
|
||||
dbname: with_you
|
||||
sslmode: disable
|
||||
# 读写分离配置(仅 postgres 模式生效,不配置则所有请求走主库)
|
||||
# 注意:replica 字段已在 config.go 中显式 BindEnv,环境变量优先于 YAML。
|
||||
# 单副本配置(推荐用环境变量覆盖,无需取消下方注释):
|
||||
# APP_DATABASE_REPLICA_HOST, APP_DATABASE_REPLICA_PORT, APP_DATABASE_REPLICA_USER,
|
||||
# APP_DATABASE_REPLICA_PASSWORD, APP_DATABASE_REPLICA_DBNAME, APP_DATABASE_REPLICA_SSLMODE
|
||||
# APP_DATABASE_REPLICA_MAX_IDLE_CONNS, APP_DATABASE_REPLICA_MAX_OPEN_CONNS(可选,缺省回退主库连接池)
|
||||
# replica:
|
||||
# host: replica1.internal
|
||||
# port: 5432
|
||||
# user: readonly
|
||||
# password: readonly_pass
|
||||
# dbname: with_you
|
||||
# sslmode: disable
|
||||
# 多副本配置(仅 YAML,环境变量不支持切片结构体):
|
||||
# replicas:
|
||||
# - host: replica1.internal
|
||||
# port: 5432
|
||||
# user: readonly
|
||||
# password: readonly_pass
|
||||
# dbname: with_you
|
||||
# sslmode: disable
|
||||
# replica_policy: random # random
|
||||
# replica_max_idle_conns: 10
|
||||
# replica_max_open_conns: 100
|
||||
max_idle_conns: 10
|
||||
max_open_conns: 100
|
||||
log_level: warn
|
||||
@@ -119,7 +143,7 @@ log:
|
||||
- bank_card
|
||||
rotation:
|
||||
max_size: 100
|
||||
max_age: 30
|
||||
max_age: 180
|
||||
max_backups: 10
|
||||
compress: true
|
||||
|
||||
@@ -135,6 +159,14 @@ upload:
|
||||
- image/gif
|
||||
- image/webp
|
||||
|
||||
# 设备 registration_id 清理配置
|
||||
# 周期性清理超过保留期未使用的设备 token,避免无效 registration_id 累积
|
||||
# 环境变量: APP_DEVICE_CLEANUP_ENABLED, APP_DEVICE_CLEANUP_RETENTION_DAYS, APP_DEVICE_CLEANUP_INTERVAL_MINUTES
|
||||
device_cleanup:
|
||||
enabled: true # 是否启用清理 worker
|
||||
retention_days: 3 # 保留天数,3 天不活跃即清理(按 last_used_at 判断)
|
||||
interval_minutes: 360 # 扫描间隔(分钟),默认 6 小时
|
||||
|
||||
# 敏感词过滤配置
|
||||
sensitive:
|
||||
enabled: true
|
||||
@@ -176,6 +208,19 @@ openai:
|
||||
request_timeout: 30
|
||||
strict_moderation: false
|
||||
|
||||
# 腾讯云文本内容安全配置(作为 OpenAI 备用)
|
||||
# 当 OpenAI 不可用或审核失败时,使用腾讯TMS进行文本审核
|
||||
# 环境变量:
|
||||
# APP_TENCENT_TMS_ENABLED, APP_TENCENT_TMS_SECRET_ID, APP_TENCENT_TMS_SECRET_KEY
|
||||
# APP_TENCENT_TMS_REGION, APP_TENCENT_TMS_BIZ_TYPE, APP_TENCENT_TMS_TIMEOUT
|
||||
tencent_tms:
|
||||
enabled: false
|
||||
secret_id: ""
|
||||
secret_key: ""
|
||||
region: "ap-guangzhou"
|
||||
biz_type: "" # 可选,使用控制台配置的自定义策略
|
||||
timeout: 30
|
||||
|
||||
# SMTP发信配置(gomail.v2)
|
||||
# 环境变量:
|
||||
# APP_EMAIL_ENABLED, APP_EMAIL_HOST, APP_EMAIL_PORT
|
||||
@@ -189,7 +234,7 @@ email:
|
||||
username: "no-reply@qczlit.cn"
|
||||
password: "HbvwwVjRyiWg9gsK"
|
||||
from_address: "no-reply@qczlit.cn"
|
||||
from_name: "Carrot BBS"
|
||||
from_name: "WithYou"
|
||||
use_tls: true
|
||||
insecure_skip_verify: false
|
||||
timeout: 15
|
||||
@@ -244,3 +289,124 @@ casbin:
|
||||
- /api/v1/auth/password/reset
|
||||
- /api/v1/auth/refresh
|
||||
- /api/v1/admin/setup-super-admin
|
||||
|
||||
# 举报配置
|
||||
# 环境变量: APP_REPORT_AUTO_HIDE_THRESHOLD
|
||||
report:
|
||||
auto_hide_threshold: 3 # 自动隐藏阈值,举报次数达到此数值时自动隐藏内容
|
||||
|
||||
# 极光推送配置
|
||||
# 环境变量:
|
||||
# APP_JPUSH_ENABLED, APP_JPUSH_APP_KEY, APP_JPUSH_MASTER_SECRET, APP_JPUSH_PRODUCTION
|
||||
# 厂商通道 channel_id(区分系统消息与私聊消息,通过 options.third_party_channel 下发):
|
||||
# APP_JPUSH_CHANNEL_SYSTEM, APP_JPUSH_CHANNEL_CHAT
|
||||
# APP_JPUSH_CHANNEL_XIAOMI_SYSTEM, APP_JPUSH_CHANNEL_XIAOMI_CHAT
|
||||
# APP_JPUSH_CHANNEL_HUAWEI_SYSTEM, APP_JPUSH_CHANNEL_HUAWEI_CHAT
|
||||
# APP_JPUSH_CHANNEL_OPPO_SYSTEM, APP_JPUSH_CHANNEL_OPPO_CHAT
|
||||
# APP_JPUSH_CHANNEL_VIVO_SYSTEM, APP_JPUSH_CHANNEL_VIVO_CHAT
|
||||
# APP_JPUSH_CHANNEL_MEIZU_SYSTEM, APP_JPUSH_CHANNEL_MEIZU_CHAT
|
||||
# APP_JPUSH_CHANNEL_HONOR_SYSTEM, APP_JPUSH_CHANNEL_HONOR_CHAT
|
||||
# APP_JPUSH_CHANNEL_FCM_SYSTEM, APP_JPUSH_CHANNEL_FCM_CHAT
|
||||
# 小米消息模板(可选,配置后私信消息携带 channel_id 与 mi_template_id):
|
||||
# APP_JPUSH_CHANNEL_XIAOMI_MI_SYSTEM_TEMPLATE_ID, APP_JPUSH_CHANNEL_XIAOMI_MI_CHAT_TEMPLATE_ID
|
||||
# OPPO 私信模板(可选,仅 OPPO,配置后下发私信时携带):
|
||||
# APP_JPUSH_CHANNEL_OPPO_SYSTEM_PRIVATE_TEMPLATE_ID, APP_JPUSH_CHANNEL_OPPO_CHAT_PRIVATE_TEMPLATE_ID
|
||||
# OPPO 消息分类(2024.11.20 新规,category 必传时 notify_level 才生效):
|
||||
# APP_JPUSH_CHANNEL_OPPO_SYSTEM_CATEGORY, APP_JPUSH_CHANNEL_OPPO_CHAT_CATEGORY
|
||||
# APP_JPUSH_CHANNEL_OPPO_SYSTEM_NOTIFY_LEVEL, APP_JPUSH_CHANNEL_OPPO_CHAT_NOTIFY_LEVEL
|
||||
# 荣耀通知栏消息智能分类(importance,NORMAL=服务通讯/LOW=资讯营销):
|
||||
# APP_JPUSH_CHANNEL_HONOR_SYSTEM_IMPORTANCE, APP_JPUSH_CHANNEL_HONOR_CHAT_IMPORTANCE
|
||||
# vivo 厂商消息场景标识(category,classification=1 时必须为系统消息类):
|
||||
# APP_JPUSH_CHANNEL_VIVO_SYSTEM_CATEGORY, APP_JPUSH_CHANNEL_VIVO_CHAT_CATEGORY
|
||||
# iOS 通知分组 thread-id:
|
||||
# APP_JPUSH_CHANNEL_IOS_CHAT_THREAD_ID, APP_JPUSH_CHANNEL_IOS_SYSTEM_THREAD_ID
|
||||
jpush:
|
||||
enabled: false
|
||||
app_key: ""
|
||||
master_secret: ""
|
||||
production: false # true: 生产环境, false: 开发环境
|
||||
# 厂商通道 channel_id 配置(全部可选,留空则不下发对应厂商字段)
|
||||
# channel.system / channel.chat 为各厂商默认 channel_id
|
||||
# channel.vendor.{vendor}.{system|chat} 可单独覆盖某厂商,留空则回退到默认值
|
||||
# 小米/OPPO 额外支持消息模板 id(按场景区分),配置后私信消息下发时携带
|
||||
# 其余厂商(华为/VIVO/魅族/荣耀/FCM)仅 channel_id
|
||||
channel:
|
||||
system: "" # 系统消息/通知默认 channel_id
|
||||
chat: "" # 私聊/群聊消息默认 channel_id
|
||||
vendor:
|
||||
# xiaomi: 默认值已在代码内置(system=153609, chat=153608, mi_system_template_id=P10761, mi_chat_template_id=M10289)
|
||||
# 不在此声明,由 config.go 的 viper.SetDefault 提供,环境变量可覆盖
|
||||
xiaomi: {}
|
||||
huawei: { system: "", chat: "" }
|
||||
# OPPO: category/notify_level 默认值已在代码内置(chat=IM/16, system=ACCOUNT/2),不在此声明
|
||||
oppo:
|
||||
system: ""
|
||||
chat: ""
|
||||
oppo_system_private_template_id: "" # OPPO 系统消息私信模板 id(需厂商后台注册)
|
||||
oppo_chat_private_template_id: "" # OPPO 私聊消息私信模板 id(需厂商后台注册)
|
||||
# vivo: category 默认值已在代码内置(chat=IM, system=ACCOUNT),不在此声明
|
||||
vivo:
|
||||
system: ""
|
||||
chat: ""
|
||||
meizu: { system: "", chat: "" }
|
||||
# 荣耀: importance 默认值已在代码内置(chat=NORMAL, system=LOW),不在此声明
|
||||
honor:
|
||||
system: ""
|
||||
chat: ""
|
||||
fcm: { system: "", chat: "" }
|
||||
# iOS APNs 通知分组(按 thread-id 区分系统/私聊通知,不配置则不分组)
|
||||
ios:
|
||||
chat_thread_id: "" # 私聊/群聊通知分组 thread-id
|
||||
system_thread_id: "" # 系统消息/通知分组 thread-id
|
||||
|
||||
# 初始化超级管理员密钥
|
||||
# 环境变量: APP_SETUP_SECRET
|
||||
# 设置后可使用 POST /api/v1/admin/setup-super-admin 接口初始化第一位超级管理员
|
||||
# 该接口只能使用一次,一旦系统中存在超级管理员,此接口将永久拒绝请求
|
||||
setup_secret: ""
|
||||
|
||||
# WebSocket 配置
|
||||
# 环境变量:
|
||||
# APP_WEBSOCKET_MODE, APP_WEBSOCKET_CLUSTER_INSTANCE_ID
|
||||
# APP_WEBSOCKET_CLUSTER_MSG_CHANNEL, APP_WEBSOCKET_CLUSTER_ONLINE_TTL
|
||||
# APP_WEBSOCKET_CLUSTER_HEARTBEAT_INTERVAL
|
||||
websocket:
|
||||
mode: standalone # standalone 或 cluster
|
||||
cluster:
|
||||
instance_id: "" # 留空自动生成 UUID
|
||||
msg_channel: "ws:msg" # Redis Pub/Sub 频道
|
||||
online_ttl: 60 # 在线状态 TTL(秒)
|
||||
heartbeat_interval: 20 # 心跳间隔(秒)
|
||||
|
||||
# Seq 预分配配置
|
||||
# 环境变量:
|
||||
# APP_SEQ_BUFFER_ENABLED, APP_SEQ_BUFFER_PRIVATE_BUFFER_SIZE
|
||||
# APP_SEQ_BUFFER_GROUP_BUFFER_SIZE, APP_SEQ_BUFFER_LOCK_TIMEOUT_MS
|
||||
# APP_SEQ_BUFFER_MAX_RETRIES
|
||||
seq_buffer:
|
||||
enabled: false # 启用后使用 Lua 预分配代替 INCR,关闭则回退旧逻辑
|
||||
private_buffer_size: 50 # 私聊每次向 Redis 预分配步长
|
||||
group_buffer_size: 200 # 群聊每次向 Redis 预分配步长(群聊消息更频繁)
|
||||
lock_timeout_ms: 5000 # 分布式锁超时(毫秒)
|
||||
max_retries: 3 # 冷启动获取锁失败最大重试次数
|
||||
|
||||
# 版本日志增量同步配置
|
||||
# 环境变量:
|
||||
# APP_VERSION_LOG_ENABLED, APP_VERSION_LOG_SYNC_LIMIT
|
||||
# APP_VERSION_LOG_MAX_SYNC_GAP
|
||||
version_log:
|
||||
enabled: false # 启用后客户端可使用 /conversations/sync 增量同步
|
||||
sync_limit: 100 # 单次同步最大返回变更条数
|
||||
max_sync_gap: 1000 # 版本差距超过此值建议全量同步
|
||||
|
||||
# LiveKit SFU 配置(语音/视频通话)
|
||||
# 环境变量:
|
||||
# APP_LIVEKIT_ENABLED, APP_LIVEKIT_URL, APP_LIVEKIT_API_KEY, APP_LIVEKIT_API_SECRET
|
||||
# APP_LIVEKIT_TOKEN_TTL, APP_LIVEKIT_WEBHOOK_SECRET
|
||||
livekit:
|
||||
enabled: false
|
||||
url: "http://localhost:7880"
|
||||
api_key: "devkey"
|
||||
api_secret: "u1sLYiHWe4HIr3KrJrvknIleoHdgHdDqSjfkBvRs2AlA"
|
||||
token_ttl: 600 # token 有效期(秒)
|
||||
webhook_secret: "" # webhook 签名密钥(与 LiveKit 服务端配置一致)
|
||||
|
||||
32
configs/livekit.yaml
Normal file
32
configs/livekit.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
# LiveKit Server Configuration
|
||||
# See https://docs.livekit.io/realtime/server/config/ for all options
|
||||
|
||||
port: 7880
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
use_external_ip: false
|
||||
# Enable TURN/TLS relay for restrictive networks
|
||||
port_range_start: 7881
|
||||
port_range_end: 7882
|
||||
node_ip: 127.0.0.1
|
||||
|
||||
keys:
|
||||
devkey: u1sLYiHWe4HIr3KrJrvknIleoHdgHdDqSjfkBvRs2AlA
|
||||
|
||||
logging:
|
||||
level: info
|
||||
|
||||
# Redis for distributed state (required)
|
||||
redis:
|
||||
address: livekit-redis:6379
|
||||
|
||||
# PostgreSQL for persistent storage (rooms, participants, egress)
|
||||
db:
|
||||
address: postgres://postgres:postgres@livekit-postgres:5432/livekit?sslmode=disable
|
||||
|
||||
# Webhook for call history persistence
|
||||
# webhook:
|
||||
# urls:
|
||||
# - http://app:8080/api/v1/calls/webhook
|
||||
# api_key: devkey
|
||||
@@ -55,6 +55,58 @@ services:
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
|
||||
# LiveKit SFU Server (voice/video calling)
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9.5
|
||||
container_name: livekit
|
||||
ports:
|
||||
- "7880:7880" # HTTP (token, webhooks, dashboard)
|
||||
- "7881:7881" # TCP/TURN fallback
|
||||
- "7882:7882" # UDP media
|
||||
volumes:
|
||||
- ./configs/livekit.yaml:/etc/livekit.yaml
|
||||
command: --config /etc/livekit.yaml --node-ip=127.0.0.1
|
||||
depends_on:
|
||||
livekit-redis:
|
||||
condition: service_started
|
||||
livekit-postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# LiveKit Redis (required for distributed coordination)
|
||||
livekit-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: livekit_redis
|
||||
ports:
|
||||
- "6380:6379"
|
||||
volumes:
|
||||
- livekit_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# LiveKit PostgreSQL (required for room/participant state)
|
||||
livekit-postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: livekit_postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: livekit
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- livekit_pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d livekit"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
@@ -76,3 +128,5 @@ volumes:
|
||||
# postgres_data:
|
||||
# redis_data:
|
||||
minio_data:
|
||||
livekit_redis_data:
|
||||
livekit_pg_data:
|
||||
|
||||
69
go.mod
69
go.mod
@@ -1,6 +1,6 @@
|
||||
module carrot_bbs
|
||||
module with_you
|
||||
|
||||
go 1.25.7
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
@@ -12,36 +12,50 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/livekit/protocol v1.39.4-0.20250721114233-52633eee694f
|
||||
github.com/minio/minio-go/v7 v7.0.99
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/image v0.38.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
gorm.io/plugin/dbresolver v1.6.2
|
||||
)
|
||||
|
||||
require (
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 // indirect
|
||||
buf.build/go/protovalidate v0.14.0 // indirect
|
||||
buf.build/go/protoyaml v0.6.0 // indirect
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/benbjohnson/clock v1.3.5 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dennwc/iters v1.1.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/frostbyte73/core v0.1.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.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/sqlite v1.11.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
@@ -51,17 +65,22 @@ require (
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/google/cel-go v0.26.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/jxskiss/base62 v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
|
||||
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
|
||||
github.com/livekit/psrpc v0.6.1-0.20250511053145-465289d72c3c // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||
github.com/microsoft/go-mssqldb v1.9.8 // indirect
|
||||
@@ -69,10 +88,31 @@ require (
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nats-io/nats.go v1.43.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/opencontainers/runc v1.1.14 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||
github.com/pion/interceptor v0.1.40 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
github.com/pion/rtp v1.8.21 // indirect
|
||||
github.com/pion/sctp v1.8.39 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.15 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.6 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||
github.com/pion/webrtc/v4 v4.1.3 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -83,30 +123,37 @@ require (
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stoewer/go-strcase v1.3.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // 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/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/driver/sqlserver v1.6.3 // indirect
|
||||
gorm.io/plugin/dbresolver v1.6.2 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.47.0 // indirect
|
||||
modernc.org/sqlite v1.48.0 // indirect
|
||||
)
|
||||
|
||||
170
go.sum
170
go.sum
@@ -1,3 +1,13 @@
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1 h1:Lg6klmCi3v7VvpqeeLEER9/m5S8y9e9DjhqQnSCNy4k=
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250717185734-6c6e0d3c608e.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
|
||||
buf.build/go/protovalidate v0.14.0 h1:kr/rC/no+DtRyYX+8KXLDxNnI1rINz0imk5K44ZpZ3A=
|
||||
buf.build/go/protovalidate v0.14.0/go.mod h1:+F/oISho9MO7gJQNYC2VWLzcO1fTPmaTA08SDYJZncA=
|
||||
buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w=
|
||||
buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q=
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
@@ -20,12 +30,22 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:
|
||||
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.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
|
||||
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
@@ -37,8 +57,8 @@ github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/casbin/casbin/v3 v3.10.0 h1:039ORla55vCeIZWd0LfzWFt1yiEA5X4W41xBW2bQuHs=
|
||||
github.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8=
|
||||
github.com/casbin/gorm-adapter/v3 v3.41.0 h1:Xhpi0tfRP9aKPDWDf6dgBxHZ9UM6IophxxPIEGWqCNM=
|
||||
@@ -46,30 +66,48 @@ github.com/casbin/gorm-adapter/v3 v3.41.0/go.mod h1:BQZRJhwUnwMpI+pT2m7/cUJwXxrH
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8=
|
||||
github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dennwc/iters v1.1.0 h1:PsS3DbOU7GxSUQO0e7SGmzHkPhtwOlwbqggJ++Bgnr8=
|
||||
github.com/dennwc/iters v1.1.0/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/docker/cli v26.1.4+incompatible h1:I8PHdc0MtxEADqYJZvhBrW9bo8gawKwwenxRM7/rLu8=
|
||||
github.com/docker/cli v26.1.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
|
||||
github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
|
||||
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gammazero/deque v1.1.0 h1:OyiyReBbnEG2PP0Bnv1AASLIYvyKqIFN5xfl1t8oGLo=
|
||||
github.com/gammazero/deque v1.1.0/go.mod h1:JVrR+Bj1NMQbPnYclvDlvSX0nVGReLrQZ0aUMuWLctg=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
@@ -78,6 +116,8 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -98,6 +138,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
@@ -109,12 +151,17 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
|
||||
github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -124,6 +171,8 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -132,8 +181,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
@@ -148,8 +197,10 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
|
||||
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -168,6 +219,14 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
|
||||
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
|
||||
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
|
||||
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
|
||||
github.com/livekit/protocol v1.39.4-0.20250721114233-52633eee694f h1:Cwe38+/ld3r5dnNmIZSALSoZPWNEMeYPZIi/qjpplLo=
|
||||
github.com/livekit/protocol v1.39.4-0.20250721114233-52633eee694f/go.mod h1:YlgUxAegtU8jZ0tVXoIV/4fHeHqqLvS+6JnPKDbpFPU=
|
||||
github.com/livekit/psrpc v0.6.1-0.20250511053145-465289d72c3c h1:WwEr0YBejYbKzk8LSaO9h8h0G9MnE7shyDu8yXQWmEc=
|
||||
github.com/livekit/psrpc v0.6.1-0.20250511053145-465289d72c3c/go.mod h1:kmD+AZPkWu0MaXIMv57jhNlbiSZZ/Jx4bzlxBDVmJes=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
@@ -181,6 +240,12 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE=
|
||||
github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -188,12 +253,58 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/nats-io/nats.go v1.43.0 h1:uRFZ2FEoRvP64+UUhaTokyS18XBCR/xM2vQZKO4i8ug=
|
||||
github.com/nats-io/nats.go v1.43.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/opencontainers/runc v1.1.14 h1:rgSuzbmgz5DUJjeSnw337TxDbRuqjs6iqQck/2weR6w=
|
||||
github.com/opencontainers/runc v1.1.14/go.mod h1:E4C2z+7BxR7GHXp0hAY53mek+x49X1LjPNeMTfRGvOA=
|
||||
github.com/ory/dockertest/v3 v3.11.0 h1:OiHcxKAvSDUwsEVh2BjxQQc/5EHz9n0va9awCtNGuyA=
|
||||
github.com/ory/dockertest/v3 v3.11.0/go.mod h1:VIPxS1gwT9NpPOrfD3rACs8Y9Z7yhzO4SB194iUDnUI=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||
github.com/pion/rtp v1.8.21 h1:3yrOwmZFyUpcIosNcWRpQaU+UXIJ6yxLuJ8Bx0mw37Y=
|
||||
github.com/pion/rtp v1.8.21/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sdp/v3 v3.0.15 h1:F0I1zds+K/+37ZrzdADmx2Q44OFDOPRLhPnNTaUX9hk=
|
||||
github.com/pion/sdp/v3 v3.0.15/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
|
||||
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
|
||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
||||
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
||||
github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
|
||||
github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
@@ -202,6 +313,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -218,8 +331,12 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -228,6 +345,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
|
||||
github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -247,13 +366,25 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
|
||||
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
@@ -280,6 +411,8 @@ 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=
|
||||
@@ -298,9 +431,11 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -399,8 +534,10 @@ 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=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
@@ -415,6 +552,7 @@ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkp
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
@@ -454,8 +592,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
22
internal/cache/cache.go
vendored
22
internal/cache/cache.go
vendored
@@ -17,9 +17,9 @@ var ErrKeyNotFound = errors.New("key not found")
|
||||
// Cache 缓存接口
|
||||
type Cache interface {
|
||||
// Set 设置缓存值,支持TTL
|
||||
Set(key string, value interface{}, ttl time.Duration)
|
||||
Set(key string, value any, ttl time.Duration)
|
||||
// Get 获取缓存值
|
||||
Get(key string) (interface{}, bool)
|
||||
Get(key string) (any, bool)
|
||||
// Delete 删除缓存
|
||||
Delete(key string)
|
||||
// DeleteByPrefix 根据前缀删除缓存
|
||||
@@ -32,20 +32,24 @@ type Cache interface {
|
||||
Increment(key string) int64
|
||||
// IncrementBy 增加指定值
|
||||
IncrementBy(key string, value int64) int64
|
||||
// DeleteBatch 批量删除多个 key(使用 Pipeline 减少网络往返)
|
||||
DeleteBatch(keys []string)
|
||||
|
||||
// ==================== Hash 操作 ====================
|
||||
// HSet 设置 Hash 字段
|
||||
HSet(ctx context.Context, key string, field string, value interface{}) error
|
||||
HSet(ctx context.Context, key string, field string, value any) error
|
||||
// HMSet 批量设置 Hash 字段
|
||||
HMSet(ctx context.Context, key string, values map[string]interface{}) error
|
||||
HMSet(ctx context.Context, key string, values map[string]any) error
|
||||
// HGet 获取 Hash 字段值
|
||||
HGet(ctx context.Context, key string, field string) (string, error)
|
||||
// HMGet 批量获取 Hash 字段值
|
||||
HMGet(ctx context.Context, key string, fields ...string) ([]interface{}, error)
|
||||
HMGet(ctx context.Context, key string, fields ...string) ([]any, error)
|
||||
// HGetAll 获取 Hash 所有字段
|
||||
HGetAll(ctx context.Context, key string) (map[string]string, error)
|
||||
// HDel 删除 Hash 字段
|
||||
HDel(ctx context.Context, key string, fields ...string) error
|
||||
// HIncrBy 原子递增 Hash 字段的值
|
||||
HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error)
|
||||
|
||||
// ==================== Sorted Set 操作 ====================
|
||||
// ZAdd 添加 Sorted Set 成员
|
||||
@@ -59,13 +63,15 @@ type Cache interface {
|
||||
// ZReplaceSortedSet 原子替换整个 ZSET(先删后批量写入)
|
||||
ZReplaceSortedSet(ctx context.Context, key string, members []redis.Z) error
|
||||
// ZRem 删除 Sorted Set 成员
|
||||
ZRem(ctx context.Context, key string, members ...interface{}) error
|
||||
ZRem(ctx context.Context, key string, members ...any) error
|
||||
// ZCard 获取 Sorted Set 成员数量
|
||||
ZCard(ctx context.Context, key string) (int64, error)
|
||||
|
||||
// ==================== 计数器操作 ====================
|
||||
// Incr 原子递增(返回新值)
|
||||
Incr(ctx context.Context, key string) (int64, error)
|
||||
// IncrBySeq 原子递增指定值(用于 seq 对齐)
|
||||
IncrBySeq(ctx context.Context, key string, delta int64) (int64, error)
|
||||
// Expire 设置过期时间
|
||||
Expire(ctx context.Context, key string, ttl time.Duration) error
|
||||
}
|
||||
@@ -93,7 +99,7 @@ var settings = Settings{
|
||||
NullTTL: 5 * time.Second,
|
||||
JitterRatio: 0.1,
|
||||
PostListTTL: 30 * time.Second,
|
||||
ConversationTTL: 60 * time.Second,
|
||||
ConversationTTL: 120 * time.Second,
|
||||
UnreadCountTTL: 30 * time.Second,
|
||||
GroupMembersTTL: 120 * time.Second,
|
||||
DisableFlushDB: true,
|
||||
@@ -145,7 +151,7 @@ func normalizePrefix(prefix string) string {
|
||||
return cmp.Or(settings.KeyPrefix, "") + ":" + prefix
|
||||
}
|
||||
|
||||
func SetWithJitter(c Cache, key string, value interface{}, ttl time.Duration, jitterRatio float64) {
|
||||
func SetWithJitter(c Cache, key string, value any, ttl time.Duration, jitterRatio float64) {
|
||||
if !settings.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
386
internal/cache/conversation_cache.go
vendored
386
internal/cache/conversation_cache.go
vendored
@@ -1,4 +1,4 @@
|
||||
package cache
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,11 +6,40 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"with_you/internal/model"
|
||||
redisPkg "with_you/internal/pkg/redis"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// nextSeqLua 原子递增 seq:key 存在时 INCR 返回新值,不存在时返回 0 触发冷启动
|
||||
var nextSeqLua = redis.NewScript(`
|
||||
local seqKey = KEYS[1]
|
||||
if redis.call('EXISTS', seqKey) == 1 then
|
||||
return redis.call('INCR', seqKey)
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`)
|
||||
|
||||
// initSeqLua 冷启动原子初始化:SETNX 防止覆盖 + INCR 递增,返回第一个可用 seq
|
||||
var initSeqLua = redis.NewScript(`
|
||||
local seqKey = KEYS[1]
|
||||
local initVal = tonumber(ARGV[1])
|
||||
redis.call('SETNX', seqKey, initVal)
|
||||
return redis.call('INCR', seqKey)
|
||||
`)
|
||||
|
||||
// syncSeqLua 写透缓存:确保 Redis seq >= DB seq,防止冷启动后读到过期值
|
||||
var syncSeqLua = redis.NewScript(`
|
||||
local current = tonumber(redis.call('GET', KEYS[1]))
|
||||
if current == nil or current < tonumber(ARGV[1]) then
|
||||
redis.call('SET', KEYS[1], ARGV[1])
|
||||
end
|
||||
`)
|
||||
|
||||
// CachedConversation 带缓存元数据的会话
|
||||
type CachedConversation struct {
|
||||
Data *model.Conversation // 实际数据
|
||||
@@ -72,19 +101,23 @@ type ConversationCacheSettings struct {
|
||||
MessageListTTL time.Duration // 消息分页列表缓存 (5min)
|
||||
MessageIndexTTL time.Duration // 消息索引缓存 (30min)
|
||||
MessageCountTTL time.Duration // 消息计数缓存 (30min)
|
||||
|
||||
// Seq 缓存配置
|
||||
SeqTTL time.Duration // Seq 计数器缓存 (24h)
|
||||
}
|
||||
|
||||
// DefaultConversationCacheSettings 返回默认配置
|
||||
func DefaultConversationCacheSettings() *ConversationCacheSettings {
|
||||
return &ConversationCacheSettings{
|
||||
DetailTTL: 5 * time.Minute,
|
||||
ListTTL: 60 * time.Second,
|
||||
ParticipantTTL: 5 * time.Minute,
|
||||
DetailTTL: 15 * time.Minute,
|
||||
ListTTL: 120 * time.Second,
|
||||
ParticipantTTL: 15 * time.Minute,
|
||||
UnreadTTL: 30 * time.Second,
|
||||
MessageDetailTTL: 30 * time.Minute,
|
||||
MessageListTTL: 5 * time.Minute,
|
||||
MessageIndexTTL: 30 * time.Minute,
|
||||
MessageCountTTL: 30 * time.Minute,
|
||||
SeqTTL: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +248,7 @@ type MessageRepository interface {
|
||||
GetMessages(convID string, page, pageSize int) ([]*model.Message, int64, error)
|
||||
GetMessagesAfterSeq(convID string, afterSeq int64, limit int) ([]*model.Message, error)
|
||||
GetMessagesBeforeSeq(convID string, beforeSeq int64, limit int) ([]*model.Message, error)
|
||||
GetNextSeq(convID string) (int64, error)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -223,22 +257,24 @@ type MessageRepository interface {
|
||||
|
||||
// ConversationCache 会话缓存管理器
|
||||
type ConversationCache struct {
|
||||
cache Cache // 底层缓存
|
||||
settings *ConversationCacheSettings // 配置
|
||||
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
||||
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
||||
cache Cache // 底层缓存
|
||||
settings *ConversationCacheSettings // 配置
|
||||
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
|
||||
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
|
||||
seqBufferMgr *SeqBufferManager // seq 预分配管理器(nil 则使用旧 INCR 逻辑)
|
||||
}
|
||||
|
||||
// NewConversationCache 创建会话缓存管理器
|
||||
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings) *ConversationCache {
|
||||
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings, seqBufferMgr *SeqBufferManager) *ConversationCache {
|
||||
if settings == nil {
|
||||
settings = DefaultConversationCacheSettings()
|
||||
}
|
||||
return &ConversationCache{
|
||||
cache: cache,
|
||||
settings: settings,
|
||||
repo: repo,
|
||||
msgRepo: msgRepo,
|
||||
cache: cache,
|
||||
settings: settings,
|
||||
repo: repo,
|
||||
msgRepo: msgRepo,
|
||||
seqBufferMgr: seqBufferMgr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +514,289 @@ func (c *ConversationCache) InvalidateUnreadCount(userID, convID string) {
|
||||
c.cache.Delete(UnreadDetailKey(userID, convID))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 已读位置缓存(OpenIM 风格 hasReadSeq)
|
||||
// ============================================================
|
||||
|
||||
// readSeqExpire 已读位置缓存 TTL
|
||||
const readSeqExpire = 30 * 24 * time.Hour
|
||||
|
||||
// SetUserReadSeq 设置用户在某会话的已读位置(防回退:仅当新值大于当前值时更新)
|
||||
func (c *ConversationCache) SetUserReadSeq(ctx context.Context, convID, userID string, seq int64) error {
|
||||
key := UserReadSeqKey(convID, userID)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
// 非 Redis 模式,直接设置
|
||||
c.cache.Set(key, seq, readSeqExpire)
|
||||
return nil
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
nKey := normalizeKey(key)
|
||||
// Lua 脚本:仅当新 seq > 当前值时才更新,防回退
|
||||
script := redis.NewScript(`
|
||||
local current = tonumber(redis.call('GET', KEYS[1]))
|
||||
if current and current >= tonumber(ARGV[1]) then
|
||||
return 0
|
||||
end
|
||||
redis.call('SET', KEYS[1], ARGV[1])
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
||||
return 1
|
||||
`)
|
||||
_, err := script.Run(ctx, rdb, []string{nKey}, seq, int64(readSeqExpire.Seconds())).Result()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserReadSeq 获取用户在某会话的已读位置
|
||||
func (c *ConversationCache) GetUserReadSeq(ctx context.Context, convID, userID string) (int64, error) {
|
||||
key := UserReadSeqKey(convID, userID)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
raw, ok := c.cache.Get(key)
|
||||
if !ok {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case int64:
|
||||
return v, nil
|
||||
case string:
|
||||
var n int64
|
||||
fmt.Sscanf(v, "%d", &n)
|
||||
return n, nil
|
||||
}
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
nKey := normalizeKey(key)
|
||||
val, err := rdb.Get(ctx, nKey).Int64()
|
||||
if err != nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetUserReadSeqs 批量获取用户在多个会话的已读位置
|
||||
func (c *ConversationCache) GetUserReadSeqs(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
|
||||
if len(convIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
result := make(map[string]int64, len(convIDs))
|
||||
for _, convID := range convIDs {
|
||||
if seq, err := c.GetUserReadSeq(ctx, convID, userID); err == nil {
|
||||
result[convID] = seq
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
keys := make([]string, len(convIDs))
|
||||
for i, convID := range convIDs {
|
||||
keys[i] = normalizeKey(UserReadSeqKey(convID, userID))
|
||||
}
|
||||
|
||||
vals, err := rdb.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]int64, len(convIDs))
|
||||
for i, val := range vals {
|
||||
if val == nil {
|
||||
continue
|
||||
}
|
||||
var seq int64
|
||||
fmt.Sscanf(fmt.Sprint(val), "%d", &seq)
|
||||
result[convIDs[i]] = seq
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetConvMaxSeq 获取会话的最大 seq(复用 msg_seq 计数器,读取当前值)
|
||||
func (c *ConversationCache) GetConvMaxSeq(ctx context.Context, convID string) (int64, error) {
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
nKey := normalizeKey(seqKey)
|
||||
val, err := rdb.Get(ctx, nKey).Int64()
|
||||
if err != nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// GetConvMaxSeqBatch 批量获取多个会话的 maxSeq(MGet 减少网络往返)
|
||||
func (c *ConversationCache) GetConvMaxSeqBatch(ctx context.Context, convIDs []string) (map[string]int64, error) {
|
||||
if len(convIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if !ok {
|
||||
result := make(map[string]int64, len(convIDs))
|
||||
for _, convID := range convIDs {
|
||||
if seq, err := c.GetConvMaxSeq(ctx, convID); err == nil {
|
||||
result[convID] = seq
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
keys := make([]string, len(convIDs))
|
||||
for i, convID := range convIDs {
|
||||
keys[i] = normalizeKey(MessageSeqKey(convID))
|
||||
}
|
||||
|
||||
vals, err := rdb.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]int64, len(convIDs))
|
||||
for i, val := range vals {
|
||||
if val == nil {
|
||||
continue
|
||||
}
|
||||
var seq int64
|
||||
fmt.Sscanf(fmt.Sprint(val), "%d", &seq)
|
||||
if seq > 0 {
|
||||
result[convIDs[i]] = seq
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ComputeUnreadCount 计算单个会话未读数:maxSeq - hasReadSeq
|
||||
func (c *ConversationCache) ComputeUnreadCount(ctx context.Context, convID, userID string) (int64, error) {
|
||||
maxSeq, err := c.GetConvMaxSeq(ctx, convID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
readSeq, err := c.GetUserReadSeq(ctx, convID, userID)
|
||||
if err != nil {
|
||||
// 未读到已读位置,视为 0,返回 maxSeq
|
||||
return maxSeq, nil
|
||||
}
|
||||
unread := maxSeq - readSeq
|
||||
if unread < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
// ComputeAllUnreadCount 计算所有会话未读总数:sum(maxSeq - hasReadSeq)
|
||||
func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID string, convIDs []string, maxSeqs map[string]int64) (int64, error) {
|
||||
readSeqs, err := c.GetUserReadSeqs(ctx, userID, convIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var total int64
|
||||
for _, convID := range convIDs {
|
||||
maxSeq, ok := maxSeqs[convID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
readSeq, hasRead := readSeqs[convID]
|
||||
if !hasRead {
|
||||
total += maxSeq
|
||||
continue
|
||||
}
|
||||
unread := maxSeq - readSeq
|
||||
if unread > 0 {
|
||||
total += unread
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// GetNextSeq 获取会话的下一个 seq 值(单次 Redis INCR,原子无竞态)
|
||||
func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int64, error) {
|
||||
// 优先使用 SeqBufferManager(已简化为单次 INCR)
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||
return c.seqBufferMgr.GetNextSeq(ctx, convID, false)
|
||||
}
|
||||
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
// 尝试 Redis INCR(key 存在时递增,不存在时返回 0 触发冷启动)
|
||||
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
|
||||
rdb := lc.GetRedisClient()
|
||||
if rdb != nil {
|
||||
result, err := nextSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}).Int64()
|
||||
if err == nil {
|
||||
if result > 0 {
|
||||
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||
return result, nil
|
||||
}
|
||||
// 冷启动:从 DB 加载 last_seq,SETNX+INCR 原子初始化
|
||||
return c.initSeqFromDB(ctx, convID, seqKey, rdb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redis 不可用,降级到 DB(SELECT FOR UPDATE 保证原子性)
|
||||
if c.msgRepo != nil {
|
||||
if dbSeq, dbErr := c.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||
return dbSeq, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("seq allocation failed for %s: no redis or db available", convID)
|
||||
}
|
||||
|
||||
// initSeqFromDB 冷启动:从 DB 加载 last_seq,用 SETNX+INCR 原子初始化
|
||||
func (c *ConversationCache) initSeqFromDB(ctx context.Context, convID, seqKey string, rdb *redisPkg.Client) (int64, error) {
|
||||
if c.repo == nil {
|
||||
return 0, fmt.Errorf("conversation repository not configured for seq init")
|
||||
}
|
||||
conv, dbErr := c.repo.GetConversationByID(convID)
|
||||
if dbErr != nil {
|
||||
return 0, fmt.Errorf("load conversation for seq init: %w", dbErr)
|
||||
}
|
||||
result, initErr := initSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, conv.LastSeq).Int64()
|
||||
if initErr != nil {
|
||||
return 0, fmt.Errorf("initSeqLua failed: %w", initErr)
|
||||
}
|
||||
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetNextSeqWithGroup 获取会话的下一个 seq 值(isGroup 参数保留兼容,简化后不区分步长)
|
||||
func (c *ConversationCache) GetNextSeqWithGroup(ctx context.Context, convID string, isGroup bool) (int64, error) {
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||
return c.seqBufferMgr.GetNextSeq(ctx, convID, isGroup)
|
||||
}
|
||||
return c.GetNextSeq(ctx, convID)
|
||||
}
|
||||
|
||||
// SyncConvSeq 写透缓存:将 DB 刚分配的 seq 同步到 Redis,确保 Redis seq >= DB seq
|
||||
func (c *ConversationCache) SyncConvSeq(ctx context.Context, convID string, seq int64) {
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
|
||||
rdb := lc.GetRedisClient()
|
||||
if rdb != nil {
|
||||
_ = syncSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, seq).Err()
|
||||
}
|
||||
}
|
||||
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.rdb != nil {
|
||||
_ = syncSeqLua.Run(ctx, c.seqBufferMgr.rdb, []string{seqKey}, seq).Err()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息缓存方法
|
||||
// ============================================================
|
||||
@@ -618,11 +937,11 @@ func (c *ConversationCache) GetMessagesBeforeSeq(ctx context.Context, convID str
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// CacheMessage 缓存单条消息(立即写入缓存)
|
||||
// 写入 Hash、Sorted Set、更新计数
|
||||
// CacheMessage 缓存单条消息(使用 Redis Pipeline 减少网络往返)
|
||||
func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg *model.Message) error {
|
||||
hashKey := MessageHashKey(convID)
|
||||
indexKey := MessageIndexKey(convID)
|
||||
countKey := MessageCountKey(convID)
|
||||
|
||||
msgData := MessageCacheDataFromModel(msg)
|
||||
data, err := json.Marshal(msgData)
|
||||
@@ -630,23 +949,34 @@ func (c *ConversationCache) CacheMessage(ctx context.Context, convID string, msg
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
// HSET 消息详情
|
||||
if err := c.cache.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
|
||||
hashKey = ResolveKey(hashKey)
|
||||
indexKey = ResolveKey(indexKey)
|
||||
countKey = ResolveKey(countKey)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if ok {
|
||||
rdb := redisCache.GetRedisClient().GetClient()
|
||||
pipe := rdb.Pipeline()
|
||||
pipe.HSet(ctx, hashKey, fmt.Sprintf("%d", msg.Seq), string(data))
|
||||
pipe.ZAdd(ctx, indexKey, redis.Z{Score: float64(msg.Seq), Member: fmt.Sprintf("%d", msg.Seq)})
|
||||
pipe.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
|
||||
pipe.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
|
||||
pipe.Incr(ctx, countKey)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return fmt.Errorf("failed to pipeline cache message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.cache.HSet(ctx, MessageHashKey(convID), fmt.Sprintf("%d", msg.Seq), string(data)); err != nil {
|
||||
return fmt.Errorf("failed to set hash: %w", err)
|
||||
}
|
||||
|
||||
// ZADD 消息索引
|
||||
if err := c.cache.ZAdd(ctx, indexKey, float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
|
||||
if err := c.cache.ZAdd(ctx, MessageIndexKey(convID), float64(msg.Seq), fmt.Sprintf("%d", msg.Seq)); err != nil {
|
||||
return fmt.Errorf("failed to add to sorted set: %w", err)
|
||||
}
|
||||
|
||||
// 设置 TTL
|
||||
c.cache.Expire(ctx, hashKey, c.settings.MessageDetailTTL)
|
||||
c.cache.Expire(ctx, indexKey, c.settings.MessageIndexTTL)
|
||||
|
||||
// INCR 消息计数
|
||||
c.cache.Expire(ctx, MessageHashKey(convID), c.settings.MessageDetailTTL)
|
||||
c.cache.Expire(ctx, MessageIndexKey(convID), c.settings.MessageIndexTTL)
|
||||
c.cache.Incr(ctx, MessageCountKey(convID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
83
internal/cache/keys.go
vendored
83
internal/cache/keys.go
vendored
@@ -27,16 +27,26 @@ const (
|
||||
PrefixUnreadConversation = "unread:conversation"
|
||||
PrefixUnreadDetail = "unread:detail"
|
||||
|
||||
// 已读位置相关(OpenIM 风格:缓存 hasReadSeq,用于 O(1) 未读数计算)
|
||||
PrefixUserReadSeq = "user_read_seq"
|
||||
|
||||
// 用户相关
|
||||
PrefixUserInfo = "users:info"
|
||||
PrefixUserMe = "users:me"
|
||||
|
||||
// 频道相关(全量列表一条缓存,供 /channels、帖子 channel 填充共用)
|
||||
PrefixChannelsAllList = "channels:all_list"
|
||||
|
||||
// 消息缓存相关
|
||||
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
|
||||
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
|
||||
keyPrefixMsgCount = "msg_count" // 消息计数
|
||||
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
||||
keyPrefixMsgPage = "msg_page" // 分页缓存
|
||||
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
|
||||
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
|
||||
keyPrefixMsgCount = "msg_count" // 消息计数
|
||||
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
||||
keyPrefixMsgPage = "msg_page" // 分页缓存
|
||||
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
|
||||
|
||||
// 帖子创建幂等键前缀(clientRequestID -> 已创建的 postID)
|
||||
keyPrefixPostIdempotent = "post_idem"
|
||||
)
|
||||
|
||||
// PostListKey 生成帖子列表缓存键
|
||||
@@ -54,6 +64,14 @@ func HotRankTopIDsKey() string {
|
||||
return HotRankTopIDs
|
||||
}
|
||||
|
||||
func HotRankZSetChannelKey(channelID string) string {
|
||||
return fmt.Sprintf("%s:ch:%s", HotRankZSet, channelID)
|
||||
}
|
||||
|
||||
func HotRankTopIDsChannelKey(channelID string) string {
|
||||
return fmt.Sprintf("%s:ch:%s", HotRankTopIDs, channelID)
|
||||
}
|
||||
|
||||
func PostListKey(postType string, userID string, page, pageSize int) string {
|
||||
if userID == "" {
|
||||
return fmt.Sprintf("%s:%s:%d:%d", PrefixPostList, postType, page, pageSize)
|
||||
@@ -71,25 +89,13 @@ func ConversationListKey(userID string, page, pageSize int) string {
|
||||
return fmt.Sprintf("%s:%s:%d:%d", PrefixConversationList, userID, page, pageSize)
|
||||
}
|
||||
|
||||
// ConversationDetailKey 生成会话详情缓存键
|
||||
func ConversationDetailKey(conversationID, userID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", PrefixConversationDetail, conversationID, userID)
|
||||
}
|
||||
|
||||
// GroupMembersKey 生成群组成员缓存键
|
||||
func GroupMembersKey(groupID string, page, pageSize int) string {
|
||||
return fmt.Sprintf("%s:%s:page:%d:size:%d", PrefixGroupMembers, groupID, page, pageSize)
|
||||
}
|
||||
|
||||
// GroupMembersAllKey 生成群组全量成员ID列表缓存键
|
||||
func GroupMembersAllKey(groupID string) string {
|
||||
return fmt.Sprintf("%s:all:%s", PrefixGroupMembers, groupID)
|
||||
}
|
||||
|
||||
// GroupInfoKey 生成群组信息缓存键
|
||||
func GroupInfoKey(groupID string) string {
|
||||
return fmt.Sprintf("%s:%s", PrefixGroupInfo, groupID)
|
||||
}
|
||||
|
||||
// UnreadSystemKey 生成系统消息未读数缓存键
|
||||
func UnreadSystemKey(userID string) string {
|
||||
@@ -106,14 +112,15 @@ func UnreadDetailKey(userID, conversationID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", PrefixUnreadDetail, userID, conversationID)
|
||||
}
|
||||
|
||||
// UserInfoKey 生成用户信息缓存键
|
||||
func UserInfoKey(userID string) string {
|
||||
return fmt.Sprintf("%s:%s", PrefixUserInfo, userID)
|
||||
|
||||
// ChannelsAllListKey 频道全量列表缓存键(含未启用,供 MapByIDs 解析历史帖子 channel)
|
||||
func ChannelsAllListKey() string {
|
||||
return PrefixChannelsAllList
|
||||
}
|
||||
|
||||
// UserMeKey 生成当前用户信息缓存键
|
||||
func UserMeKey(userID string) string {
|
||||
return fmt.Sprintf("%s:%s", PrefixUserMe, userID)
|
||||
// InvalidateChannelList 失效频道列表缓存(管理端增删改频道后调用)
|
||||
func InvalidateChannelList(c Cache) {
|
||||
c.Delete(ChannelsAllListKey())
|
||||
}
|
||||
|
||||
// InvalidatePostList 失效帖子列表缓存
|
||||
@@ -131,20 +138,12 @@ func InvalidateConversationList(cache Cache, userID string) {
|
||||
cache.DeleteByPrefix(PrefixConversationList + ":" + userID + ":")
|
||||
}
|
||||
|
||||
// InvalidateConversationDetail 失效会话详情缓存
|
||||
func InvalidateConversationDetail(cache Cache, conversationID, userID string) {
|
||||
cache.Delete(ConversationDetailKey(conversationID, userID))
|
||||
}
|
||||
|
||||
// InvalidateGroupMembers 失效群组成员缓存
|
||||
func InvalidateGroupMembers(cache Cache, groupID string) {
|
||||
cache.DeleteByPrefix(PrefixGroupMembers + ":" + groupID)
|
||||
}
|
||||
|
||||
// InvalidateGroupInfo 失效群组信息缓存
|
||||
func InvalidateGroupInfo(cache Cache, groupID string) {
|
||||
cache.Delete(GroupInfoKey(groupID))
|
||||
}
|
||||
|
||||
// InvalidateUnreadSystem 失效系统消息未读数缓存
|
||||
func InvalidateUnreadSystem(cache Cache, userID string) {
|
||||
@@ -161,11 +160,6 @@ func InvalidateUnreadDetail(cache Cache, userID, conversationID string) {
|
||||
cache.Delete(UnreadDetailKey(userID, conversationID))
|
||||
}
|
||||
|
||||
// InvalidateUserInfo 失效用户信息缓存
|
||||
func InvalidateUserInfo(cache Cache, userID string) {
|
||||
cache.Delete(UserInfoKey(userID))
|
||||
cache.Delete(UserMeKey(userID))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息缓存 Key 生成函数
|
||||
@@ -200,3 +194,20 @@ func MessagePageKey(convID string, page, pageSize int) string {
|
||||
func InvalidateMessagePages(cache Cache, conversationID string) {
|
||||
cache.DeleteByPrefix(fmt.Sprintf("%s:%s:", keyPrefixMsgPage, conversationID))
|
||||
}
|
||||
|
||||
// MessageIdempotentKey 消息幂等键 (clientMsgID -> server MsgID)
|
||||
func MessageIdempotentKey(senderID, clientMsgID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
|
||||
}
|
||||
|
||||
// PostCreateIdempotentKey 帖子创建幂等键 (userID + clientRequestID -> postID)
|
||||
// 用于在 SetNX 原子抢占期间识别同一客户端请求的重试,避免重复发帖。
|
||||
func PostCreateIdempotentKey(userID, clientRequestID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", keyPrefixPostIdempotent, userID, clientRequestID)
|
||||
}
|
||||
|
||||
// UserReadSeqKey 用户已读位置缓存键(OpenIM 风格 SEQ_USER_READ:{convID}:{userID})
|
||||
func UserReadSeqKey(convID, userID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", PrefixUserReadSeq, convID, userID)
|
||||
}
|
||||
|
||||
|
||||
117
internal/cache/layered_cache.go
vendored
117
internal/cache/layered_cache.go
vendored
@@ -1,16 +1,14 @@
|
||||
package cache
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
redislib "github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
redisPkg "carrot_bbs/internal/pkg/redis"
|
||||
redisPkg "with_you/internal/pkg/redis"
|
||||
)
|
||||
|
||||
type LayeredCache struct {
|
||||
@@ -111,7 +109,11 @@ func (c *LayeredCache) Get(key string) (any, bool) {
|
||||
c.mu.Unlock()
|
||||
|
||||
if ttl := c.getLocalTTL(); ttl > 0 {
|
||||
c.local.Set(key, val, ttl)
|
||||
if str, ok := val.(string); ok {
|
||||
c.local.SetRaw(key, []byte(str), ttl)
|
||||
} else {
|
||||
c.local.Set(key, val, ttl)
|
||||
}
|
||||
}
|
||||
return val, true
|
||||
}
|
||||
@@ -157,6 +159,23 @@ func (c *LayeredCache) DeleteByPrefix(prefix string) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -273,6 +292,14 @@ func (c *LayeredCache) HDel(ctx context.Context, key string, fields ...string) e
|
||||
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
|
||||
@@ -325,14 +352,27 @@ func (c *LayeredCache) ZCard(ctx context.Context, key string) (int64, error) {
|
||||
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.enabled || c.redis == nil {
|
||||
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
|
||||
@@ -375,59 +415,6 @@ func (c *LayeredCache) GetRedisClient() *redisPkg.Client {
|
||||
return c.redis.GetRedisClient()
|
||||
}
|
||||
|
||||
func GetTypedLayered[T any](c *LayeredCache, key string) (T, bool) {
|
||||
var zero T
|
||||
if !c.enabled {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
raw, ok := c.Get(key)
|
||||
if !ok {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
if typed, ok := raw.(T); ok {
|
||||
return typed, true
|
||||
}
|
||||
|
||||
if str, ok := raw.(string); ok {
|
||||
var out T
|
||||
if err := json.Unmarshal([]byte(str), &out); err != nil {
|
||||
return zero, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
if data, err := json.Marshal(raw); err == nil {
|
||||
var out T
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return zero, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
return zero, false
|
||||
}
|
||||
|
||||
func GetOrLoadLayered[T any](
|
||||
c *LayeredCache,
|
||||
key string,
|
||||
ttl time.Duration,
|
||||
loader func() (T, error),
|
||||
) (T, error) {
|
||||
if cached, ok := GetTypedLayered[T](c, key); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
loaded, err := loader()
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
|
||||
c.Set(key, loaded, ttl)
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
func (c *LayeredCache) InvalidateUserCache(userID uint) {
|
||||
c.DeleteByPrefix(cacheKeyPrefixUser + ":")
|
||||
@@ -472,15 +459,3 @@ func buildGroupMembersKey(groupID uint) string {
|
||||
}
|
||||
|
||||
var _ Cache = (*LayeredCache)(nil)
|
||||
|
||||
func (c *LayeredCache) logStats() {
|
||||
stats := c.GetStats()
|
||||
zap.L().Debug("LayeredCache stats",
|
||||
zap.Int64("local_hits", stats.LocalHits),
|
||||
zap.Int64("redis_hits", stats.RedisHits),
|
||||
zap.Int64("misses", stats.Misses),
|
||||
zap.Int64("sets", stats.Sets),
|
||||
zap.Int64("invalidates", stats.Invalidates),
|
||||
zap.Float64("hit_rate", c.GetHitRate()),
|
||||
)
|
||||
}
|
||||
|
||||
33
internal/cache/lru.go
vendored
33
internal/cache/lru.go
vendored
@@ -87,6 +87,39 @@ func (l *LRU) set(key string, value any, ttl time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
13
internal/cache/lru_striped.go
vendored
13
internal/cache/lru_striped.go
vendored
@@ -106,11 +106,20 @@ func (l *LRUStriped) Name() string {
|
||||
}
|
||||
|
||||
func (l *LRUStriped) DeleteMulti(keys []string) error {
|
||||
var err error
|
||||
for _, key := range keys {
|
||||
l.Delete(key)
|
||||
}
|
||||
return err
|
||||
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 {
|
||||
|
||||
19
internal/cache/metrics.go
vendored
19
internal/cache/metrics.go
vendored
@@ -14,25 +14,6 @@ type cacheMetrics struct {
|
||||
// metrics 全局缓存指标实例
|
||||
var metrics cacheMetrics
|
||||
|
||||
// MetricsSnapshot 指标快照
|
||||
type MetricsSnapshot struct {
|
||||
Hit int64
|
||||
Miss int64
|
||||
DecodeError int64
|
||||
SetError int64
|
||||
Invalidate int64
|
||||
}
|
||||
|
||||
// GetMetricsSnapshot 获取当前指标快照
|
||||
func GetMetricsSnapshot() MetricsSnapshot {
|
||||
return MetricsSnapshot{
|
||||
Hit: metrics.hit.Load(),
|
||||
Miss: metrics.miss.Load(),
|
||||
DecodeError: metrics.decodeError.Load(),
|
||||
SetError: metrics.setError.Load(),
|
||||
Invalidate: metrics.invalidate.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// recordHit 记录缓存命中
|
||||
func recordHit() {
|
||||
|
||||
54
internal/cache/redis_cache.go
vendored
54
internal/cache/redis_cache.go
vendored
@@ -1,4 +1,4 @@
|
||||
package cache
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
redislib "github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
redisPkg "carrot_bbs/internal/pkg/redis"
|
||||
redisPkg "with_you/internal/pkg/redis"
|
||||
)
|
||||
|
||||
// RedisCache Redis缓存实现
|
||||
@@ -26,7 +26,7 @@ func NewRedisCache(client *redisPkg.Client) *RedisCache {
|
||||
}
|
||||
|
||||
// Set 设置缓存值
|
||||
func (c *RedisCache) Set(key string, value interface{}, ttl time.Duration) {
|
||||
func (c *RedisCache) Set(key string, value any, ttl time.Duration) {
|
||||
key = normalizeKey(key)
|
||||
// 将值序列化为JSON
|
||||
data, err := json.Marshal(value)
|
||||
@@ -49,7 +49,7 @@ func (c *RedisCache) Set(key string, value interface{}, ttl time.Duration) {
|
||||
}
|
||||
|
||||
// Get 获取缓存值
|
||||
func (c *RedisCache) Get(key string) (interface{}, bool) {
|
||||
func (c *RedisCache) Get(key string) (any, bool) {
|
||||
key = normalizeKey(key)
|
||||
data, err := c.client.Get(c.ctx, key)
|
||||
if err != nil {
|
||||
@@ -82,7 +82,6 @@ func (c *RedisCache) Delete(key string) {
|
||||
// DeleteByPrefix 根据前缀删除缓存
|
||||
func (c *RedisCache) DeleteByPrefix(prefix string) {
|
||||
prefix = normalizePrefix(prefix)
|
||||
// 使用原生客户端执行SCAN命令
|
||||
rdb := c.client.GetClient()
|
||||
var cursor uint64
|
||||
for {
|
||||
@@ -112,6 +111,30 @@ func (c *RedisCache) DeleteByPrefix(prefix string) {
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteBatch 批量删除多个 key(使用 Redis Pipeline 减少网络往返)
|
||||
func (c *RedisCache) DeleteBatch(keys []string) {
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
normalizedKeys := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
normalizedKeys[i] = normalizeKey(k)
|
||||
}
|
||||
|
||||
recordInvalidateMultiple(int64(len(normalizedKeys)))
|
||||
// 使用 pipeline 批量删除
|
||||
pipe := c.client.GetClient().Pipeline()
|
||||
for _, k := range normalizedKeys {
|
||||
pipe.Del(c.ctx, k)
|
||||
}
|
||||
if _, err := pipe.Exec(c.ctx); err != nil {
|
||||
zap.L().Error("Failed to batch delete keys",
|
||||
zap.Int("count", len(normalizedKeys)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear 清空所有缓存
|
||||
func (c *RedisCache) Clear() {
|
||||
if settings.DisableFlushDB {
|
||||
@@ -164,13 +187,13 @@ func (c *RedisCache) IncrementBy(key string, value int64) int64 {
|
||||
// ==================== RedisCache Hash 操作 ====================
|
||||
|
||||
// HSet 设置 Hash 字段
|
||||
func (c *RedisCache) HSet(ctx context.Context, key string, field string, value interface{}) error {
|
||||
func (c *RedisCache) HSet(ctx context.Context, key string, field string, value any) error {
|
||||
key = normalizeKey(key)
|
||||
return c.client.HSet(ctx, key, field, value)
|
||||
}
|
||||
|
||||
// HMSet 批量设置 Hash 字段
|
||||
func (c *RedisCache) HMSet(ctx context.Context, key string, values map[string]interface{}) error {
|
||||
func (c *RedisCache) HMSet(ctx context.Context, key string, values map[string]any) error {
|
||||
key = normalizeKey(key)
|
||||
return c.client.HMSet(ctx, key, values)
|
||||
}
|
||||
@@ -182,7 +205,7 @@ func (c *RedisCache) HGet(ctx context.Context, key string, field string) (string
|
||||
}
|
||||
|
||||
// HMGet 批量获取 Hash 字段值
|
||||
func (c *RedisCache) HMGet(ctx context.Context, key string, fields ...string) ([]interface{}, error) {
|
||||
func (c *RedisCache) HMGet(ctx context.Context, key string, fields ...string) ([]any, error) {
|
||||
key = normalizeKey(key)
|
||||
return c.client.HMGet(ctx, key, fields...)
|
||||
}
|
||||
@@ -199,6 +222,12 @@ func (c *RedisCache) HDel(ctx context.Context, key string, fields ...string) err
|
||||
return c.client.HDel(ctx, key, fields...)
|
||||
}
|
||||
|
||||
// HIncrBy 原子递增 Hash 字段的值
|
||||
func (c *RedisCache) HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error) {
|
||||
key = normalizeKey(key)
|
||||
return c.client.HIncrBy(ctx, key, field, incr)
|
||||
}
|
||||
|
||||
// ==================== RedisCache Sorted Set 操作 ====================
|
||||
|
||||
// ZAdd 添加 Sorted Set 成员
|
||||
@@ -239,7 +268,7 @@ func (c *RedisCache) ZReplaceSortedSet(ctx context.Context, key string, members
|
||||
}
|
||||
|
||||
// ZRem 删除 Sorted Set 成员
|
||||
func (c *RedisCache) ZRem(ctx context.Context, key string, members ...interface{}) error {
|
||||
func (c *RedisCache) ZRem(ctx context.Context, key string, members ...any) error {
|
||||
key = normalizeKey(key)
|
||||
return c.client.ZRem(ctx, key, members...)
|
||||
}
|
||||
@@ -258,6 +287,13 @@ func (c *RedisCache) Incr(ctx context.Context, key string) (int64, error) {
|
||||
return c.client.Incr(ctx, key)
|
||||
}
|
||||
|
||||
// IncrBySeq 原子递增指定值(用于 seq 对齐)
|
||||
func (c *RedisCache) IncrBySeq(ctx context.Context, key string, delta int64) (int64, error) {
|
||||
key = normalizeKey(key)
|
||||
rdb := c.client.GetClient()
|
||||
return rdb.IncrBy(ctx, key, delta).Result()
|
||||
}
|
||||
|
||||
// Expire 设置过期时间
|
||||
func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
||||
key = normalizeKey(key)
|
||||
|
||||
19
internal/cache/repository_adapter.go
vendored
19
internal/cache/repository_adapter.go
vendored
@@ -1,17 +1,17 @@
|
||||
package cache
|
||||
package cache
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/repository"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/repository"
|
||||
)
|
||||
|
||||
// ConversationRepositoryAdapter 适配 MessageRepository 到 ConversationRepository 接口
|
||||
type ConversationRepositoryAdapter struct {
|
||||
repo *repository.MessageRepository
|
||||
repo repository.MessageRepository
|
||||
}
|
||||
|
||||
// NewConversationRepositoryAdapter 创建适配器
|
||||
func NewConversationRepositoryAdapter(repo *repository.MessageRepository) ConversationRepository {
|
||||
func NewConversationRepositoryAdapter(repo repository.MessageRepository) ConversationRepository {
|
||||
return &ConversationRepositoryAdapter{repo: repo}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,11 @@ func (a *ConversationRepositoryAdapter) GetUnreadCount(userID, convID string) (i
|
||||
|
||||
// MessageRepositoryAdapter 适配 MessageRepository 到 MessageRepository 接口
|
||||
type MessageRepositoryAdapter struct {
|
||||
repo *repository.MessageRepository
|
||||
repo repository.MessageRepository
|
||||
}
|
||||
|
||||
// NewMessageRepositoryAdapter 创建适配器
|
||||
func NewMessageRepositoryAdapter(repo *repository.MessageRepository) MessageRepository {
|
||||
func NewMessageRepositoryAdapter(repo repository.MessageRepository) MessageRepository {
|
||||
return &MessageRepositoryAdapter{repo: repo}
|
||||
}
|
||||
|
||||
@@ -74,3 +74,8 @@ func (a *MessageRepositoryAdapter) CreateMessage(msg *model.Message) error {
|
||||
func (a *MessageRepositoryAdapter) UpdateConversationLastSeq(convID string, seq int64) error {
|
||||
return a.repo.UpdateConversationLastSeq(convID, seq)
|
||||
}
|
||||
|
||||
// GetNextSeq 实现 MessageRepository 接口
|
||||
func (a *MessageRepositoryAdapter) GetNextSeq(convID string) (int64, error) {
|
||||
return a.repo.GetNextSeq(convID)
|
||||
}
|
||||
|
||||
105
internal/cache/seq_buffer.go
vendored
Normal file
105
internal/cache/seq_buffer.go
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
redisPkg "with_you/internal/pkg/redis"
|
||||
)
|
||||
|
||||
const seqKeyTTL = 24 * time.Hour
|
||||
|
||||
// SeqBufferConfig seq 预分配配置(保留结构以兼容配置文件,实际不再使用缓冲)
|
||||
type SeqBufferConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
PrivateBufferSize int `mapstructure:"private_buffer_size"`
|
||||
GroupBufferSize int `mapstructure:"group_buffer_size"`
|
||||
LockTimeoutMs int `mapstructure:"lock_timeout_ms"`
|
||||
MaxRetries int `mapstructure:"max_retries"`
|
||||
}
|
||||
|
||||
// SeqBufferManager seq 分配管理器(简化版:每条消息一个 Redis INCR,无本地缓冲)
|
||||
type SeqBufferManager struct {
|
||||
rdb *redis.Client
|
||||
config SeqBufferConfig
|
||||
repo ConversationRepository
|
||||
msgRepo MessageRepository
|
||||
}
|
||||
|
||||
// NewSeqBufferManager 创建 seq 分配管理器
|
||||
func NewSeqBufferManager(rdb *redis.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||
return &SeqBufferManager{
|
||||
rdb: rdb,
|
||||
config: cfg,
|
||||
repo: repo,
|
||||
msgRepo: msgRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNextSeq 获取下一个 seq(单次 Redis INCR,原子且无竞态)
|
||||
func (m *SeqBufferManager) GetNextSeq(ctx context.Context, convID string, _ bool) (int64, error) {
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
// 1. 尝试 INCR(key 存在时直接递增,原子操作)
|
||||
result, err := nextSeqLua.Run(ctx, m.rdb, []string{seqKey}).Int64()
|
||||
if err == nil {
|
||||
if result > 0 {
|
||||
m.rdb.Expire(ctx, seqKey, seqKeyTTL)
|
||||
return result, nil
|
||||
}
|
||||
// result == 0: 冷启动,需从 DB 初始化
|
||||
return m.initAndIncr(ctx, convID, seqKey)
|
||||
}
|
||||
|
||||
// Redis 不可用,降级到 DB
|
||||
zap.L().Warn("seq INCR failed, falling back to DB", zap.String("convID", convID), zap.Error(err))
|
||||
if m.msgRepo != nil {
|
||||
if dbSeq, dbErr := m.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||
return dbSeq, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("seq allocation failed for %s: %w", convID, err)
|
||||
}
|
||||
|
||||
// initAndIncr 冷启动:从 DB 加载 last_seq,然后用 SETNX+INCR 原子初始化
|
||||
func (m *SeqBufferManager) initAndIncr(ctx context.Context, convID, seqKey string) (int64, error) {
|
||||
if m.repo == nil {
|
||||
return 0, fmt.Errorf("conversation repository not configured for seq init")
|
||||
}
|
||||
|
||||
conv, err := m.repo.GetConversationByID(convID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("load conversation for seq init: %w", err)
|
||||
}
|
||||
|
||||
initVal := conv.LastSeq
|
||||
result, err := initSeqLua.Run(ctx, m.rdb, []string{seqKey}, initVal).Int64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("initSeqLua failed: %w", err)
|
||||
}
|
||||
|
||||
m.rdb.Expire(ctx, seqKey, seqKeyTTL)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// InvalidateBuffer no-op(保留接口兼容)
|
||||
func (m *SeqBufferManager) InvalidateBuffer(convID string) {}
|
||||
|
||||
// IsEnabled 是否启用
|
||||
func (m *SeqBufferManager) IsEnabled() bool {
|
||||
return m.config.Enabled && m.rdb != nil
|
||||
}
|
||||
|
||||
// NewSeqBufferManagerFromRedisPkg 从 pkg/redis.Client 创建 SeqBufferManager
|
||||
func NewSeqBufferManagerFromRedisPkg(redisPkgClient *redisPkg.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
|
||||
if redisPkgClient == nil {
|
||||
zap.L().Warn("SeqBuffer: redis client is nil, seq pre-allocation disabled")
|
||||
return &SeqBufferManager{config: cfg, repo: repo, msgRepo: msgRepo}
|
||||
}
|
||||
rdb := redisPkgClient.GetClient()
|
||||
return NewSeqBufferManager(rdb, cfg, repo, msgRepo)
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
package config
|
||||
package config
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,13 +19,26 @@ type Config struct {
|
||||
Log LogConfig `mapstructure:"log"`
|
||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||
Upload UploadConfig `mapstructure:"upload"`
|
||||
FileCleanup FileCleanupConfig `mapstructure:"file_cleanup"`
|
||||
DeviceCleanup DeviceCleanupConfig `mapstructure:"device_cleanup"`
|
||||
OpenAI OpenAIConfig `mapstructure:"openai"`
|
||||
TencentTMS TencentTMSConfig `mapstructure:"tencent_tms"`
|
||||
Email EmailConfig `mapstructure:"email"`
|
||||
ConversationCache ConversationCacheConfig `mapstructure:"conversation_cache"`
|
||||
GRPC GRPCConfig `mapstructure:"grpc"`
|
||||
Casbin CasbinConfig `mapstructure:"casbin"`
|
||||
Encryption EncryptionConfig `mapstructure:"encryption"`
|
||||
HotRank HotRankConfig `mapstructure:"hot_rank"`
|
||||
LiveKit LiveKitConfig `mapstructure:"livekit"`
|
||||
Report ReportConfig `mapstructure:"report"`
|
||||
Sensitive SensitiveConfig `mapstructure:"sensitive"`
|
||||
JPush JPushConfig `mapstructure:"jpush"`
|
||||
SetupSecret string `mapstructure:"setup_secret"`
|
||||
WebSocket WSConfig `mapstructure:"websocket"`
|
||||
Runner RunnerConfig `mapstructure:"runner"`
|
||||
SeqBuffer SeqBufferConfig `mapstructure:"seq_buffer"`
|
||||
VersionLog VersionLogConfig `mapstructure:"version_log"`
|
||||
PushWorker PushWorkerConfig `mapstructure:"push_worker"`
|
||||
}
|
||||
|
||||
// Load 加载配置文件
|
||||
@@ -47,13 +57,28 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("server.mode", "debug")
|
||||
viper.SetDefault("server.host", "0.0.0.0")
|
||||
viper.SetDefault("database.type", "sqlite")
|
||||
viper.SetDefault("database.sqlite.path", "./data/carrot_bbs.db")
|
||||
viper.SetDefault("database.sqlite.path", "./data/with_you.db")
|
||||
viper.SetDefault("database.max_idle_conns", 10)
|
||||
viper.SetDefault("database.max_open_conns", 100)
|
||||
viper.SetDefault("database.log_level", "warn")
|
||||
viper.SetDefault("database.slow_threshold_ms", 200)
|
||||
viper.SetDefault("database.ignore_record_not_found", true)
|
||||
viper.SetDefault("database.parameterized_queries", true)
|
||||
viper.SetDefault("database.replica_policy", "random")
|
||||
|
||||
// 读写分离:单副本字段显式 BindEnv。
|
||||
// AutomaticEnv 对 YAML 中未出现的嵌套 key(replica 段在 config.yaml 中默认被注释)
|
||||
// 不会反向推导,必须逐字段 BindEnv 才能让 APP_DATABASE_REPLICA_HOST 等环境变量生效。
|
||||
// 与 jpush.channel.* 处理方式一致(见下方 BindEnv 块)。
|
||||
viper.BindEnv("database.replica.host", "APP_DATABASE_REPLICA_HOST")
|
||||
viper.BindEnv("database.replica.port", "APP_DATABASE_REPLICA_PORT")
|
||||
viper.BindEnv("database.replica.user", "APP_DATABASE_REPLICA_USER")
|
||||
viper.BindEnv("database.replica.password", "APP_DATABASE_REPLICA_PASSWORD")
|
||||
viper.BindEnv("database.replica.dbname", "APP_DATABASE_REPLICA_DBNAME")
|
||||
viper.BindEnv("database.replica.sslmode", "APP_DATABASE_REPLICA_SSLMODE")
|
||||
// 副本连接池参数(可选,留空回退到主库连接池配置,见 database.go)
|
||||
viper.BindEnv("database.replica_max_idle_conns", "APP_DATABASE_REPLICA_MAX_IDLE_CONNS")
|
||||
viper.BindEnv("database.replica_max_open_conns", "APP_DATABASE_REPLICA_MAX_OPEN_CONNS")
|
||||
viper.SetDefault("redis.type", "miniredis")
|
||||
viper.SetDefault("redis.redis.host", "localhost")
|
||||
viper.SetDefault("redis.redis.port", 6379)
|
||||
@@ -86,6 +111,15 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("rate_limit.requests_per_minute", 60)
|
||||
viper.SetDefault("upload.max_file_size", 10485760)
|
||||
viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/gif", "image/webp"})
|
||||
// 文件过期清理默认值
|
||||
viper.SetDefault("file_cleanup.enabled", true)
|
||||
viper.SetDefault("file_cleanup.retention_days", 7)
|
||||
viper.SetDefault("file_cleanup.interval_minutes", 360)
|
||||
viper.SetDefault("file_cleanup.batch_size", 100)
|
||||
// 设备 registration_id 清理默认值(3 天不活跃即清理)
|
||||
viper.SetDefault("device_cleanup.enabled", true)
|
||||
viper.SetDefault("device_cleanup.retention_days", 3)
|
||||
viper.SetDefault("device_cleanup.interval_minutes", 360)
|
||||
viper.SetDefault("s3.endpoint", "")
|
||||
viper.SetDefault("s3.access_key", "")
|
||||
viper.SetDefault("s3.secret_key", "")
|
||||
@@ -95,6 +129,10 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("s3.domain", "")
|
||||
viper.SetDefault("sensitive.enabled", true)
|
||||
viper.SetDefault("sensitive.replace_str", "***")
|
||||
viper.SetDefault("sensitive.min_match_len", 2)
|
||||
viper.SetDefault("sensitive.load_from_db", true)
|
||||
viper.SetDefault("sensitive.load_from_redis", false)
|
||||
viper.SetDefault("sensitive.redis_key_prefix", "sensitive_words")
|
||||
viper.SetDefault("audit.enabled", false)
|
||||
viper.SetDefault("audit.provider", "local")
|
||||
viper.SetDefault("openai.enabled", true)
|
||||
@@ -104,13 +142,19 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("openai.moderation_max_images_per_request", 1)
|
||||
viper.SetDefault("openai.request_timeout", 30)
|
||||
viper.SetDefault("openai.strict_moderation", false)
|
||||
viper.SetDefault("tencent_tms.enabled", false)
|
||||
viper.SetDefault("tencent_tms.secret_id", "")
|
||||
viper.SetDefault("tencent_tms.secret_key", "")
|
||||
viper.SetDefault("tencent_tms.region", "ap-guangzhou")
|
||||
viper.SetDefault("tencent_tms.biz_type", "")
|
||||
viper.SetDefault("tencent_tms.timeout", 30)
|
||||
viper.SetDefault("email.enabled", false)
|
||||
viper.SetDefault("email.host", "")
|
||||
viper.SetDefault("email.port", 587)
|
||||
viper.SetDefault("email.username", "")
|
||||
viper.SetDefault("email.password", "")
|
||||
viper.SetDefault("email.from_address", "")
|
||||
viper.SetDefault("email.from_name", "Carrot BBS")
|
||||
viper.SetDefault("email.from_name", "WithYou")
|
||||
viper.SetDefault("email.use_tls", true)
|
||||
viper.SetDefault("email.insecure_skip_verify", false)
|
||||
viper.SetDefault("email.timeout", 15)
|
||||
@@ -133,7 +177,6 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("grpc.tls_enabled", false)
|
||||
viper.SetDefault("grpc.tls_cert_file", "")
|
||||
viper.SetDefault("grpc.tls_key_file", "")
|
||||
// Casbin 默认值
|
||||
viper.SetDefault("casbin.model_path", "./configs/casbin/model.conf")
|
||||
viper.SetDefault("casbin.auto_save", true)
|
||||
viper.SetDefault("casbin.auto_load", true)
|
||||
@@ -150,6 +193,136 @@ func Load(configPath string) (*Config, error) {
|
||||
viper.SetDefault("hot_rank.recent_window_hours", 168)
|
||||
viper.SetDefault("hot_rank.recent_fetch_limit", 500)
|
||||
viper.SetDefault("hot_rank.candidate_cap", 800)
|
||||
// Report 默认值
|
||||
viper.SetDefault("report.auto_hide_threshold", 3)
|
||||
viper.SetDefault("jpush.enabled", false)
|
||||
viper.SetDefault("jpush.app_key", "")
|
||||
viper.SetDefault("jpush.master_secret", "")
|
||||
viper.SetDefault("jpush.production", false)
|
||||
// JPush 厂商通道 channel_id 默认值
|
||||
// 各厂商 channel_id 通过环境变量配置(见下方 BindEnv)
|
||||
viper.SetDefault("jpush.channel.system", "")
|
||||
viper.SetDefault("jpush.channel.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.system", "153609")
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.chat", "153608")
|
||||
viper.SetDefault("jpush.channel.vendor.huawei.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.huawei.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.vivo.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.vivo.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.meizu.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.meizu.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.honor.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.honor.chat", "")
|
||||
viper.SetDefault("jpush.channel.vendor.fcm.system", "")
|
||||
viper.SetDefault("jpush.channel.vendor.fcm.chat", "")
|
||||
// 小米/OPPO 模板 id 默认值
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.mi_system_template_id", "P10761")
|
||||
viper.SetDefault("jpush.channel.vendor.xiaomi.mi_chat_template_id", "M10289")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_system_private_template_id", "")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_chat_private_template_id", "")
|
||||
// OPPO 消息分类默认值(category / notify_level)
|
||||
// OPPO 消息分类默认值(2024.11.20 新规)
|
||||
// chat: category=IM(即时通讯), notify_level=16(通知栏+锁屏+横幅+震动+铃声,强提醒)
|
||||
// system: category=ACCOUNT(账号动态/互动通知), notify_level=2(通知栏+锁屏,避免打扰)
|
||||
// 注:使用 notify_level 时 category 必传,此处两者同时配置
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_system_category", "ACCOUNT")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_chat_category", "IM")
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_system_notify_level", 2)
|
||||
viper.SetDefault("jpush.channel.vendor.oppo.oppo_chat_notify_level", 16)
|
||||
// 荣耀 importance 默认值
|
||||
// chat: NORMAL(服务与通讯,及时送达), system: LOW(资讯营销,避免打扰)
|
||||
// 注:classification 优先级更高,会覆盖 importance
|
||||
viper.SetDefault("jpush.channel.vendor.honor.honor_system_importance", "LOW")
|
||||
viper.SetDefault("jpush.channel.vendor.honor.honor_chat_importance", "NORMAL")
|
||||
// vivo category 默认值(classification=1 时必须为系统消息类)
|
||||
// chat: IM(即时通讯), system: ACCOUNT(账号动态/互动通知)
|
||||
viper.SetDefault("jpush.channel.vendor.vivo.vivo_system_category", "ACCOUNT")
|
||||
viper.SetDefault("jpush.channel.vendor.vivo.vivo_chat_category", "IM")
|
||||
// iOS 通知分组 thread-id 默认值(留空,不分组)
|
||||
viper.SetDefault("jpush.channel.ios.chat_thread_id", "")
|
||||
viper.SetDefault("jpush.channel.ios.system_thread_id", "")
|
||||
// JPush 厂商 channel_id 环境变量显式绑定
|
||||
// AutomaticEnv 对深层嵌套 key 不可靠,故逐个 BindEnv
|
||||
// 命名规则:APP_JPUSH_CHANNEL_{VENDOR}_{SYSTEM|CHAT}
|
||||
viper.BindEnv("jpush.channel.system", "APP_JPUSH_CHANNEL_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.chat", "APP_JPUSH_CHANNEL_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.system", "APP_JPUSH_CHANNEL_XIAOMI_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.chat", "APP_JPUSH_CHANNEL_XIAOMI_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.huawei.system", "APP_JPUSH_CHANNEL_HUAWEI_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.huawei.chat", "APP_JPUSH_CHANNEL_HUAWEI_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.system", "APP_JPUSH_CHANNEL_OPPO_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.chat", "APP_JPUSH_CHANNEL_OPPO_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.vivo.system", "APP_JPUSH_CHANNEL_VIVO_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.vivo.chat", "APP_JPUSH_CHANNEL_VIVO_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.meizu.system", "APP_JPUSH_CHANNEL_MEIZU_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.meizu.chat", "APP_JPUSH_CHANNEL_MEIZU_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.honor.system", "APP_JPUSH_CHANNEL_HONOR_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.honor.chat", "APP_JPUSH_CHANNEL_HONOR_CHAT")
|
||||
viper.BindEnv("jpush.channel.vendor.fcm.system", "APP_JPUSH_CHANNEL_FCM_SYSTEM")
|
||||
viper.BindEnv("jpush.channel.vendor.fcm.chat", "APP_JPUSH_CHANNEL_FCM_CHAT")
|
||||
// 小米/OPPO 模板 id 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.mi_system_template_id", "APP_JPUSH_CHANNEL_XIAOMI_MI_SYSTEM_TEMPLATE_ID")
|
||||
viper.BindEnv("jpush.channel.vendor.xiaomi.mi_chat_template_id", "APP_JPUSH_CHANNEL_XIAOMI_MI_CHAT_TEMPLATE_ID")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_system_private_template_id", "APP_JPUSH_CHANNEL_OPPO_SYSTEM_PRIVATE_TEMPLATE_ID")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_chat_private_template_id", "APP_JPUSH_CHANNEL_OPPO_CHAT_PRIVATE_TEMPLATE_ID")
|
||||
// OPPO category / notify_level 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_system_category", "APP_JPUSH_CHANNEL_OPPO_SYSTEM_CATEGORY")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_chat_category", "APP_JPUSH_CHANNEL_OPPO_CHAT_CATEGORY")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_system_notify_level", "APP_JPUSH_CHANNEL_OPPO_SYSTEM_NOTIFY_LEVEL")
|
||||
viper.BindEnv("jpush.channel.vendor.oppo.oppo_chat_notify_level", "APP_JPUSH_CHANNEL_OPPO_CHAT_NOTIFY_LEVEL")
|
||||
// 荣耀 importance 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.vendor.honor.honor_system_importance", "APP_JPUSH_CHANNEL_HONOR_SYSTEM_IMPORTANCE")
|
||||
viper.BindEnv("jpush.channel.vendor.honor.honor_chat_importance", "APP_JPUSH_CHANNEL_HONOR_CHAT_IMPORTANCE")
|
||||
// vivo category 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.vendor.vivo.vivo_system_category", "APP_JPUSH_CHANNEL_VIVO_SYSTEM_CATEGORY")
|
||||
viper.BindEnv("jpush.channel.vendor.vivo.vivo_chat_category", "APP_JPUSH_CHANNEL_VIVO_CHAT_CATEGORY")
|
||||
// iOS thread-id 环境变量绑定
|
||||
viper.BindEnv("jpush.channel.ios.chat_thread_id", "APP_JPUSH_CHANNEL_IOS_CHAT_THREAD_ID")
|
||||
viper.BindEnv("jpush.channel.ios.system_thread_id", "APP_JPUSH_CHANNEL_IOS_SYSTEM_THREAD_ID")
|
||||
viper.SetDefault("setup_secret", "")
|
||||
// WebSocket 默认值
|
||||
viper.SetDefault("websocket.mode", "standalone")
|
||||
viper.SetDefault("websocket.cluster.instance_id", "")
|
||||
viper.SetDefault("websocket.cluster.msg_channel", "ws:msg")
|
||||
viper.SetDefault("websocket.cluster.online_ttl", 60)
|
||||
viper.SetDefault("websocket.cluster.heartbeat_interval", 20)
|
||||
// Runner 集群默认值(mode 和 instance_id 空则跟随 websocket)
|
||||
viper.SetDefault("runner.mode", "")
|
||||
viper.SetDefault("runner.cluster.instance_id", "")
|
||||
viper.SetDefault("runner.cluster.task_channel", "runner:task")
|
||||
viper.SetDefault("runner.cluster.result_channel", "runner:result")
|
||||
viper.SetDefault("runner.cluster.registry_prefix", "runner:reg:")
|
||||
viper.SetDefault("runner.cluster.cap_prefix", "runner:cap:")
|
||||
viper.SetDefault("runner.cluster.registry_ttl", 90)
|
||||
viper.SetDefault("runner.cluster.heartbeat_interval", 30)
|
||||
// SeqBuffer 默认值
|
||||
viper.SetDefault("seq_buffer.enabled", false)
|
||||
viper.SetDefault("seq_buffer.private_buffer_size", 50)
|
||||
viper.SetDefault("seq_buffer.group_buffer_size", 200)
|
||||
viper.SetDefault("seq_buffer.lock_timeout_ms", 5000)
|
||||
viper.SetDefault("seq_buffer.max_retries", 3)
|
||||
// VersionLog 默认值
|
||||
viper.SetDefault("version_log.enabled", false)
|
||||
viper.SetDefault("version_log.sync_limit", 100)
|
||||
viper.SetDefault("version_log.max_sync_gap", 1000)
|
||||
// PushWorker 默认值
|
||||
viper.SetDefault("push_worker.enabled", false)
|
||||
viper.SetDefault("push_worker.stream", "msg_push")
|
||||
viper.SetDefault("push_worker.group", "push_worker")
|
||||
viper.SetDefault("push_worker.batch_size", 50)
|
||||
viper.SetDefault("push_worker.poll_timeout_ms", 2000)
|
||||
viper.SetDefault("push_worker.max_retries", 3)
|
||||
viper.SetDefault("push_worker.max_stream_len", 100000)
|
||||
viper.SetDefault("push_worker.idle_timeout_ms", 30000)
|
||||
// LiveKit 默认值
|
||||
viper.SetDefault("livekit.enabled", false)
|
||||
viper.SetDefault("livekit.url", "http://localhost:7880")
|
||||
viper.SetDefault("livekit.api_key", "devkey")
|
||||
viper.SetDefault("livekit.api_secret", "")
|
||||
viper.SetDefault("livekit.token_ttl", 600)
|
||||
viper.SetDefault("livekit.webhook_secret", "")
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
@@ -160,85 +333,26 @@ func Load(configPath string) (*Config, error) {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Runner mode 和 instance_id 空则跟随 WebSocket 配置
|
||||
cfg.Runner.ApplyWSDefaults(cfg.WebSocket)
|
||||
|
||||
// Convert seconds to duration
|
||||
cfg.JWT.AccessTokenExpire = time.Duration(viper.GetInt("jwt.access_token_expire")) * time.Second
|
||||
cfg.JWT.RefreshTokenExpire = time.Duration(viper.GetInt("jwt.refresh_token_expire")) * time.Second
|
||||
|
||||
// 环境变量覆盖(显式处理敏感配置)
|
||||
cfg.JWT.Secret = getEnvOrDefault("APP_JWT_SECRET", cfg.JWT.Secret)
|
||||
cfg.Database.SQLite.Path = getEnvOrDefault("APP_DATABASE_SQLITE_PATH", cfg.Database.SQLite.Path)
|
||||
cfg.Database.Postgres.Host = getEnvOrDefault("APP_DATABASE_POSTGRES_HOST", cfg.Database.Postgres.Host)
|
||||
cfg.Database.Postgres.Port, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_POSTGRES_PORT", fmt.Sprintf("%d", cfg.Database.Postgres.Port)))
|
||||
cfg.Database.Postgres.User = getEnvOrDefault("APP_DATABASE_POSTGRES_USER", cfg.Database.Postgres.User)
|
||||
cfg.Database.Postgres.Password = getEnvOrDefault("APP_DATABASE_POSTGRES_PASSWORD", cfg.Database.Postgres.Password)
|
||||
cfg.Database.Postgres.DBName = getEnvOrDefault("APP_DATABASE_POSTGRES_DBNAME", cfg.Database.Postgres.DBName)
|
||||
cfg.Database.LogLevel = getEnvOrDefault("APP_DATABASE_LOG_LEVEL", cfg.Database.LogLevel)
|
||||
cfg.Database.SlowThresholdMs, _ = strconv.Atoi(getEnvOrDefault("APP_DATABASE_SLOW_THRESHOLD_MS", fmt.Sprintf("%d", cfg.Database.SlowThresholdMs)))
|
||||
cfg.Database.IgnoreRecordNotFound, _ = strconv.ParseBool(getEnvOrDefault("APP_DATABASE_IGNORE_RECORD_NOT_FOUND", fmt.Sprintf("%t", cfg.Database.IgnoreRecordNotFound)))
|
||||
cfg.Database.ParameterizedQueries, _ = strconv.ParseBool(getEnvOrDefault("APP_DATABASE_PARAMETERIZED_QUERIES", fmt.Sprintf("%t", cfg.Database.ParameterizedQueries)))
|
||||
cfg.Redis.Redis.Host = getEnvOrDefault("APP_REDIS_REDIS_HOST", cfg.Redis.Redis.Host)
|
||||
cfg.Redis.Redis.Port, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_REDIS_PORT", fmt.Sprintf("%d", cfg.Redis.Redis.Port)))
|
||||
cfg.Redis.Redis.Password = getEnvOrDefault("APP_REDIS_REDIS_PASSWORD", cfg.Redis.Redis.Password)
|
||||
cfg.Redis.Redis.DB, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_REDIS_DB", fmt.Sprintf("%d", cfg.Redis.Redis.DB)))
|
||||
cfg.Redis.Miniredis.Host = getEnvOrDefault("APP_REDIS_MINIREDIS_HOST", cfg.Redis.Miniredis.Host)
|
||||
cfg.Redis.Miniredis.Port, _ = strconv.Atoi(getEnvOrDefault("APP_REDIS_MINIREDIS_PORT", fmt.Sprintf("%d", cfg.Redis.Miniredis.Port)))
|
||||
cfg.Redis.Type = getEnvOrDefault("APP_REDIS_TYPE", cfg.Redis.Type)
|
||||
cfg.Cache.KeyPrefix = getEnvOrDefault("APP_CACHE_KEY_PREFIX", cfg.Cache.KeyPrefix)
|
||||
cfg.Cache.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_CACHE_ENABLED", fmt.Sprintf("%t", cfg.Cache.Enabled)))
|
||||
cfg.Cache.DisableFlushDB, _ = strconv.ParseBool(getEnvOrDefault("APP_CACHE_DISABLE_FLUSHDB", fmt.Sprintf("%t", cfg.Cache.DisableFlushDB)))
|
||||
cfg.Cache.DefaultTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_DEFAULT_TTL", fmt.Sprintf("%d", cfg.Cache.DefaultTTL)))
|
||||
cfg.Cache.NullTTL, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_NULL_TTL", fmt.Sprintf("%d", cfg.Cache.NullTTL)))
|
||||
cfg.Cache.JitterRatio, _ = strconv.ParseFloat(getEnvOrDefault("APP_CACHE_JITTER_RATIO", fmt.Sprintf("%.2f", cfg.Cache.JitterRatio)), 64)
|
||||
cfg.Cache.Modules.PostList, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_POST_LIST_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.PostList)))
|
||||
cfg.Cache.Modules.Conversation, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_CONVERSATION_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.Conversation)))
|
||||
cfg.Cache.Modules.UnreadCount, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_UNREAD_COUNT_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.UnreadCount)))
|
||||
cfg.Cache.Modules.GroupMembers, _ = strconv.Atoi(getEnvOrDefault("APP_CACHE_MODULES_GROUP_MEMBERS_TTL", fmt.Sprintf("%d", cfg.Cache.Modules.GroupMembers)))
|
||||
cfg.S3.Endpoint = getEnvOrDefault("APP_S3_ENDPOINT", cfg.S3.Endpoint)
|
||||
cfg.S3.AccessKey = getEnvOrDefault("APP_S3_ACCESS_KEY", cfg.S3.AccessKey)
|
||||
cfg.S3.SecretKey = getEnvOrDefault("APP_S3_SECRET_KEY", cfg.S3.SecretKey)
|
||||
cfg.S3.Bucket = getEnvOrDefault("APP_S3_BUCKET", cfg.S3.Bucket)
|
||||
cfg.S3.Domain = getEnvOrDefault("APP_S3_DOMAIN", cfg.S3.Domain)
|
||||
cfg.Server.Host = getEnvOrDefault("APP_SERVER_HOST", cfg.Server.Host)
|
||||
cfg.Server.Port, _ = strconv.Atoi(getEnvOrDefault("APP_SERVER_PORT", fmt.Sprintf("%d", cfg.Server.Port)))
|
||||
cfg.Server.Mode = getEnvOrDefault("APP_SERVER_MODE", cfg.Server.Mode)
|
||||
cfg.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)))
|
||||
// Encryption 环境变量覆盖
|
||||
cfg.Encryption.Enabled, _ = strconv.ParseBool(getEnvOrDefault("APP_ENCRYPTION_ENABLED", fmt.Sprintf("%t", cfg.Encryption.Enabled)))
|
||||
cfg.Encryption.Key = getEnvOrDefault("APP_ENCRYPTION_KEY", cfg.Encryption.Key)
|
||||
cfg.Encryption.KeyVersion, _ = strconv.Atoi(getEnvOrDefault("APP_ENCRYPTION_KEY_VERSION", fmt.Sprintf("%d", cfg.Encryption.KeyVersion)))
|
||||
// Viper AutomaticEnv 已自动处理所有 APP_ 前缀的环境变量
|
||||
|
||||
// 安全检查:生产模式必须设置JWT密钥
|
||||
if cfg.Server.Mode != "debug" {
|
||||
if cfg.JWT.Secret == "" || cfg.JWT.Secret == "your-jwt-secret-key-change-in-production" {
|
||||
return nil, fmt.Errorf("SECURITY ERROR: JWT secret must be set via APP_JWT_SECRET environment variable in production mode")
|
||||
}
|
||||
if len(cfg.JWT.Secret) < 32 {
|
||||
return nil, fmt.Errorf("SECURITY ERROR: JWT secret must be at least 32 characters long in production mode")
|
||||
}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// getEnvOrDefault 获取环境变量值,如果未设置则返回默认值
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
return cmp.Or(os.Getenv(key), defaultValue)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,22 @@ import "fmt"
|
||||
|
||||
// DatabaseConfig 数据库配置
|
||||
type DatabaseConfig struct {
|
||||
Type string `mapstructure:"type"`
|
||||
SQLite SQLiteConfig `mapstructure:"sqlite"`
|
||||
Postgres PostgresConfig `mapstructure:"postgres"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
LogLevel string `mapstructure:"log_level"`
|
||||
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
|
||||
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
|
||||
ParameterizedQueries bool `mapstructure:"parameterized_queries"`
|
||||
Type string `mapstructure:"type"`
|
||||
SQLite SQLiteConfig `mapstructure:"sqlite"`
|
||||
Postgres PostgresConfig `mapstructure:"postgres"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
LogLevel string `mapstructure:"log_level"`
|
||||
SlowThresholdMs int `mapstructure:"slow_threshold_ms"`
|
||||
IgnoreRecordNotFound bool `mapstructure:"ignore_record_not_found"`
|
||||
ParameterizedQueries bool `mapstructure:"parameterized_queries"`
|
||||
// 读写分离:单副本配置(支持环境变量,如 APP_DATABASE_REPLICA_HOST)
|
||||
Replica PostgresConfig `mapstructure:"replica"`
|
||||
// 读写分离:多副本配置(仅 YAML 支持,环境变量无法覆盖切片结构体)
|
||||
Replicas []PostgresConfig `mapstructure:"replicas"`
|
||||
ReplicaPolicy string `mapstructure:"replica_policy"`
|
||||
ReplicaMaxIdle int `mapstructure:"replica_max_idle_conns"`
|
||||
ReplicaMaxOpen int `mapstructure:"replica_max_open_conns"`
|
||||
}
|
||||
|
||||
// SQLiteConfig SQLite 配置
|
||||
@@ -20,7 +27,7 @@ type SQLiteConfig struct {
|
||||
Path string `mapstructure:"path"`
|
||||
}
|
||||
|
||||
// PostgresConfig PostgreSQL 配置
|
||||
// PostgresConfig PostgreSQL 配置(主库和副本共用)
|
||||
type PostgresConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
@@ -37,3 +44,18 @@ func (d PostgresConfig) DSN() string {
|
||||
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
|
||||
)
|
||||
}
|
||||
|
||||
// HasReplica 返回是否配置了读副本(单副本或多副本)
|
||||
func (d DatabaseConfig) HasReplica() bool {
|
||||
return d.Replica.Host != "" || len(d.Replicas) > 0
|
||||
}
|
||||
|
||||
// AllReplicas 返回所有读副本配置(合并单副本 + 多副本)
|
||||
func (d DatabaseConfig) AllReplicas() []PostgresConfig {
|
||||
var result []PostgresConfig
|
||||
if d.Replica.Host != "" {
|
||||
result = append(result, d.Replica)
|
||||
}
|
||||
result = append(result, d.Replicas...)
|
||||
return result
|
||||
}
|
||||
@@ -24,3 +24,13 @@ type OpenAIConfig struct {
|
||||
RequestTimeout int `mapstructure:"request_timeout"`
|
||||
StrictModeration bool `mapstructure:"strict_moderation"`
|
||||
}
|
||||
|
||||
// TencentTMSConfig 腾讯云文本内容安全配置(作为 OpenAI 审核的备用)
|
||||
type TencentTMSConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
SecretID string `mapstructure:"secret_id"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Region string `mapstructure:"region"`
|
||||
BizType string `mapstructure:"biz_type"`
|
||||
Timeout int `mapstructure:"timeout"`
|
||||
}
|
||||
|
||||
83
internal/config/jpush.go
Normal file
83
internal/config/jpush.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
type JPushConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
AppKey string `mapstructure:"app_key"`
|
||||
MasterSecret string `mapstructure:"master_secret"`
|
||||
Production bool `mapstructure:"production"`
|
||||
// Channel 厂商通道 channel_id 配置
|
||||
// 通过 options.third_party_channel 下发到各厂商(小米/华为/OPPO 等)
|
||||
Channel ChannelConfig `mapstructure:"channel"`
|
||||
}
|
||||
|
||||
// ChannelConfig 厂商通道 channel_id 配置
|
||||
// 区分「系统消息」与「私聊/群聊消息」两类 channel
|
||||
type ChannelConfig struct {
|
||||
// System 系统消息/通知默认 channel_id,未单独配置 Vendor 时各厂商共用
|
||||
System string `mapstructure:"system"`
|
||||
// Chat 私聊/群聊消息默认 channel_id,未单独配置 Vendor 时各厂商共用
|
||||
Chat string `mapstructure:"chat"`
|
||||
// Vendor 各厂商 channel_id 覆盖配置(留空则回退到 System/Chat)
|
||||
Vendor VendorChannel `mapstructure:"vendor"`
|
||||
// iOS APNs 通知场景区分配置(按 thread-id 分组)
|
||||
IOS IOSChannelConfig `mapstructure:"ios"`
|
||||
}
|
||||
|
||||
// IOSChannelConfig iOS APNs 通知场景区分配置
|
||||
// iOS 走 APNs 无 channel_id 概念,通过 thread-id 实现通知分组
|
||||
type IOSChannelConfig struct {
|
||||
// ChatThreadID 私聊/群聊消息通知分组 thread-id
|
||||
ChatThreadID string `mapstructure:"chat_thread_id"`
|
||||
// SystemThreadID 系统消息/通知分组 thread-id
|
||||
SystemThreadID string `mapstructure:"system_thread_id"`
|
||||
}
|
||||
|
||||
// VendorChannel 各厂商通道 channel_id 覆盖
|
||||
// 每个厂商区分 System(系统消息)与 Chat(私聊消息)两个 channel_id
|
||||
type VendorChannel struct {
|
||||
Xiaomi VendorChannelID `mapstructure:"xiaomi"`
|
||||
Huawei VendorChannelID `mapstructure:"huawei"`
|
||||
OPPO VendorChannelID `mapstructure:"oppo"`
|
||||
VIVO VendorChannelID `mapstructure:"vivo"`
|
||||
Meizu VendorChannelID `mapstructure:"meizu"`
|
||||
Honor VendorChannelID `mapstructure:"honor"`
|
||||
FCM VendorChannelID `mapstructure:"fcm"`
|
||||
}
|
||||
|
||||
// VendorChannelID 单个厂商的两类 channel 配置
|
||||
// 每个 channel 可携带厂商私有模板配置(如小米 mi_template_id、OPPO private_msg_template_id)
|
||||
type VendorChannelID struct {
|
||||
// channel_id
|
||||
System string `mapstructure:"system"`
|
||||
Chat string `mapstructure:"chat"`
|
||||
|
||||
// 小米消息模板(可选,配置后私信消息下发时携带 channel_id 及 mi_template_id)
|
||||
// 见:小米关于消息模板推送新规的更新通知
|
||||
MiSystemTemplateID string `mapstructure:"mi_system_template_id"` // 系统消息模板 id
|
||||
MiChatTemplateID string `mapstructure:"mi_chat_template_id"` // 私聊消息模板 id
|
||||
|
||||
// OPPO 私信模板(可选,仅 OPPO 厂商,配置后下发私信时携带)
|
||||
// 见:OPUSH 私信模版校验能力接入说明
|
||||
OppoSystemPrivateTemplateID string `mapstructure:"oppo_system_private_template_id"` // 系统消息私信模板 id
|
||||
OppoChatPrivateTemplateID string `mapstructure:"oppo_chat_private_template_id"` // 私聊消息私信模板 id
|
||||
|
||||
// OPPO 消息分类(可选,2024.11.20 新规)
|
||||
// category 消息场景标识,使用 notify_level 时必传;notify_level 提醒等级
|
||||
// 取值:category 如 IM/ACCOUNT;notify_level 1=通知栏, 2=通知栏+锁屏, 16=通知栏+锁屏+横幅+震动+铃声
|
||||
OppoSystemCategory string `mapstructure:"oppo_system_category"` // 系统消息 category(如 ACCOUNT)
|
||||
OppoChatCategory string `mapstructure:"oppo_chat_category"` // 私聊消息 category(如 IM)
|
||||
OppoSystemNotifyLevel int `mapstructure:"oppo_system_notify_level"` // 系统消息提醒等级(如 2)
|
||||
OppoChatNotifyLevel int `mapstructure:"oppo_chat_notify_level"` // 私聊消息提醒等级(如 16)
|
||||
|
||||
// 荣耀通知栏消息智能分类(可选,importance 字段)
|
||||
// 取值:"NORMAL"=服务与通讯,"LOW"=资讯营销
|
||||
// 注:classification 优先级更高,会覆盖 importance 设置的值
|
||||
HonorSystemImportance string `mapstructure:"honor_system_importance"` // 系统消息 importance(如 LOW)
|
||||
HonorChatImportance string `mapstructure:"honor_chat_importance"` // 私聊消息 importance(如 NORMAL)
|
||||
|
||||
// vivo 厂商消息场景标识(可选,category 字段)
|
||||
// 用于标识消息类型,确定提醒方式;classification=1 时 category 必须为系统消息类
|
||||
// 不携带 category 会默认按运营消息下发,受频控限制
|
||||
VivoSystemCategory string `mapstructure:"vivo_system_category"` // 系统消息 category(如 ACCOUNT 账号动态)
|
||||
VivoChatCategory string `mapstructure:"vivo_chat_category"` // 私聊消息 category(如 IM 即时通讯)
|
||||
}
|
||||
82
internal/config/jpush_default_test.go
Normal file
82
internal/config/jpush_default_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestXiaomiDefaultValuesFromViper 验证:config.yaml 中小米字段留空时,
|
||||
// viper.SetDefault 设置的默认值(153608/153609 + M10289/P10761)能正确填充到 Config 结构体。
|
||||
// 这证明:不配置环境变量/yaml 值时,代码内置默认值生效。
|
||||
func TestXiaomiDefaultValuesFromViper(t *testing.T) {
|
||||
// 加载仓库内的 config.yaml(小米字段已留空,依赖代码默认值)
|
||||
yamlPath := filepath.Join("..", "..", "configs", "config.yaml")
|
||||
cfg, err := Load(yamlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load config failed: %v", err)
|
||||
}
|
||||
|
||||
x := cfg.JPush.Channel.Vendor.Xiaomi
|
||||
if x.System != "153609" {
|
||||
t.Errorf("xiaomi.system default = %q, want 153609", x.System)
|
||||
}
|
||||
if x.Chat != "153608" {
|
||||
t.Errorf("xiaomi.chat default = %q, want 153608", x.Chat)
|
||||
}
|
||||
if x.MiSystemTemplateID != "P10761" {
|
||||
t.Errorf("xiaomi.mi_system_template_id default = %q, want P10761", x.MiSystemTemplateID)
|
||||
}
|
||||
if x.MiChatTemplateID != "M10289" {
|
||||
t.Errorf("xiaomi.mi_chat_template_id default = %q, want M10289", x.MiChatTemplateID)
|
||||
}
|
||||
t.Logf("小米默认值生效: system=%s chat=%s mi_sys=%s mi_chat=%s",
|
||||
x.System, x.Chat, x.MiSystemTemplateID, x.MiChatTemplateID)
|
||||
}
|
||||
|
||||
// TestVendorDefaultValuesFromViper 验证:OPPO/vivo/荣耀的分类字段在 yaml 留空时,
|
||||
// viper.SetDefault 设置的默认值能正确填充到 Config 结构体。
|
||||
// channel_id 类字段需厂商后台注册,不在默认值范围(留空)。
|
||||
func TestVendorDefaultValuesFromViper(t *testing.T) {
|
||||
yamlPath := filepath.Join("..", "..", "configs", "config.yaml")
|
||||
cfg, err := Load(yamlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load config failed: %v", err)
|
||||
}
|
||||
|
||||
// OPPO: chat=IM/16, system=ACCOUNT/2
|
||||
oppo := cfg.JPush.Channel.Vendor.OPPO
|
||||
if oppo.OppoChatCategory != "IM" {
|
||||
t.Errorf("oppo.oppo_chat_category default = %q, want IM", oppo.OppoChatCategory)
|
||||
}
|
||||
if oppo.OppoSystemCategory != "ACCOUNT" {
|
||||
t.Errorf("oppo.oppo_system_category default = %q, want ACCOUNT", oppo.OppoSystemCategory)
|
||||
}
|
||||
if oppo.OppoChatNotifyLevel != 16 {
|
||||
t.Errorf("oppo.oppo_chat_notify_level default = %d, want 16", oppo.OppoChatNotifyLevel)
|
||||
}
|
||||
if oppo.OppoSystemNotifyLevel != 2 {
|
||||
t.Errorf("oppo.oppo_system_notify_level default = %d, want 2", oppo.OppoSystemNotifyLevel)
|
||||
}
|
||||
|
||||
// vivo: chat=IM, system=ACCOUNT
|
||||
vivo := cfg.JPush.Channel.Vendor.VIVO
|
||||
if vivo.VivoChatCategory != "IM" {
|
||||
t.Errorf("vivo.vivo_chat_category default = %q, want IM", vivo.VivoChatCategory)
|
||||
}
|
||||
if vivo.VivoSystemCategory != "ACCOUNT" {
|
||||
t.Errorf("vivo.vivo_system_category default = %q, want ACCOUNT", vivo.VivoSystemCategory)
|
||||
}
|
||||
|
||||
// 荣耀: chat=NORMAL, system=LOW
|
||||
honor := cfg.JPush.Channel.Vendor.Honor
|
||||
if honor.HonorChatImportance != "NORMAL" {
|
||||
t.Errorf("honor.honor_chat_importance default = %q, want NORMAL", honor.HonorChatImportance)
|
||||
}
|
||||
if honor.HonorSystemImportance != "LOW" {
|
||||
t.Errorf("honor.honor_system_importance default = %q, want LOW", honor.HonorSystemImportance)
|
||||
}
|
||||
|
||||
t.Logf("OPPO: chat=%s/%d system=%s/%d", oppo.OppoChatCategory, oppo.OppoChatNotifyLevel, oppo.OppoSystemCategory, oppo.OppoSystemNotifyLevel)
|
||||
t.Logf("vivo: chat=%s system=%s", vivo.VivoChatCategory, vivo.VivoSystemCategory)
|
||||
t.Logf("荣耀: chat=%s system=%s", honor.HonorChatImportance, honor.HonorSystemImportance)
|
||||
}
|
||||
11
internal/config/livekit.go
Normal file
11
internal/config/livekit.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
// LiveKitConfig LiveKit SFU 配置
|
||||
type LiveKitConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
URL string `mapstructure:"url"`
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
APISecret string `mapstructure:"api_secret"`
|
||||
TokenTTL int `mapstructure:"token_ttl"`
|
||||
WebhookSecret string `mapstructure:"webhook_secret"`
|
||||
}
|
||||
13
internal/config/push_worker.go
Normal file
13
internal/config/push_worker.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
// PushWorkerConfig 推送 Worker 配置(Redis Stream 异步推送)
|
||||
type PushWorkerConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Stream string `mapstructure:"stream"` // Redis Stream 名称,默认 "msg_push"
|
||||
Group string `mapstructure:"group"` // Consumer Group 名称,默认 "push_worker"
|
||||
BatchSize int `mapstructure:"batch_size"` // XREADGROUP 每次读取条数,默认 50
|
||||
PollTimeoutMs int `mapstructure:"poll_timeout_ms"` // XREADGROUP BLOCK 超时 ms,默认 2000
|
||||
MaxRetries int `mapstructure:"max_retries"` // 消息最大重试次数,默认 3
|
||||
MaxStreamLen int64 `mapstructure:"max_stream_len"` // Stream MAXLEN,默认 100000
|
||||
IdleTimeoutMs int64 `mapstructure:"idle_timeout_ms"` // XPENDING 空闲超时 ms,默认 30000
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisConfig Redis 配置
|
||||
@@ -62,19 +59,3 @@ type MiniredisConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
}
|
||||
|
||||
// NewRedis 创建 Redis 客户端(真实 Redis)
|
||||
func NewRedis(cfg *RedisConfig) (*redis.Client, error) {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr(),
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
PoolSize: cfg.PoolSize,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to redis: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
7
internal/config/report.go
Normal file
7
internal/config/report.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package config
|
||||
|
||||
// ReportConfig 举报配置
|
||||
type ReportConfig struct {
|
||||
// AutoHideThreshold 自动隐藏阈值(举报次数达到此数值时自动隐藏内容)
|
||||
AutoHideThreshold int `mapstructure:"auto_hide_threshold"`
|
||||
}
|
||||
28
internal/config/runner.go
Normal file
28
internal/config/runner.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
// RunnerConfig Runner 集群配置
|
||||
type RunnerConfig struct {
|
||||
Mode string `mapstructure:"mode"` // "standalone" | "cluster",空则跟随 websocket.mode
|
||||
Cluster RunnerClusterConfig `mapstructure:"cluster"`
|
||||
}
|
||||
|
||||
// RunnerClusterConfig Runner 集群模式配置
|
||||
type RunnerClusterConfig struct {
|
||||
InstanceID string `mapstructure:"instance_id"` // 空则跟随 websocket.cluster.instance_id
|
||||
TaskChannel string `mapstructure:"task_channel"`
|
||||
ResultChannel string `mapstructure:"result_channel"`
|
||||
RegistryPrefix string `mapstructure:"registry_prefix"`
|
||||
CapPrefix string `mapstructure:"cap_prefix"`
|
||||
RegistryTTL int `mapstructure:"registry_ttl"`
|
||||
HeartbeatInterval int `mapstructure:"heartbeat_interval"`
|
||||
}
|
||||
|
||||
// ApplyWSDefaults 用 WebSocket 配置填充 Runner 中未设置的共享字段
|
||||
func (r *RunnerConfig) ApplyWSDefaults(ws WSConfig) {
|
||||
if r.Mode == "" {
|
||||
r.Mode = ws.Mode
|
||||
}
|
||||
if r.Cluster.InstanceID == "" {
|
||||
r.Cluster.InstanceID = ws.Cluster.InstanceID
|
||||
}
|
||||
}
|
||||
10
internal/config/sensitive.go
Normal file
10
internal/config/sensitive.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package config
|
||||
|
||||
type SensitiveConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
ReplaceStr string `mapstructure:"replace_str"`
|
||||
MinMatchLen int `mapstructure:"min_match_len"`
|
||||
LoadFromDB bool `mapstructure:"load_from_db"`
|
||||
LoadFromRedis bool `mapstructure:"load_from_redis"`
|
||||
RedisKeyPrefix string `mapstructure:"redis_key_prefix"`
|
||||
}
|
||||
10
internal/config/seq_buffer.go
Normal file
10
internal/config/seq_buffer.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package config
|
||||
|
||||
// SeqBufferConfig seq 预分配配置
|
||||
type SeqBufferConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
PrivateBufferSize int `mapstructure:"private_buffer_size"` // 私聊预分配步长(默认 50)
|
||||
GroupBufferSize int `mapstructure:"group_buffer_size"` // 群聊预分配步长(默认 200)
|
||||
LockTimeoutMs int `mapstructure:"lock_timeout_ms"` // 分布式锁超时毫秒(默认 5000)
|
||||
MaxRetries int `mapstructure:"max_retries"` // 冷启动重试次数(默认 3)
|
||||
}
|
||||
@@ -61,3 +61,19 @@ type UploadConfig struct {
|
||||
MaxFileSize int64 `mapstructure:"max_file_size"`
|
||||
AllowedTypes []string `mapstructure:"allowed_types"`
|
||||
}
|
||||
|
||||
// FileCleanupConfig 聊天文件 TTL 过期清理配置
|
||||
type FileCleanupConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // 是否启用清理 worker
|
||||
RetentionDays int `mapstructure:"retention_days"` // 保留天数,默认 7
|
||||
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||
BatchSize int `mapstructure:"batch_size"` // 单次扫描处理上限,默认 100
|
||||
}
|
||||
|
||||
// DeviceCleanupConfig 设备 registration_id 清理配置
|
||||
// 周期性清理超过保留期未使用的设备 token,避免无效 registration_id 累积
|
||||
type DeviceCleanupConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // 是否启用清理 worker,默认 true
|
||||
RetentionDays int `mapstructure:"retention_days"` // 保留天数,默认 3(3 天不活跃即清理)
|
||||
IntervalMinutes int `mapstructure:"interval_minutes"` // 扫描间隔(分钟),默认 360(6 小时)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
// S3Config S3 存储配置
|
||||
type S3Config struct {
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
@@ -19,32 +10,3 @@ type S3Config struct {
|
||||
Region string `mapstructure:"region"`
|
||||
Domain string `mapstructure:"domain"` // 自定义域名,如 s3.carrot.skin
|
||||
}
|
||||
|
||||
// NewS3 创建 S3 客户端
|
||||
func NewS3(cfg *S3Config) (*minio.Client, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create S3 client: %w", err)
|
||||
}
|
||||
|
||||
exists, err := client.BucketExists(ctx, cfg.Bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check bucket: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{
|
||||
Region: cfg.Region,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to create bucket: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
8
internal/config/version_log.go
Normal file
8
internal/config/version_log.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package config
|
||||
|
||||
// VersionLogConfig 版本日志增量同步配置
|
||||
type VersionLogConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
SyncLimit int `mapstructure:"sync_limit"` // 单次同步最大返回条数
|
||||
MaxSyncGap int64 `mapstructure:"max_sync_gap"` // 超过此版本差距建议全量同步
|
||||
}
|
||||
15
internal/config/websocket.go
Normal file
15
internal/config/websocket.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package config
|
||||
|
||||
// WSConfig WebSocket 配置
|
||||
type WSConfig struct {
|
||||
Mode string `mapstructure:"mode"` // standalone | cluster
|
||||
Cluster WSClusterConfig `mapstructure:"cluster"`
|
||||
}
|
||||
|
||||
// WSClusterConfig WebSocket 集群配置
|
||||
type WSClusterConfig struct {
|
||||
InstanceID string `mapstructure:"instance_id"`
|
||||
MsgChannel string `mapstructure:"msg_channel"`
|
||||
OnlineTTL int `mapstructure:"online_ttl"`
|
||||
HeartbeatInterval int `mapstructure:"heartbeat_interval"`
|
||||
}
|
||||
535
internal/database/database.go
Normal file
535
internal/database/database.go
Normal file
@@ -0,0 +1,535 @@
|
||||
// Package database 负责数据库连接初始化、自动迁移与种子数据。
|
||||
//
|
||||
// 这些引导职责从 model 包拆分而来:model 包应只包含纯数据结构定义与
|
||||
// GORM 钩子,不应依赖 config 或持有启动逻辑。本包依赖 config + model,
|
||||
// 方向合法(引导层 → 模型层 / 配置层)。
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/plugin/dbresolver"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// NewDB 创建数据库连接(用于 Wire 依赖注入)
|
||||
func NewDB(cfg *config.DatabaseConfig) (*gorm.DB, error) {
|
||||
var err error
|
||||
var db *gorm.DB
|
||||
gormLogger := logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Duration(cfg.SlowThresholdMs) * time.Millisecond,
|
||||
LogLevel: parseGormLogLevel(cfg.LogLevel),
|
||||
IgnoreRecordNotFoundError: cfg.IgnoreRecordNotFound,
|
||||
ParameterizedQueries: cfg.ParameterizedQueries,
|
||||
Colorful: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 根据数据库类型选择驱动
|
||||
switch cfg.Type {
|
||||
case "sqlite":
|
||||
db, err = gorm.Open(sqlite.Open(cfg.SQLite.Path), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
case "postgres", "postgresql":
|
||||
dsn := cfg.Postgres.DSN()
|
||||
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
default:
|
||||
// 默认使用PostgreSQL
|
||||
dsn := cfg.Postgres.DSN()
|
||||
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// 配置连接池(SQLite不支持连接池配置,跳过)
|
||||
if cfg.Type != "sqlite" {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database instance: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
}
|
||||
|
||||
// 配置读写分离(仅 PostgreSQL 且配置了读副本时生效)
|
||||
if (cfg.Type == "postgres" || cfg.Type == "postgresql") && cfg.HasReplica() {
|
||||
allReplicas := cfg.AllReplicas()
|
||||
replicaDialectors := make([]gorm.Dialector, len(allReplicas))
|
||||
for i, replica := range allReplicas {
|
||||
replicaDialectors[i] = postgres.Open(replica.DSN())
|
||||
}
|
||||
|
||||
replicaMaxIdle := cfg.ReplicaMaxIdle
|
||||
if replicaMaxIdle == 0 {
|
||||
replicaMaxIdle = cfg.MaxIdleConns
|
||||
}
|
||||
replicaMaxOpen := cfg.ReplicaMaxOpen
|
||||
if replicaMaxOpen == 0 {
|
||||
replicaMaxOpen = cfg.MaxOpenConns
|
||||
}
|
||||
|
||||
resolver := dbresolver.Register(dbresolver.Config{
|
||||
Replicas: replicaDialectors,
|
||||
}).
|
||||
SetMaxIdleConns(replicaMaxIdle).
|
||||
SetMaxOpenConns(replicaMaxOpen).
|
||||
SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := db.Use(resolver); err != nil {
|
||||
return nil, fmt.Errorf("failed to register dbresolver plugin: %w", err)
|
||||
}
|
||||
|
||||
zap.L().Info("Database read/write splitting configured",
|
||||
zap.String("type", cfg.Type),
|
||||
zap.Int("replica_count", len(allReplicas)),
|
||||
)
|
||||
}
|
||||
|
||||
// 自动迁移
|
||||
if err := autoMigrate(db); err != nil {
|
||||
return nil, fmt.Errorf("failed to auto migrate: %w", err)
|
||||
}
|
||||
|
||||
zap.L().Info("Database connected and migrated successfully",
|
||||
zap.String("type", cfg.Type),
|
||||
)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func parseGormLogLevel(level string) logger.LogLevel {
|
||||
switch level {
|
||||
case "silent":
|
||||
return logger.Silent
|
||||
case "error":
|
||||
return logger.Error
|
||||
case "warn":
|
||||
return logger.Warn
|
||||
case "info":
|
||||
return logger.Info
|
||||
default:
|
||||
return logger.Warn
|
||||
}
|
||||
}
|
||||
|
||||
// autoMigrate 自动迁移数据库表
|
||||
func autoMigrate(db *gorm.DB) error {
|
||||
err := db.AutoMigrate(
|
||||
// 用户相关
|
||||
&model.User{},
|
||||
|
||||
// 帖子相关
|
||||
&model.Post{},
|
||||
&model.PostImage{},
|
||||
&model.Channel{},
|
||||
|
||||
// 评论相关
|
||||
&model.Comment{},
|
||||
&model.CommentLike{},
|
||||
|
||||
// 消息相关(使用雪花算法ID和seq机制)
|
||||
// 已读位置存储在 ConversationParticipant.LastReadSeq 中
|
||||
&model.Conversation{},
|
||||
&model.ConversationParticipant{},
|
||||
&model.Message{},
|
||||
|
||||
// 系统通知(独立表,每个用户只能看到自己的通知)
|
||||
&model.SystemNotification{},
|
||||
|
||||
// 通知
|
||||
&model.Notification{},
|
||||
|
||||
// 推送中心相关
|
||||
&model.PushRecord{}, // 推送记录
|
||||
&model.DeviceToken{}, // 设备Token
|
||||
|
||||
// 社交
|
||||
&model.Follow{},
|
||||
&model.UserBlock{},
|
||||
&model.PostLike{},
|
||||
&model.Favorite{},
|
||||
|
||||
// 投票
|
||||
&model.VoteOption{},
|
||||
&model.UserVote{},
|
||||
|
||||
// 敏感词和审核
|
||||
&model.SensitiveWord{},
|
||||
// &model.AuditLog{}, // TODO: define AuditLog model
|
||||
|
||||
// 举报
|
||||
&model.Report{},
|
||||
|
||||
// 日志相关
|
||||
&model.OperationLog{}, // 操作日志
|
||||
&model.LoginLog{}, // 登录日志
|
||||
&model.DataChangeLog{}, // 数据变更日志
|
||||
|
||||
// 群组相关
|
||||
&model.Group{},
|
||||
&model.GroupMember{},
|
||||
&model.GroupAnnouncement{},
|
||||
&model.GroupJoinRequest{},
|
||||
|
||||
// 自定义表情
|
||||
&model.UserSticker{},
|
||||
|
||||
// 课表
|
||||
&model.ScheduleCourse{},
|
||||
|
||||
// 成绩与考试
|
||||
&model.Grade{},
|
||||
&model.GpaSummary{},
|
||||
&model.Exam{},
|
||||
&model.EmptyClassroom{},
|
||||
|
||||
// 用户活跃相关
|
||||
&model.UserActiveLog{},
|
||||
&model.UserActivityStat{},
|
||||
|
||||
// 角色和权限相关
|
||||
&model.Role{},
|
||||
&model.UserRole{},
|
||||
&model.CasbinRule{},
|
||||
|
||||
// 学习资料相关
|
||||
&model.MaterialSubject{},
|
||||
&model.MaterialFile{},
|
||||
|
||||
// 通话相关
|
||||
&model.CallSession{},
|
||||
&model.CallParticipant{},
|
||||
|
||||
// 身份认证相关
|
||||
&model.VerificationRecord{},
|
||||
|
||||
// 用户资料审核相关
|
||||
&model.UserProfileAudit{},
|
||||
|
||||
// 帖子内链引用关系
|
||||
&model.PostReference{},
|
||||
|
||||
// 二手交易相关
|
||||
&model.TradeItem{},
|
||||
&model.TradeImage{},
|
||||
&model.TradeFavorite{},
|
||||
|
||||
// 文件上传记录(用于聊天文件 TTL 过期清理)
|
||||
&model.UploadedFile{},
|
||||
|
||||
// 会话(令牌撤销支持)
|
||||
&model.Session{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// AutoMigrate 不会删除列:清理已废弃的 posts.hot_score
|
||||
if err := dropPostsHotScoreColumnIfExists(db); err != nil {
|
||||
return fmt.Errorf("drop legacy posts.hot_score: %w", err)
|
||||
}
|
||||
|
||||
// 初始化角色种子数据
|
||||
if err := seedRoles(db); err != nil {
|
||||
return fmt.Errorf("failed to seed roles: %w", err)
|
||||
}
|
||||
|
||||
// 初始化权限策略种子数据
|
||||
if err := seedPermissions(db); err != nil {
|
||||
return fmt.Errorf("failed to seed permissions: %w", err)
|
||||
}
|
||||
|
||||
// 清理历史 Casbin g 分组规则(真源已统一为 user_roles 表,Casbin 仅保留 p 策略)。
|
||||
// 幂等:仅在 casbin_rule 中存在 ptype='g' 记录时删除。
|
||||
if err := cleanupLegacyCasbinGRules(db); err != nil {
|
||||
return fmt.Errorf("failed to cleanup legacy casbin g rules: %w", err)
|
||||
}
|
||||
|
||||
// 初始化频道配置种子数据
|
||||
if err := seedChannels(db); err != nil {
|
||||
return fmt.Errorf("failed to seed channels: %w", err)
|
||||
}
|
||||
|
||||
// 初始化学习资料学科种子数据
|
||||
if err := seedMaterialSubjects(db); err != nil {
|
||||
return fmt.Errorf("failed to seed material subjects: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// postWithHotScore 仅用于 Migrator 检测/删除旧列(Post 模型已移除 HotScore)
|
||||
type postWithHotScore struct {
|
||||
HotScore float64 `gorm:"column:hot_score"`
|
||||
}
|
||||
|
||||
func (postWithHotScore) TableName() string { return "posts" }
|
||||
|
||||
func dropPostsHotScoreColumnIfExists(db *gorm.DB) error {
|
||||
m := db.Migrator()
|
||||
if !m.HasTable(&model.Post{}) {
|
||||
return nil
|
||||
}
|
||||
if !m.HasColumn(&postWithHotScore{}, "HotScore") {
|
||||
return nil
|
||||
}
|
||||
return m.DropColumn(&postWithHotScore{}, "HotScore")
|
||||
}
|
||||
|
||||
// cleanupLegacyCasbinGRules 清理历史 Casbin g 分组规则。
|
||||
//
|
||||
// 真源策略变更后:user_roles 表为用户-角色唯一真源,Casbin 仅保留 p, role, resource, action 策略。
|
||||
// 旧的 g, user, role 记录不再被 matcher 使用(model.conf 已移除 g 依赖),保留只会造成管理后台误解。
|
||||
// 幂等:仅删除 ptype='g' 的记录;若不存在则无操作。
|
||||
func cleanupLegacyCasbinGRules(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&model.CasbinRule{}) {
|
||||
return nil
|
||||
}
|
||||
result := db.Where("ptype = ?", "g").Delete(&model.CasbinRule{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
zap.L().Info("Cleaned up legacy casbin g rules",
|
||||
zap.Int64("count", result.RowsAffected),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedRoles 初始化角色种子数据
|
||||
func seedRoles(db *gorm.DB) error {
|
||||
// 检查是否已有角色数据
|
||||
var count int64
|
||||
if err := db.Model(&model.Role{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 如果已有数据,跳过种子初始化
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 预定义角色数据
|
||||
roles := []model.Role{
|
||||
{
|
||||
Name: model.RoleSuperAdmin,
|
||||
DisplayName: "超级管理员",
|
||||
Description: "拥有系统最高权限,可以管理所有用户和内容",
|
||||
Priority: 100,
|
||||
},
|
||||
{
|
||||
Name: model.RoleAdmin,
|
||||
DisplayName: "管理员",
|
||||
Description: "系统管理员,可以管理用户和内容",
|
||||
Priority: 80,
|
||||
},
|
||||
{
|
||||
Name: model.RoleModerator,
|
||||
DisplayName: "版主",
|
||||
Description: "内容审核员,可以审核和管理内容",
|
||||
Priority: 60,
|
||||
},
|
||||
{
|
||||
Name: model.RoleUser,
|
||||
DisplayName: "普通用户",
|
||||
Description: "注册用户,拥有基本权限",
|
||||
Priority: 40,
|
||||
},
|
||||
{
|
||||
Name: model.RoleBanned,
|
||||
DisplayName: "被封禁用户",
|
||||
Description: "被禁止访问的用户",
|
||||
Priority: 0,
|
||||
},
|
||||
}
|
||||
|
||||
// 批量插入角色
|
||||
if err := db.Create(&roles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zap.L().Info("Seeded roles successfully",
|
||||
zap.Int("count", len(roles)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedPermissions 初始化权限策略种子数据
|
||||
//
|
||||
// 真源策略:user_roles 表为用户-角色真源,Casbin 仅维护 p, role, resource, action 策略。
|
||||
// 资源命名约定:admin/<domain>[/<sub>](路径式,配合 globMatch 中 * 不跨 /、** 跨 / 的语义)。
|
||||
//
|
||||
// 升级兼容:旧版本以 URL 路径式资源(如 /api/v1/admin/*、/*)作为 Casbin 策略,
|
||||
// 新版本改为 admin/<domain> 资源命名后,存量部署需要迁移。本函数检测到任何 v1 以 '/' 开头的
|
||||
// 旧策略时,将其视为旧版本部署:清空所有 p 策略并按新约定重新 seed。
|
||||
// 新部署无此条件不触发,幂等(admin/<domain> 不以 '/' 开头,不会误判为新旧迁移)。
|
||||
func seedPermissions(db *gorm.DB) error {
|
||||
// 检查是否已有权限策略数据
|
||||
var count int64
|
||||
if err := db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
// 检测是否存在旧版本路径式策略(v1 以 '/' 开头)。
|
||||
// 旧策略例如:{p, super_admin, /*, *} / {p, admin, /api/v1/admin/*, *} 等。
|
||||
// 新约定资源形如 admin/users(不以 '/' 开头),不会被本条件误命中。
|
||||
// 旧策略在新 model.conf 的 admin/<domain> 命名下永远不会匹配,保留只会让管理后台权限全断。
|
||||
var legacyCount int64
|
||||
if err := db.Model(&model.CasbinRule{}).
|
||||
Where("ptype = ? AND v1 LIKE '/%'", "p").
|
||||
Count(&legacyCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if legacyCount == 0 {
|
||||
// 已是新版本策略,无需重复 seed。
|
||||
return nil
|
||||
}
|
||||
|
||||
// 命中旧策略:迁移。先 dump 待删除策略到日志便于回查,再删除全部 p 策略。
|
||||
zap.L().Info("Migrating legacy path-based casbin p rules to admin/<domain> resource naming",
|
||||
zap.Int64("legacy_count", legacyCount),
|
||||
zap.Int64("total_p_count", count),
|
||||
)
|
||||
var legacyRules []model.CasbinRule
|
||||
if err := db.Where("ptype = ?", "p").Find(&legacyRules).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range legacyRules {
|
||||
zap.L().Debug("legacy casbin p rule will be removed",
|
||||
zap.String("v0", r.V0),
|
||||
zap.String("v1", r.V1),
|
||||
zap.String("v2", r.V2),
|
||||
)
|
||||
}
|
||||
if err := db.Where("ptype = ?", "p").Delete(&model.CasbinRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 注意:count > 0 分支到此已清空所有 p 策略,下面统一重新 seed。
|
||||
}
|
||||
|
||||
// 预定义权限策略数据
|
||||
// p = sub, obj, act (角色, 资源, 操作)
|
||||
//
|
||||
// 资源命名约定:admin/<domain>[/<sub>](路径式),动作:read / write / *。
|
||||
// super_admin 通过 admin/** 通配获得所有管理能力(globMatch 中 ** 跨 '/');
|
||||
// admin 仅获得业务管理能力,角色与权限管理(admin/roles/*、admin/users/roles/*)
|
||||
// 与日志导出(admin/logs/export)仅 super_admin 拥有(admin/logs 单层匹配不跨 /)。
|
||||
permissions := []model.CasbinRule{
|
||||
// 超级管理员 - 通配所有 admin 资源(globMatch:** 跨 '/' 分隔的多级资源)
|
||||
{Ptype: "p", V0: model.RoleSuperAdmin, V1: "admin/**", V2: "*"},
|
||||
|
||||
// 管理员 - 业务管理能力(不含角色/权限管理与日志导出)
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/status", V2: "write"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/users/devices", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/posts", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/comments", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/groups", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/channels", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/dashboard", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/reports", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/verifications", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/profile_audits", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/materials", V2: "*"},
|
||||
{Ptype: "p", V0: model.RoleAdmin, V1: "admin/logs", V2: "read"},
|
||||
|
||||
// 版主 - 内容审核
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/posts", V2: "write"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/comments", V2: "write"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "read"},
|
||||
{Ptype: "p", V0: model.RoleModerator, V1: "admin/reports", V2: "write"},
|
||||
|
||||
// 普通用户与被封禁用户:admin 路由外的资源由路由层中间件保障,Casbin 不维护。
|
||||
}
|
||||
|
||||
// 批量插入权限策略
|
||||
if err := db.Create(&permissions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zap.L().Info("Seeded permissions successfully",
|
||||
zap.Int("count", len(permissions)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedChannels 初始化频道种子数据(仅在表为空时)
|
||||
func seedChannels(db *gorm.DB) error {
|
||||
// 检查是否已有频道数据
|
||||
var count int64
|
||||
if err := db.Model(&model.Channel{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
zap.L().Info("Channels already exist, skipping seed", zap.Int64("count", count))
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultChannels := []model.Channel{
|
||||
{Name: "跳蚤市场", Slug: "market", Description: "二手交易与闲置发布", SortOrder: 10, IsActive: true},
|
||||
{Name: "课程交流", Slug: "courses", Description: "课程资料、选课与学习讨论", SortOrder: 20, IsActive: true},
|
||||
{Name: "工作实习", Slug: "jobs", Description: "实习、内推与求职信息", SortOrder: 30, IsActive: true},
|
||||
{Name: "考研考公", Slug: "exam", Description: "备考经验、资料与互助", SortOrder: 40, IsActive: true},
|
||||
{Name: "保研出国", Slug: "graduate-abroad", Description: "保研、留学申请与经验分享", SortOrder: 50, IsActive: true},
|
||||
}
|
||||
|
||||
if err := db.Create(&defaultChannels).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zap.L().Info("Seeded channels successfully", zap.Int("count", len(defaultChannels)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedMaterialSubjects 初始化学习资料学科种子数据(仅在表为空时)
|
||||
func seedMaterialSubjects(db *gorm.DB) error {
|
||||
// 检查是否已有数据
|
||||
var count int64
|
||||
if err := db.Model(&model.MaterialSubject{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
zap.L().Info("Material subjects already exist, skipping seed", zap.Int64("count", count))
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultSubjects := []model.MaterialSubject{
|
||||
{Name: "数学", Icon: "calculator-variant", Color: "#FF6B6B", Description: "高等数学、线性代数、概率论等", SortOrder: 10, IsActive: true},
|
||||
{Name: "英语", Icon: "translate", Color: "#4ECDC4", Description: "四六级、考研英语、托福雅思等", SortOrder: 20, IsActive: true},
|
||||
{Name: "物理", Icon: "atom", Color: "#45B7D1", Description: "大学物理、电磁学、力学等", SortOrder: 30, IsActive: true},
|
||||
{Name: "化学", Icon: "flask", Color: "#96CEB4", Description: "有机化学、无机化学、分析化学等", SortOrder: 40, IsActive: true},
|
||||
{Name: "计算机", Icon: "laptop", Color: "#FFEAA7", Description: "数据结构、算法、操作系统等", SortOrder: 50, IsActive: true},
|
||||
{Name: "经济管理", Icon: "chart-line", Color: "#DDA0DD", Description: "微观经济学、宏观经济学、管理学等", SortOrder: 60, IsActive: true},
|
||||
{Name: "法学", Icon: "scale-balance", Color: "#F39C12", Description: "民法、刑法、宪法等", SortOrder: 70, IsActive: true},
|
||||
{Name: "语文文学", Icon: "book-open-page-variant", Color: "#3498DB", Description: "古代文学、现代文学、写作等", SortOrder: 80, IsActive: true},
|
||||
}
|
||||
|
||||
if err := db.Create(&defaultSubjects).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zap.L().Info("Seeded material subjects successfully", zap.Int("count", len(defaultSubjects)))
|
||||
return nil
|
||||
}
|
||||
222
internal/database/database_test.go
Normal file
222
internal/database/database_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// 注意:完整的 NewDB→autoMigrate 在 SQLite 下会因 model 包多个表共用
|
||||
// 同名索引 idx_created(data_change_log / login_log / operation_log)而冲突。
|
||||
// 这是 model 包的既有约束(PostgreSQL 经 CREATE INDEX IF NOT EXISTS 容忍),
|
||||
// 与本次分层拆分无关。因此种子逻辑测试在隔离的 SQLite 库上验证:
|
||||
// 仅迁移被种子函数读写的表,避开跨表索引冲突,专注验证种子正确性与幂等性。
|
||||
|
||||
// newSeedTestDB 创建一个仅迁移种子相关表的 SQLite 测试库。
|
||||
func newSeedTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "seed_test.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite failed: %v", err)
|
||||
}
|
||||
|
||||
// 仅迁移种子函数涉及的表(无 idx_created 冲突)
|
||||
if err := db.AutoMigrate(
|
||||
&model.Role{},
|
||||
&model.CasbinRule{},
|
||||
&model.Channel{},
|
||||
&model.MaterialSubject{},
|
||||
&model.Post{}, // dropPostsHotScoreColumnIfExists 依赖 posts 表存在
|
||||
); err != nil {
|
||||
t.Fatalf("autoMigrate seed tables failed: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
// TestSeedRoles 验证角色种子写入 5 个预定义角色。
|
||||
func TestSeedRoles(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedRoles(db); err != nil {
|
||||
t.Fatalf("seedRoles failed: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.Role{}).Count(&count)
|
||||
if count != 5 {
|
||||
t.Errorf("expected 5 seeded roles, got %d", count)
|
||||
}
|
||||
|
||||
expected := []string{
|
||||
model.RoleSuperAdmin, model.RoleAdmin, model.RoleModerator,
|
||||
model.RoleUser, model.RoleBanned,
|
||||
}
|
||||
for _, name := range expected {
|
||||
var role model.Role
|
||||
if err := db.Where("name = ?", name).First(&role).Error; err != nil {
|
||||
t.Errorf("expected role %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedRoles_Idempotent 验证角色种子幂等:表非空时跳过。
|
||||
func TestSeedRoles_Idempotent(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedRoles(db); err != nil {
|
||||
t.Fatalf("first seedRoles: %v", err)
|
||||
}
|
||||
// 第二次调用应跳过(表已有数据)
|
||||
if err := seedRoles(db); err != nil {
|
||||
t.Fatalf("second seedRoles: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.Role{}).Count(&count)
|
||||
if count != 5 {
|
||||
t.Errorf("idempotent seed failed: expected 5 roles, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedPermissions 验证权限策略种子写入,含超级管理员通配规则。
|
||||
func TestSeedPermissions(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedPermissions(db); err != nil {
|
||||
t.Fatalf("seedPermissions failed: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count)
|
||||
if count == 0 {
|
||||
t.Fatal("expected seeded permissions, got 0")
|
||||
}
|
||||
|
||||
// 超级管理员 admin/** 通配(globMatch 中 ** 跨 '/')
|
||||
var rule model.CasbinRule
|
||||
if err := db.Where("ptype=? AND v0=? AND v1=?", "p", model.RoleSuperAdmin, "admin/**").First(&rule).Error; err != nil {
|
||||
t.Errorf("expected superadmin admin/** wildcard permission: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedPermissions_Idempotent 验证权限种子幂等。
|
||||
func TestSeedPermissions_Idempotent(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedPermissions(db); err != nil {
|
||||
t.Fatalf("first seedPermissions: %v", err)
|
||||
}
|
||||
var count1 int64
|
||||
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count1)
|
||||
|
||||
if err := seedPermissions(db); err != nil {
|
||||
t.Fatalf("second seedPermissions: %v", err)
|
||||
}
|
||||
var count2 int64
|
||||
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count2)
|
||||
|
||||
if count2 != count1 {
|
||||
t.Errorf("seed not idempotent: first=%d, second=%d", count1, count2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedChannels 验证频道种子写入 5 个默认频道。
|
||||
func TestSeedChannels(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedChannels(db); err != nil {
|
||||
t.Fatalf("seedChannels failed: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.Channel{}).Count(&count)
|
||||
if count != 5 {
|
||||
t.Errorf("expected 5 channels, got %d", count)
|
||||
}
|
||||
|
||||
// 抽查一个频道
|
||||
var ch model.Channel
|
||||
if err := db.Where("slug = ?", "market").First(&ch).Error; err != nil {
|
||||
t.Errorf("expected market channel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMaterialSubjects 验证学科种子写入 8 个默认学科。
|
||||
func TestSeedMaterialSubjects(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
|
||||
if err := seedMaterialSubjects(db); err != nil {
|
||||
t.Fatalf("seedMaterialSubjects failed: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.MaterialSubject{}).Count(&count)
|
||||
if count != 8 {
|
||||
t.Errorf("expected 8 material subjects, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseGormLogLevel 验证日志级别解析映射。
|
||||
func TestParseGormLogLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want logger.LogLevel
|
||||
}{
|
||||
{"silent", logger.Silent},
|
||||
{"error", logger.Error},
|
||||
{"warn", logger.Warn},
|
||||
{"info", logger.Info},
|
||||
{"", logger.Warn}, // 默认
|
||||
{"bogus", logger.Warn}, // 未知值降级为 warn
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseGormLogLevel(c.in); got != c.want {
|
||||
t.Errorf("parseGormLogLevel(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropPostsHotScoreColumnIfExists_NoTable 验证当 posts 表不存在时安全跳过。
|
||||
func TestDropPostsHotScoreColumnIfExists_NoTable(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "empty.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
// 未迁移 posts 表 → 应安全返回 nil
|
||||
if err := dropPostsHotScoreColumnIfExists(db); err != nil {
|
||||
t.Errorf("expected nil when posts table absent, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 编译期断言:确保 NewDB 签名符合 (*config.DatabaseConfig) -> (*gorm.DB, error)
|
||||
var _ = func() (*gorm.DB, error) {
|
||||
return NewDB(&config.DatabaseConfig{})
|
||||
}
|
||||
|
||||
var _ = os.RemoveAll // 保持 os 引用可用(供未来临时文件场景扩展)
|
||||
@@ -1,8 +1,8 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"time"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ==================== User DTOs ====================
|
||||
@@ -24,6 +24,9 @@ type UserResponse struct {
|
||||
IsFollowing bool `json:"is_following"`
|
||||
IsFollowingMe bool `json:"is_following_me"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// 认证信息
|
||||
Identity model.UserIdentity `json:"identity,omitempty"`
|
||||
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
|
||||
}
|
||||
|
||||
// UserSelfResponse 用户自身信息响应(包含敏感信息,仅本人可见)
|
||||
@@ -44,6 +47,11 @@ type UserSelfResponse struct {
|
||||
FollowingCount int `json:"following_count"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// 隐私设置
|
||||
PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"`
|
||||
// 认证信息
|
||||
Identity model.UserIdentity `json:"identity,omitempty"`
|
||||
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
|
||||
}
|
||||
|
||||
// UserDetailResponse 用户详情响应(仅本人可见,包含敏感信息)
|
||||
@@ -66,6 +74,11 @@ type UserDetailResponse struct {
|
||||
IsFollowing bool `json:"is_following"`
|
||||
IsFollowingMe bool `json:"is_following_me"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// 隐私设置
|
||||
PrivacySettings *PrivacySettingsResponse `json:"privacy_settings,omitempty"`
|
||||
// 认证信息
|
||||
Identity model.UserIdentity `json:"identity,omitempty"`
|
||||
VerificationStatus model.VerificationStatus `json:"verification_status,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Admin User DTOs ====================
|
||||
@@ -118,69 +131,6 @@ type AdminUpdateUserStatusRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=active banned"`
|
||||
}
|
||||
|
||||
// ==================== Post DTOs ====================
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// PostDetailResponse 帖子详情响应
|
||||
type PostDetailResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ChannelID *string `json:"channel_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Images []PostImageResponse `json:"images"`
|
||||
Status string `json:"status"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// ==================== Comment DTOs ====================
|
||||
|
||||
// CommentImageResponse 评论图片响应
|
||||
@@ -197,6 +147,7 @@ type CommentResponse struct {
|
||||
ParentID *string `json:"parent_id"`
|
||||
RootID *string `json:"root_id"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments,omitempty"`
|
||||
Images []CommentImageResponse `json:"images"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
RepliesCount int `json:"replies_count"`
|
||||
@@ -227,15 +178,17 @@ type NotificationResponse struct {
|
||||
type SegmentType string
|
||||
|
||||
const (
|
||||
SegmentTypeText SegmentType = "text"
|
||||
SegmentTypeImage SegmentType = "image"
|
||||
SegmentTypeVoice SegmentType = "voice"
|
||||
SegmentTypeVideo SegmentType = "video"
|
||||
SegmentTypeFile SegmentType = "file"
|
||||
SegmentTypeAt SegmentType = "at"
|
||||
SegmentTypeReply SegmentType = "reply"
|
||||
SegmentTypeFace SegmentType = "face"
|
||||
SegmentTypeLink SegmentType = "link"
|
||||
SegmentTypeText SegmentType = "text"
|
||||
SegmentTypeImage SegmentType = "image"
|
||||
SegmentTypeVoice SegmentType = "voice"
|
||||
SegmentTypeVideo SegmentType = "video"
|
||||
SegmentTypeFile SegmentType = "file"
|
||||
SegmentTypeAt SegmentType = "at"
|
||||
SegmentTypeReply SegmentType = "reply"
|
||||
SegmentTypeFace SegmentType = "face"
|
||||
SegmentTypeLink SegmentType = "link"
|
||||
SegmentTypeVote SegmentType = "vote"
|
||||
SegmentTypePostRef SegmentType = "post_ref"
|
||||
)
|
||||
|
||||
// TextSegmentData 文本数据
|
||||
@@ -303,6 +256,36 @@ type LinkSegmentData struct {
|
||||
Image string `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
// VoteOptionData 投票选项数据
|
||||
type VoteOptionData struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// PostRefSegmentData 帖子内链Segment数据
|
||||
type PostRefSegmentData struct {
|
||||
PostID string `json:"post_id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Author *UserResponse `json:"author,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Accessible bool `json:"accessible"`
|
||||
ClickCount int `json:"click_count,omitzero"`
|
||||
}
|
||||
|
||||
// PostRefBrief 帖子内链简要信息 (用于搜索联想)
|
||||
type PostRefBrief struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// VoteSegmentData 投票Segment数据
|
||||
type VoteSegmentData struct {
|
||||
Options []VoteOptionData `json:"options"`
|
||||
MaxChoices int `json:"max_choices,omitempty"`
|
||||
IsMultiChoice bool `json:"is_multi_choice,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Message DTOs ====================
|
||||
|
||||
// MessageResponse 消息响应
|
||||
@@ -321,26 +304,27 @@ type MessageResponse struct {
|
||||
|
||||
// ConversationResponse 会话响应
|
||||
type ConversationResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
Group *GroupResponse `json:"group,omitempty"`
|
||||
LastSeq int64 `json:"last_seq"`
|
||||
LastMessage *MessageResponse `json:"last_message"`
|
||||
LastMessageAt string `json:"last_message_at"`
|
||||
UnreadCount int `json:"unread_count"`
|
||||
Participants []*UserResponse `json:"participants,omitempty"` // 私聊时使用
|
||||
MemberCount int `json:"member_count,omitempty"` // 群聊时使用
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
NotificationMuted bool `json:"notification_muted"`
|
||||
Group *GroupResponse `json:"group,omitempty"`
|
||||
LastSeq int64 `json:"last_seq"`
|
||||
LastMessage *MessageResponse `json:"last_message"`
|
||||
LastMessageAt string `json:"last_message_at"`
|
||||
UnreadCount int `json:"unread_count"`
|
||||
Participants []*UserResponse `json:"participants,omitempty"` // 私聊时使用
|
||||
MemberCount int `json:"member_count,omitempty"` // 群聊时使用
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ConversationParticipantResponse 会话参与者响应
|
||||
type ConversationParticipantResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
LastReadSeq int64 `json:"last_read_seq"`
|
||||
Muted bool `json:"muted"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
UserID string `json:"user_id"`
|
||||
LastReadSeq int64 `json:"last_read_seq"`
|
||||
NotificationMuted bool `json:"notification_muted"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
}
|
||||
|
||||
// ==================== Auth DTOs ====================
|
||||
@@ -397,8 +381,9 @@ type CreateConversationRequest struct {
|
||||
|
||||
// SendMessageRequest 发送消息请求
|
||||
type SendMessageRequest struct {
|
||||
Segments model.MessageSegments `json:"segments" binding:"required"` // 消息链(必须)
|
||||
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID (string类型)
|
||||
Segments model.MessageSegments `json:"segments" binding:"required"` // 消息链(必须)
|
||||
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID (string类型)
|
||||
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID(幂等性去重)
|
||||
}
|
||||
|
||||
// MarkReadRequest 标记已读请求
|
||||
@@ -406,6 +391,23 @@ type MarkReadRequest struct {
|
||||
LastReadSeq int64 `json:"last_read_seq" binding:"required"` // 已读到的seq位置
|
||||
}
|
||||
|
||||
// BatchMarkReadItem 批量标记已读单项
|
||||
type BatchMarkReadItem struct {
|
||||
ConversationID string `json:"conversation_id" binding:"required"`
|
||||
LastReadSeq int64 `json:"last_read_seq" binding:"required"`
|
||||
}
|
||||
|
||||
// BatchMarkReadRequest 批量标记已读请求
|
||||
type BatchMarkReadRequest struct {
|
||||
Conversations []BatchMarkReadItem `json:"conversations" binding:"required"`
|
||||
}
|
||||
|
||||
// BatchMarkReadResponse 批量标记已读响应
|
||||
type BatchMarkReadResponse struct {
|
||||
SuccessCount int `json:"success_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
// SetConversationPinnedRequest 设置会话置顶请求
|
||||
type SetConversationPinnedRequest struct {
|
||||
ConversationID string `json:"conversation_id" binding:"required"`
|
||||
@@ -424,18 +426,19 @@ type ConversationListResponse struct {
|
||||
|
||||
// ConversationDetailResponse 会话详情响应
|
||||
type ConversationDetailResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
LastSeq int64 `json:"last_seq"`
|
||||
LastMessage *MessageResponse `json:"last_message"`
|
||||
LastMessageAt string `json:"last_message_at"`
|
||||
UnreadCount int64 `json:"unread_count"`
|
||||
Participants []*UserResponse `json:"participants"`
|
||||
MyLastReadSeq int64 `json:"my_last_read_seq"` // 当前用户的已读位置
|
||||
OtherLastReadSeq int64 `json:"other_last_read_seq"` // 对方用户的已读位置
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
NotificationMuted bool `json:"notification_muted"`
|
||||
LastSeq int64 `json:"last_seq"`
|
||||
LastMessage *MessageResponse `json:"last_message"`
|
||||
LastMessageAt string `json:"last_message_at"`
|
||||
UnreadCount int64 `json:"unread_count"`
|
||||
Participants []*UserResponse `json:"participants"`
|
||||
MyLastReadSeq int64 `json:"my_last_read_seq"` // 当前用户的已读位置
|
||||
OtherLastReadSeq int64 `json:"other_last_read_seq"` // 对方用户的已读位置
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UnreadCountResponse 未读数响应
|
||||
@@ -449,6 +452,18 @@ type ConversationUnreadCountResponse struct {
|
||||
UnreadCount int64 `json:"unread_count"`
|
||||
}
|
||||
|
||||
// SyncDataItem 同步数据单项(轻量级,仅包含 seq 和时间)
|
||||
type SyncDataItem struct {
|
||||
ConversationID string `json:"id"`
|
||||
MaxSeq int64 `json:"max_seq"`
|
||||
LastMessageAt string `json:"last_message_at"`
|
||||
}
|
||||
|
||||
// SyncDataResponse 同步数据响应
|
||||
type SyncDataResponse struct {
|
||||
Conversations []SyncDataItem `json:"conversations"`
|
||||
}
|
||||
|
||||
// MessageListResponse 消息列表响应
|
||||
type MessageListResponse struct {
|
||||
Messages []*MessageResponse `json:"messages"`
|
||||
@@ -484,6 +499,22 @@ type DeviceTokenResponse struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminDeviceTokenResponse 管理端设备Token响应
|
||||
// 相比 DeviceTokenResponse,额外包含 user_id 与 push_token(即 JPush RegistrationID),
|
||||
// 供后台查询用户设备 registration_id 使用。
|
||||
type AdminDeviceTokenResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceType string `json:"device_type"`
|
||||
PushToken string `json:"push_token,omitempty"` // JPush RegistrationID
|
||||
IsActive bool `json:"is_active"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
LastUsedAt time.Time `json:"last_used_at,omitzero"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ==================== 推送记录 DTOs ====================
|
||||
|
||||
// PushRecordResponse 推送记录响应
|
||||
@@ -508,15 +539,15 @@ type PushRecordListResponse struct {
|
||||
|
||||
// SystemMessageResponse 系统消息响应
|
||||
type SystemMessageResponse struct {
|
||||
ID string `json:"id"`
|
||||
SenderID string `json:"sender_id"`
|
||||
ReceiverID string `json:"receiver_id"`
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category"`
|
||||
SystemType string `json:"system_type"`
|
||||
ExtraData map[string]interface{} `json:"extra_data,omitempty"`
|
||||
IsRead bool `json:"is_read"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
SenderID string `json:"sender_id"`
|
||||
ReceiverID string `json:"receiver_id"`
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category"`
|
||||
SystemType string `json:"system_type"`
|
||||
ExtraData map[string]any `json:"extra_data,omitempty"`
|
||||
IsRead bool `json:"is_read"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SystemMessageListResponse 系统消息列表响应
|
||||
@@ -558,144 +589,30 @@ func FormatTimePointer(t *time.Time) string {
|
||||
return FormatTime(*t)
|
||||
}
|
||||
|
||||
// ==================== Group DTOs ====================
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// ==================== WebSocket Event DTOs ====================
|
||||
|
||||
// WSEventResponse WebSocket事件响应结构体
|
||||
// 用于后端推送消息给前端的标准格式
|
||||
type WSEventResponse struct {
|
||||
ID string `json:"id"` // 事件唯一ID (UUID)
|
||||
Time int64 `json:"time"` // 时间戳 (毫秒)
|
||||
Type string `json:"type"` // 事件类型 (message, notification, system等)
|
||||
DetailType string `json:"detail_type"` // 详细类型 (private, group, like, comment等)
|
||||
Seq string `json:"seq"` // 消息序列号
|
||||
Segments model.MessageSegments `json:"segments"` // 消息段数组
|
||||
SenderID string `json:"sender_id"` // 发送者用户ID
|
||||
ID string `json:"id"` // 事件唯一ID (UUID)
|
||||
Time int64 `json:"time"` // 时间戳 (毫秒)
|
||||
Type string `json:"type"` // 事件类型 (message, notification, system等)
|
||||
DetailType string `json:"detail_type"` // 详细类型 (private, group, like, comment等)
|
||||
ConversationID string `json:"conversation_id"` // 会话ID
|
||||
Seq int64 `json:"seq"` // 消息序列号
|
||||
Segments model.MessageSegments `json:"segments"` // 消息段数组
|
||||
SenderID string `json:"sender_id"` // 发送者用户ID
|
||||
}
|
||||
|
||||
// ==================== WebSocket Request DTOs ====================
|
||||
|
||||
// SendMessageParams 发送消息参数(用于 REST API)
|
||||
type SendMessageParams struct {
|
||||
DetailType string `json:"detail_type"` // 消息类型: private, group
|
||||
ConversationID string `json:"conversation_id"` // 会话ID
|
||||
Segments model.MessageSegments `json:"segments"` // 消息内容(消息段数组)
|
||||
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID
|
||||
DetailType string `json:"detail_type"` // 消息类型: private, group
|
||||
ConversationID string `json:"conversation_id"` // 会话ID
|
||||
Segments model.MessageSegments `json:"segments"` // 消息内容(消息段数组)
|
||||
ReplyToID *string `json:"reply_to_id,omitempty"` // 回复的消息ID
|
||||
ClientMsgID string `json:"client_msg_id,omitempty"` // 客户端消息ID(幂等性去重)
|
||||
}
|
||||
|
||||
// DeleteMsgParams 撤回消息参数
|
||||
@@ -744,6 +661,12 @@ type SetGroupAvatarParams struct {
|
||||
Avatar string `json:"avatar"` // 头像URL
|
||||
}
|
||||
|
||||
// SetGroupDescriptionParams 设置群描述参数
|
||||
type SetGroupDescriptionParams struct {
|
||||
GroupID string `json:"group_id"` // 群组ID
|
||||
Description string `json:"description"` // 群描述
|
||||
}
|
||||
|
||||
// SetGroupLeaveParams 退出群组参数
|
||||
type SetGroupLeaveParams struct {
|
||||
GroupID string `json:"group_id"` // 群组ID
|
||||
@@ -866,62 +789,36 @@ type GetMyMemberInfoParams struct {
|
||||
GroupID string `json:"group_id"` // 群组ID
|
||||
}
|
||||
|
||||
// ==================== 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"` // 投票选项,至少2个最多10个
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// ==================== WebSocket Response DTOs ====================
|
||||
|
||||
// WSResponse WebSocket响应结构体
|
||||
type WSResponse struct {
|
||||
Success bool `json:"success"` // 是否成功
|
||||
Action string `json:"action"` // 响应原action
|
||||
Data interface{} `json:"data,omitempty"` // 响应数据
|
||||
Error string `json:"error,omitempty"` // 错误信息
|
||||
Success bool `json:"success"` // 是否成功
|
||||
Action string `json:"action"` // 响应原action
|
||||
Data any `json:"data,omitempty"` // 响应数据
|
||||
Error string `json:"error,omitempty"` // 错误信息
|
||||
}
|
||||
|
||||
// ==================== Admin Post DTOs ====================
|
||||
|
||||
// AdminPostListResponse 管理端帖子列表响应
|
||||
type AdminPostListResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Images []PostImageResponse `json:"images"`
|
||||
Status string `json:"status"`
|
||||
LikesCount int `json:"likes_count,omitzero"`
|
||||
CommentsCount int `json:"comments_count,omitzero"`
|
||||
FavoritesCount int `json:"favorites_count,omitzero"`
|
||||
ViewsCount int `json:"views_count,omitzero"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsFeatured bool `json:"is_featured"`
|
||||
IsLocked bool `json:"is_locked"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Images []PostImageResponse `json:"images"`
|
||||
Status string `json:"status"`
|
||||
LikesCount int `json:"likes_count,omitzero"`
|
||||
CommentsCount int `json:"comments_count,omitzero"`
|
||||
FavoritesCount int `json:"favorites_count,omitzero"`
|
||||
ViewsCount int `json:"views_count,omitzero"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsFeatured bool `json:"is_featured"`
|
||||
IsLocked bool `json:"is_locked"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ContentEditedAt string `json:"content_edited_at,omitempty"`
|
||||
@@ -930,29 +827,30 @@ type AdminPostListResponse struct {
|
||||
|
||||
// AdminPostDetailResponse 管理端帖子详情响应
|
||||
type AdminPostDetailResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ChannelID *string `json:"channel_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Images []PostImageResponse `json:"images"`
|
||||
Status string `json:"status"`
|
||||
LikesCount int `json:"likes_count,omitzero"`
|
||||
CommentsCount int `json:"comments_count,omitzero"`
|
||||
FavoritesCount int `json:"favorites_count,omitzero"`
|
||||
SharesCount int `json:"shares_count,omitzero"`
|
||||
ViewsCount int `json:"views_count,omitzero"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsFeatured bool `json:"is_featured"`
|
||||
IsLocked bool `json:"is_locked"`
|
||||
IsVote bool `json:"is_vote"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ContentEditedAt string `json:"content_edited_at,omitempty"`
|
||||
Author *UserResponse `json:"author"`
|
||||
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"`
|
||||
LikesCount int `json:"likes_count,omitzero"`
|
||||
CommentsCount int `json:"comments_count,omitzero"`
|
||||
FavoritesCount int `json:"favorites_count,omitzero"`
|
||||
SharesCount int `json:"shares_count,omitzero"`
|
||||
ViewsCount int `json:"views_count,omitzero"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsFeatured bool `json:"is_featured"`
|
||||
IsLocked bool `json:"is_locked"`
|
||||
IsVote bool `json:"is_vote"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ContentEditedAt string `json:"content_edited_at,omitempty"`
|
||||
Author *UserResponse `json:"author"`
|
||||
}
|
||||
|
||||
// AdminModeratePostRequest 审核帖子请求
|
||||
@@ -963,12 +861,12 @@ type AdminModeratePostRequest struct {
|
||||
|
||||
// AdminBatchDeletePostsRequest 批量删除帖子请求
|
||||
type AdminBatchDeletePostsRequest struct {
|
||||
IDs []string `json:"ids" binding:"required,min=1"`
|
||||
IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"`
|
||||
}
|
||||
|
||||
// AdminBatchModeratePostsRequest 批量审核帖子请求
|
||||
type AdminBatchModeratePostsRequest struct {
|
||||
IDs []string `json:"ids" binding:"required,min=1"`
|
||||
IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"`
|
||||
Status string `json:"status" binding:"required,oneof=published rejected"`
|
||||
}
|
||||
|
||||
@@ -1001,6 +899,9 @@ type AdminCommentListResponse struct {
|
||||
Content string `json:"content"`
|
||||
Images []CommentImageResponse `json:"images"`
|
||||
Status string `json:"status"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
RepliesCount int `json:"replies_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
@@ -1025,18 +926,33 @@ type AdminCommentDetailResponse struct {
|
||||
Content string `json:"content"`
|
||||
Images []CommentImageResponse `json:"images"`
|
||||
Status string `json:"status"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
RepliesCount int `json:"replies_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Author *UserResponse `json:"author"`
|
||||
Post *AdminCommentPostInfo `json:"post"`
|
||||
ReplyToUser *UserResponse `json:"reply_to_user,omitempty"` // 被回复的用户信息
|
||||
ReplyToUser *UserResponse `json:"reply_to_user,omitempty"`
|
||||
}
|
||||
|
||||
// AdminBatchDeleteCommentsRequest 批量删除评论请求
|
||||
type AdminBatchDeleteCommentsRequest struct {
|
||||
IDs []string `json:"ids" binding:"required,min=1"`
|
||||
IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"`
|
||||
}
|
||||
|
||||
// AdminModerateCommentRequest 审核评论请求
|
||||
type AdminModerateCommentRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=published rejected"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// AdminBatchModerateCommentsRequest 批量审核评论请求
|
||||
type AdminBatchModerateCommentsRequest struct {
|
||||
IDs []string `json:"ids" binding:"required,min=1,max=100,dive,required"`
|
||||
Status string `json:"status" binding:"required,oneof=published rejected"`
|
||||
}
|
||||
|
||||
// ==================== Admin Group DTOs ====================
|
||||
@@ -1132,6 +1048,9 @@ type DashboardStatsResponse struct {
|
||||
PendingReview int64 `json:"pending_review"` // 待审核总数
|
||||
PendingPostsCount int64 `json:"pending_posts_count"` // 待审核帖子数
|
||||
PendingComments int64 `json:"pending_comments_count"` // 待审核评论数
|
||||
PendingAvatars int64 `json:"pending_avatars"` // 待审核头像数
|
||||
PendingCovers int64 `json:"pending_covers"` // 待审核背景图数
|
||||
PendingBios int64 `json:"pending_bios"` // 待审核签名数
|
||||
}
|
||||
|
||||
// UserActivityTrendResponse 用户活跃度趋势响应
|
||||
@@ -1203,13 +1122,13 @@ type CursorPageRequest struct {
|
||||
}
|
||||
|
||||
// CursorPageResponse 游标分页响应(泛型版本)
|
||||
// 注意:由于 Go 的限制,这里使用 interface{} 作为 Items 类型
|
||||
// 注意:由于 Go 的限制,这里使用 any 作为 Items 类型
|
||||
// 实际使用时可以创建特定类型的响应结构体
|
||||
type CursorPageResponse struct {
|
||||
Items interface{} `json:"list"`
|
||||
NextCursor string `json:"next_cursor,omitempty"` // 下一页游标
|
||||
PrevCursor string `json:"prev_cursor,omitempty"` // 上一页游标(可选)
|
||||
HasMore bool `json:"has_more"` // 是否有更多数据
|
||||
Items any `json:"list"`
|
||||
NextCursor string `json:"next_cursor,omitempty"` // 下一页游标
|
||||
PrevCursor string `json:"prev_cursor,omitempty"` // 上一页游标(可选)
|
||||
HasMore bool `json:"has_more"` // 是否有更多数据
|
||||
}
|
||||
|
||||
// CursorPageMeta 分页元信息
|
||||
@@ -1224,6 +1143,7 @@ type PostCursorPageResponse struct {
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
IsBlocked bool `json:"is_blocked,omitempty"`
|
||||
}
|
||||
|
||||
// CommentCursorPageResponse 评论游标分页响应
|
||||
@@ -1281,3 +1201,146 @@ type GroupAnnouncementCursorPageResponse struct {
|
||||
PrevCursor string `json:"prev_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// ==================== Verification DTOs ====================
|
||||
|
||||
// VerificationStatus 审核状态
|
||||
type VerificationStatusDTO string
|
||||
|
||||
const (
|
||||
VerificationStatusNotSubmittedDTO VerificationStatusDTO = "not_submitted"
|
||||
VerificationStatusPendingDTO VerificationStatusDTO = "pending"
|
||||
VerificationStatusApprovedDTO VerificationStatusDTO = "approved"
|
||||
VerificationStatusRejectedDTO VerificationStatusDTO = "rejected"
|
||||
)
|
||||
|
||||
// UserIdentityDTO 用户身份类型
|
||||
type UserIdentityDTO string
|
||||
|
||||
const (
|
||||
UserIdentityGeneralDTO UserIdentityDTO = "general"
|
||||
UserIdentityStudentDTO UserIdentityDTO = "student"
|
||||
UserIdentityTeacherDTO UserIdentityDTO = "teacher"
|
||||
UserIdentityStaffDTO UserIdentityDTO = "staff"
|
||||
UserIdentityAlumniDTO UserIdentityDTO = "alumni"
|
||||
UserIdentityExternalDTO UserIdentityDTO = "external"
|
||||
)
|
||||
|
||||
// SubmitVerificationRequest 提交认证申请请求
|
||||
type SubmitVerificationRequest struct {
|
||||
Identity UserIdentityDTO `json:"identity" binding:"required,oneof=student teacher staff alumni"`
|
||||
RealName string `json:"real_name" binding:"required,max=100"`
|
||||
StudentID string `json:"student_id" binding:"omitempty,max=50"`
|
||||
EmployeeID string `json:"employee_id" binding:"omitempty,max=50"`
|
||||
Department string `json:"department" binding:"omitempty,max=200"`
|
||||
ProofMaterials string `json:"proof_materials" binding:"required"`
|
||||
}
|
||||
|
||||
// VerificationRecordResponse 认证记录响应
|
||||
type VerificationRecordResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Identity string `json:"identity"`
|
||||
Status string `json:"status"`
|
||||
RealName string `json:"real_name"`
|
||||
StudentID string `json:"student_id,omitempty"`
|
||||
EmployeeID string `json:"employee_id,omitempty"`
|
||||
Department string `json:"department,omitempty"`
|
||||
ProofMaterials string `json:"proof_materials"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// VerificationStatusResponse 认证状态响应
|
||||
type VerificationStatusResponse struct {
|
||||
Identity string `json:"identity"`
|
||||
VerificationStatus string `json:"verification_status"`
|
||||
HasPendingRequest bool `json:"has_pending_request"`
|
||||
LatestRecord *VerificationRecordResponse `json:"latest_record,omitempty"`
|
||||
}
|
||||
|
||||
// AdminVerificationListRequest 管理端认证列表请求
|
||||
type AdminVerificationListRequest struct {
|
||||
Page int `form:"page" binding:"omitempty,min=1"`
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"`
|
||||
Status string `form:"status" binding:"omitempty,oneof=pending approved rejected"`
|
||||
Keyword string `form:"keyword" binding:"omitempty,max=100"`
|
||||
}
|
||||
|
||||
// AdminVerificationListResponse 管理端认证列表响应
|
||||
type AdminVerificationListResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Identity string `json:"identity"`
|
||||
Status string `json:"status"`
|
||||
RealName string `json:"real_name"`
|
||||
StudentID string `json:"student_id,omitempty"`
|
||||
EmployeeID string `json:"employee_id,omitempty"`
|
||||
Department string `json:"department,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ReviewerName string `json:"reviewer_name,omitempty"`
|
||||
}
|
||||
|
||||
// AdminVerificationDetailResponse 管理端认证详情响应
|
||||
type AdminVerificationDetailResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Identity string `json:"identity"`
|
||||
Status string `json:"status"`
|
||||
RealName string `json:"real_name"`
|
||||
StudentID string `json:"student_id,omitempty"`
|
||||
EmployeeID string `json:"employee_id,omitempty"`
|
||||
Department string `json:"department,omitempty"`
|
||||
ProofMaterials string `json:"proof_materials"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
ReviewerName string `json:"reviewer_name,omitempty"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
}
|
||||
|
||||
// AdminReviewVerificationRequest 管理端审核认证请求
|
||||
type AdminReviewVerificationRequest struct {
|
||||
Approve bool `json:"approve"`
|
||||
RejectReason string `json:"reject_reason" binding:"omitempty,max=500"`
|
||||
}
|
||||
|
||||
// ==================== Privacy & Account Deletion DTOs ====================
|
||||
|
||||
// PrivacySettingsRequest 隐私设置请求
|
||||
type PrivacySettingsRequest struct {
|
||||
FollowersVisibility *string `json:"followers_visibility" binding:"omitempty,oneof=everyone following self"`
|
||||
FollowingVisibility *string `json:"following_visibility" binding:"omitempty,oneof=everyone following self"`
|
||||
PostsVisibility *string `json:"posts_visibility" binding:"omitempty,oneof=everyone following self"`
|
||||
FavoritesVisibility *string `json:"favorites_visibility" binding:"omitempty,oneof=everyone following self"`
|
||||
}
|
||||
|
||||
// PrivacySettingsResponse 隐私设置响应
|
||||
type PrivacySettingsResponse struct {
|
||||
FollowersVisibility string `json:"followers_visibility"`
|
||||
FollowingVisibility string `json:"following_visibility"`
|
||||
PostsVisibility string `json:"posts_visibility"`
|
||||
FavoritesVisibility string `json:"favorites_visibility"`
|
||||
}
|
||||
|
||||
// RequestDeletionRequest 申请注销请求
|
||||
type RequestDeletionRequest struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// DeletionStatusResponse 注销状态响应
|
||||
type DeletionStatusResponse struct {
|
||||
IsPendingDeletion bool `json:"is_pending_deletion"`
|
||||
DeletionRequestedAt string `json:"deletion_requested_at,omitempty"`
|
||||
CoolDownDays int `json:"cool_down_days,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dto
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ==================== Group 转换 ====================
|
||||
@@ -50,26 +50,6 @@ func GroupMemberToResponse(member *model.GroupMember) *GroupMemberResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// GroupMemberToResponseWithUser 将GroupMember转换为GroupMemberResponse(包含用户信息)
|
||||
func GroupMemberToResponseWithUser(member *model.GroupMember, user *model.User) *GroupMemberResponse {
|
||||
if member == nil {
|
||||
return nil
|
||||
}
|
||||
resp := GroupMemberToResponse(member)
|
||||
if user != nil {
|
||||
resp.User = ConvertUserToResponse(user)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// GroupMembersToResponse 将GroupMember列表转换为GroupMemberResponse列表
|
||||
func GroupMembersToResponse(members []model.GroupMember) []*GroupMemberResponse {
|
||||
result := make([]*GroupMemberResponse, 0, len(members))
|
||||
for i := range members {
|
||||
result = append(result, GroupMemberToResponse(&members[i]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GroupAnnouncementToResponse 将GroupAnnouncement转换为GroupAnnouncementResponse
|
||||
func GroupAnnouncementToResponse(announcement *model.GroupAnnouncement) *GroupAnnouncementResponse {
|
||||
|
||||
120
internal/dto/group_dto.go
Normal file
120
internal/dto/group_dto.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package dto
|
||||
|
||||
// ==================== Group DTOs ====================
|
||||
// 从 dto.go 拆分而来:群组相关的请求与响应 DTO。
|
||||
// 与 group_converter.go 同属群组域。
|
||||
|
||||
// CreateGroupRequest 创建群组请求
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
MemberIDs []string `json:"member_ids"`
|
||||
}
|
||||
|
||||
// UpdateGroupRequest 更新群组请求
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name" binding:"omitempty,max=50"`
|
||||
Description string `json:"description" binding:"omitempty,max=500"`
|
||||
Avatar string `json:"avatar" binding:"omitempty,url"`
|
||||
}
|
||||
|
||||
// InviteMembersRequest 邀请成员请求
|
||||
type InviteMembersRequest struct {
|
||||
MemberIDs []string `json:"member_ids" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// TransferOwnerRequest 转让群主请求
|
||||
type TransferOwnerRequest struct {
|
||||
NewOwnerID string `json:"new_owner_id" binding:"required"`
|
||||
}
|
||||
|
||||
// SetRoleRequest 设置角色请求
|
||||
type SetRoleRequest struct {
|
||||
Role string `json:"role" binding:"required,oneof=admin member"`
|
||||
}
|
||||
|
||||
// SetNicknameRequest 设置昵称请求
|
||||
type SetNicknameRequest struct {
|
||||
Nickname string `json:"nickname" binding:"max=50"`
|
||||
}
|
||||
|
||||
// MuteMemberRequest 禁言成员请求
|
||||
type MuteMemberRequest struct {
|
||||
Muted bool `json:"muted"`
|
||||
}
|
||||
|
||||
// SetMuteAllRequest 设置全员禁言请求
|
||||
type SetMuteAllRequest struct {
|
||||
MuteAll bool `json:"mute_all"`
|
||||
}
|
||||
|
||||
// SetJoinTypeRequest 设置加群方式请求
|
||||
type SetJoinTypeRequest struct {
|
||||
JoinType int `json:"join_type" binding:"min=0,max=2"`
|
||||
}
|
||||
|
||||
// CreateAnnouncementRequest 创建群公告请求
|
||||
type CreateAnnouncementRequest struct {
|
||||
Content string `json:"content" binding:"required,max=2000"`
|
||||
}
|
||||
|
||||
// GroupResponse 群组响应
|
||||
type GroupResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Avatar string `json:"avatar"`
|
||||
Description string `json:"description"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
MemberCount int `json:"member_count"`
|
||||
MaxMembers int `json:"max_members"`
|
||||
JoinType int `json:"join_type"`
|
||||
MuteAll bool `json:"mute_all"`
|
||||
IsMember bool `json:"is_member"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// GroupMemberResponse 群成员响应
|
||||
type GroupMemberResponse struct {
|
||||
ID string `json:"id"`
|
||||
GroupID string `json:"group_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
Nickname string `json:"nickname"`
|
||||
Muted bool `json:"muted"`
|
||||
JoinTime string `json:"join_time"`
|
||||
User *UserResponse `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// GroupAnnouncementResponse 群公告响应
|
||||
type GroupAnnouncementResponse struct {
|
||||
ID string `json:"id"`
|
||||
GroupID string `json:"group_id"`
|
||||
Content string `json:"content"`
|
||||
AuthorID string `json:"author_id"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// GroupListResponse 群组列表响应
|
||||
type GroupListResponse struct {
|
||||
List []*GroupResponse `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// GroupMemberListResponse 群成员列表响应
|
||||
type GroupMemberListResponse struct {
|
||||
List []*GroupMemberResponse `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// GroupAnnouncementListResponse 群公告列表响应
|
||||
type GroupAnnouncementListResponse struct {
|
||||
List []*GroupAnnouncementResponse `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
package dto
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ==================== Message 转换 ====================
|
||||
|
||||
// ConvertMessageToResponse 将Message转换为MessageResponse
|
||||
func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
||||
return ConvertMessageToResponseWithExpiry(message, nil)
|
||||
}
|
||||
|
||||
// ConvertMessageToResponseWithExpiry 将Message转换为MessageResponse,并对已过期的 file segment 注入 expired 标记。
|
||||
// expiredURLSet 为已从 S3 清理的文件 URL 集合(可为 nil,表示不标记)。
|
||||
// 注意:注入时对 file segment 的 Data 做深拷贝,避免污染消息缓存。
|
||||
func ConvertMessageToResponseWithExpiry(message *model.Message, expiredURLSet map[string]struct{}) *MessageResponse {
|
||||
if message == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -15,6 +22,19 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
||||
// 直接使用 segments,不需要解析
|
||||
segments := make(model.MessageSegments, len(message.Segments))
|
||||
for i, seg := range message.Segments {
|
||||
// 对 file 类型且 URL 已过期的 segment,深拷贝 Data 并注入 expired 标记
|
||||
if seg.Type == string(model.ContentTypeFile) && len(expiredURLSet) > 0 {
|
||||
url, _ := seg.Data["url"].(string)
|
||||
if _, expired := expiredURLSet[url]; expired {
|
||||
newData := make(map[string]any, len(seg.Data)+1)
|
||||
for k, v := range seg.Data {
|
||||
newData[k] = v
|
||||
}
|
||||
newData["expired"] = true
|
||||
segments[i] = model.MessageSegment{Type: seg.Type, Data: newData}
|
||||
continue
|
||||
}
|
||||
}
|
||||
segments[i] = model.MessageSegment{
|
||||
Type: seg.Type,
|
||||
Data: seg.Data,
|
||||
@@ -38,7 +58,7 @@ func ConvertMessageToResponse(message *model.Message) *MessageResponse {
|
||||
// participants: 会话参与者列表(用户信息)
|
||||
// unreadCount: 当前用户的未读消息数
|
||||
// lastMessage: 最后一条消息
|
||||
func ConvertConversationToResponse(conv *model.Conversation, participants []*model.User, unreadCount int, lastMessage *model.Message, isPinned bool) *ConversationResponse {
|
||||
func ConvertConversationToResponse(conv *model.Conversation, participants []*model.User, unreadCount int, lastMessage *model.Message, isPinned bool, notificationMuted bool) *ConversationResponse {
|
||||
if conv == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -55,22 +75,23 @@ func ConvertConversationToResponse(conv *model.Conversation, participants []*mod
|
||||
}
|
||||
|
||||
return &ConversationResponse{
|
||||
ID: conv.ID,
|
||||
Type: string(conv.Type),
|
||||
IsPinned: isPinned,
|
||||
Group: groupResponse,
|
||||
LastSeq: conv.LastSeq,
|
||||
LastMessage: ConvertMessageToResponse(lastMessage),
|
||||
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
||||
UnreadCount: unreadCount,
|
||||
Participants: participantResponses,
|
||||
CreatedAt: FormatTime(conv.CreatedAt),
|
||||
UpdatedAt: FormatTime(conv.UpdatedAt),
|
||||
ID: conv.ID,
|
||||
Type: string(conv.Type),
|
||||
IsPinned: isPinned,
|
||||
NotificationMuted: notificationMuted,
|
||||
Group: groupResponse,
|
||||
LastSeq: conv.LastSeq,
|
||||
LastMessage: ConvertMessageToResponse(lastMessage),
|
||||
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
||||
UnreadCount: unreadCount,
|
||||
Participants: participantResponses,
|
||||
CreatedAt: FormatTime(conv.CreatedAt),
|
||||
UpdatedAt: FormatTime(conv.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertConversationToDetailResponse 将Conversation转换为ConversationDetailResponse
|
||||
func ConvertConversationToDetailResponse(conv *model.Conversation, participants []*model.User, unreadCount int64, lastMessage *model.Message, myLastReadSeq int64, otherLastReadSeq int64, isPinned bool) *ConversationDetailResponse {
|
||||
func ConvertConversationToDetailResponse(conv *model.Conversation, participants []*model.User, unreadCount int64, lastMessage *model.Message, myLastReadSeq int64, otherLastReadSeq int64, isPinned bool, notificationMuted bool) *ConversationDetailResponse {
|
||||
if conv == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -81,18 +102,19 @@ func ConvertConversationToDetailResponse(conv *model.Conversation, participants
|
||||
}
|
||||
|
||||
return &ConversationDetailResponse{
|
||||
ID: conv.ID,
|
||||
Type: string(conv.Type),
|
||||
IsPinned: isPinned,
|
||||
LastSeq: conv.LastSeq,
|
||||
LastMessage: ConvertMessageToResponse(lastMessage),
|
||||
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
||||
UnreadCount: unreadCount,
|
||||
Participants: participantResponses,
|
||||
MyLastReadSeq: myLastReadSeq,
|
||||
OtherLastReadSeq: otherLastReadSeq,
|
||||
CreatedAt: FormatTime(conv.CreatedAt),
|
||||
UpdatedAt: FormatTime(conv.UpdatedAt),
|
||||
ID: conv.ID,
|
||||
Type: string(conv.Type),
|
||||
IsPinned: isPinned,
|
||||
NotificationMuted: notificationMuted,
|
||||
LastSeq: conv.LastSeq,
|
||||
LastMessage: ConvertMessageToResponse(lastMessage),
|
||||
LastMessageAt: FormatTimePointer(conv.LastMsgTime),
|
||||
UnreadCount: unreadCount,
|
||||
Participants: participantResponses,
|
||||
MyLastReadSeq: myLastReadSeq,
|
||||
OtherLastReadSeq: otherLastReadSeq,
|
||||
CreatedAt: FormatTime(conv.CreatedAt),
|
||||
UpdatedAt: FormatTime(conv.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,15 +127,16 @@ func ConvertMessagesToResponse(messages []*model.Message) []*MessageResponse {
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertConversationsToResponse 将Conversation列表转换为响应列表
|
||||
func ConvertConversationsToResponse(convs []*model.Conversation) []*ConversationResponse {
|
||||
result := make([]*ConversationResponse, 0, len(convs))
|
||||
for _, conv := range convs {
|
||||
result = append(result, ConvertConversationToResponse(conv, nil, 0, nil, false))
|
||||
// ConvertMessagesToResponseWithExpiry 将Message列表转换为响应列表,并标记过期文件
|
||||
func ConvertMessagesToResponseWithExpiry(messages []*model.Message, expiredURLSet map[string]struct{}) []*MessageResponse {
|
||||
result := make([]*MessageResponse, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
result = append(result, ConvertMessageToResponseWithExpiry(msg, expiredURLSet))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// ==================== PushRecord 转换 ====================
|
||||
|
||||
// PushRecordToResponse 将PushRecord转换为PushRecordResponse
|
||||
@@ -176,3 +199,34 @@ func DeviceTokensToResponse(tokens []*model.DeviceToken) []*DeviceTokenResponse
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// DeviceTokenToAdminResponse 将DeviceToken转换为管理端响应(含 registration_id)
|
||||
func DeviceTokenToAdminResponse(token *model.DeviceToken) *AdminDeviceTokenResponse {
|
||||
if token == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &AdminDeviceTokenResponse{
|
||||
ID: token.ID,
|
||||
UserID: token.UserID,
|
||||
DeviceID: token.DeviceID,
|
||||
DeviceType: string(token.DeviceType),
|
||||
PushToken: token.PushToken,
|
||||
IsActive: token.IsActive,
|
||||
DeviceName: token.DeviceName,
|
||||
CreatedAt: token.CreatedAt,
|
||||
UpdatedAt: token.UpdatedAt,
|
||||
}
|
||||
if token.LastUsedAt != nil {
|
||||
resp.LastUsedAt = *token.LastUsedAt
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// DeviceTokensToAdminResponse 将DeviceToken列表转换为管理端响应列表
|
||||
func DeviceTokensToAdminResponse(tokens []*model.DeviceToken) []*AdminDeviceTokenResponse {
|
||||
result := make([]*AdminDeviceTokenResponse, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
result = append(result, DeviceTokenToAdminResponse(token))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dto
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"with_you/internal/model"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
@@ -33,49 +33,6 @@ func ConvertNotificationsToResponse(notifications []*model.Notification) []*Noti
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== SystemMessage 转换 ====================
|
||||
|
||||
// SystemMessageToResponse 将Message转换为SystemMessageResponse
|
||||
func SystemMessageToResponse(msg *model.Message) *SystemMessageResponse {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 从 segments 中提取文本内容
|
||||
content := ExtractTextContentFromModel(msg.Segments)
|
||||
|
||||
resp := &SystemMessageResponse{
|
||||
ID: msg.ID,
|
||||
SenderID: msg.SenderID,
|
||||
ReceiverID: "", // 系统消息的接收者需要从上下文获取
|
||||
Content: content,
|
||||
Category: string(msg.Category),
|
||||
SystemType: string(msg.SystemType),
|
||||
CreatedAt: msg.CreatedAt,
|
||||
}
|
||||
if msg.ExtraData != nil {
|
||||
resp.ExtraData = map[string]interface{}{
|
||||
"actor_id": msg.ExtraData.ActorID,
|
||||
"actor_name": msg.ExtraData.ActorName,
|
||||
"avatar_url": msg.ExtraData.AvatarURL,
|
||||
"target_id": msg.ExtraData.TargetID,
|
||||
"target_type": msg.ExtraData.TargetType,
|
||||
"action_url": msg.ExtraData.ActionURL,
|
||||
"action_time": msg.ExtraData.ActionTime,
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// SystemMessagesToResponse 将Message列表转换为SystemMessageResponse列表
|
||||
func SystemMessagesToResponse(messages []*model.Message) []*SystemMessageResponse {
|
||||
result := make([]*SystemMessageResponse, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
result = append(result, SystemMessageToResponse(msg))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SystemNotificationToResponse 将SystemNotification转换为SystemMessageResponse
|
||||
func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageResponse {
|
||||
if n == nil {
|
||||
@@ -92,7 +49,7 @@ func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageRes
|
||||
CreatedAt: n.CreatedAt,
|
||||
}
|
||||
if n.ExtraData != nil {
|
||||
resp.ExtraData = map[string]interface{}{
|
||||
resp.ExtraData = map[string]any{
|
||||
"actor_id": n.ExtraData.ActorID,
|
||||
"actor_id_str": n.ExtraData.ActorIDStr,
|
||||
"actor_name": n.ExtraData.ActorName,
|
||||
@@ -118,11 +75,3 @@ func SystemNotificationToResponse(n *model.SystemNotification) *SystemMessageRes
|
||||
return resp
|
||||
}
|
||||
|
||||
// SystemNotificationsToResponse 将SystemNotification列表转换为SystemMessageResponse列表
|
||||
func SystemNotificationsToResponse(notifications []*model.SystemNotification) []*SystemMessageResponse {
|
||||
result := make([]*SystemMessageResponse, 0, len(notifications))
|
||||
for _, n := range notifications {
|
||||
result = append(result, SystemNotificationToResponse(n))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ==================== Post 转换 ====================
|
||||
@@ -14,11 +14,13 @@ func ConvertPostImageToResponse(img *model.PostImage) PostImageResponse {
|
||||
return PostImageResponse{}
|
||||
}
|
||||
return PostImageResponse{
|
||||
ID: img.ID,
|
||||
URL: img.URL,
|
||||
ThumbnailURL: img.ThumbnailURL,
|
||||
Width: img.Width,
|
||||
Height: img.Height,
|
||||
ID: img.ID,
|
||||
URL: img.URL,
|
||||
ThumbnailURL: img.ThumbnailURL,
|
||||
PreviewURL: img.PreviewURL,
|
||||
PreviewURLLarge: img.PreviewURLLarge,
|
||||
Width: img.Width,
|
||||
Height: img.Height,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +33,56 @@ func ConvertPostImagesToResponse(images []model.PostImage) []PostImageResponse {
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertPostToResponse 将Post转换为PostResponse(列表用)
|
||||
func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse {
|
||||
func postChannelBrief(post *model.Post, channelByID map[string]*model.Channel) *PostChannelBrief {
|
||||
if post == nil || post.ChannelID == nil || *post.ChannelID == "" || channelByID == nil {
|
||||
return nil
|
||||
}
|
||||
ch := channelByID[*post.ChannelID]
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
return &PostChannelBrief{ID: ch.ID, Name: ch.Name}
|
||||
}
|
||||
|
||||
// CollectPostChannelIDs 从帖子列表收集去重后的频道 ID(用于批量查库填充 channel 名称)
|
||||
func CollectPostChannelIDs(posts []*model.Post) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0)
|
||||
for _, p := range posts {
|
||||
if p == nil || p.ChannelID == nil || *p.ChannelID == "" {
|
||||
continue
|
||||
}
|
||||
id := *p.ChannelID
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PostChannelBriefForPost 根据帖子与频道映射生成频道摘要(供 Handler 手动组装响应)
|
||||
func PostChannelBriefForPost(post *model.Post, channelByID map[string]*model.Channel) *PostChannelBrief {
|
||||
return postChannelBrief(post, channelByID)
|
||||
}
|
||||
|
||||
// SegmentsOrDefault 如果 segments 为空但 content 非空,
|
||||
// 返回降级的单 text segment;否则原样返回。
|
||||
func SegmentsOrDefault(segments model.MessageSegments, content string) model.MessageSegments {
|
||||
if len(segments) > 0 {
|
||||
return segments
|
||||
}
|
||||
if content != "" {
|
||||
return model.MessageSegments{
|
||||
{Type: string(SegmentTypeText), Data: map[string]any{"text": content}},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertPostToResponse 将Post转换为PostResponse(列表用);channelByID 可为 nil
|
||||
func ConvertPostToResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostResponse {
|
||||
if post == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -53,6 +103,7 @@ func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostRes
|
||||
ChannelID: post.ChannelID,
|
||||
Title: post.Title,
|
||||
Content: post.Content,
|
||||
Segments: SegmentsOrDefault(post.Segments, post.Content),
|
||||
Images: images,
|
||||
Status: string(post.Status),
|
||||
LikesCount: post.LikesCount,
|
||||
@@ -69,52 +120,12 @@ func ConvertPostToResponse(post *model.Post, isLiked, isFavorited bool) *PostRes
|
||||
Author: author,
|
||||
IsLiked: isLiked,
|
||||
IsFavorited: isFavorited,
|
||||
Channel: postChannelBrief(post, channelByID),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertPostToDetailResponse 将Post转换为PostDetailResponse
|
||||
func ConvertPostToDetailResponse(post *model.Post, isLiked, isFavorited bool) *PostDetailResponse {
|
||||
if post == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
images := make([]PostImageResponse, 0)
|
||||
for _, img := range post.Images {
|
||||
images = append(images, ConvertPostImageToResponse(&img))
|
||||
}
|
||||
|
||||
var author *UserResponse
|
||||
if post.User != nil {
|
||||
author = ConvertUserToResponse(post.User)
|
||||
}
|
||||
|
||||
return &PostDetailResponse{
|
||||
ID: post.ID,
|
||||
UserID: post.UserID,
|
||||
ChannelID: post.ChannelID,
|
||||
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),
|
||||
ContentEditedAt: FormatTimePointer(post.ContentEditedAt),
|
||||
Author: author,
|
||||
IsLiked: isLiked,
|
||||
IsFavorited: isFavorited,
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertPostsToResponse 将Post列表转换为响应列表(每个帖子独立检查点赞/收藏状态)
|
||||
func ConvertPostsToResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||
// ConvertPostsToResponse 将Post列表转换为响应列表;channelByID 可为 nil
|
||||
func ConvertPostsToResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||
result := make([]*PostResponse, 0, len(posts))
|
||||
for _, post := range posts {
|
||||
isLiked := false
|
||||
@@ -125,21 +136,21 @@ func ConvertPostsToResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[
|
||||
if isFavoritedMap != nil {
|
||||
isFavorited = isFavoritedMap[post.ID]
|
||||
}
|
||||
result = append(result, ConvertPostToResponse(post, isLiked, isFavorited))
|
||||
result = append(result, ConvertPostToResponse(post, channelByID, isLiked, isFavorited))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// BuildPostResponse 构建单个帖子响应(包含交互状态)
|
||||
// 这是一个语义化的辅助函数,便于 Handler 层调用
|
||||
func BuildPostResponse(post *model.Post, isLiked, isFavorited bool) *PostResponse {
|
||||
return ConvertPostToResponse(post, isLiked, isFavorited)
|
||||
func BuildPostResponse(post *model.Post, channelByID map[string]*model.Channel, isLiked, isFavorited bool) *PostResponse {
|
||||
return ConvertPostToResponse(post, channelByID, isLiked, isFavorited)
|
||||
}
|
||||
|
||||
// BuildPostsWithInteractionResponse 批量构建帖子响应(包含交互状态)
|
||||
// 这是一个语义化的辅助函数,便于 Handler 层调用
|
||||
func BuildPostsWithInteractionResponse(posts []*model.Post, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||
return ConvertPostsToResponse(posts, isLikedMap, isFavoritedMap)
|
||||
func BuildPostsWithInteractionResponse(posts []*model.Post, channelByID map[string]*model.Channel, isLikedMap, isFavoritedMap map[string]bool) []*PostResponse {
|
||||
return ConvertPostsToResponse(posts, channelByID, isLikedMap, isFavoritedMap)
|
||||
}
|
||||
|
||||
// ==================== Comment 转换 ====================
|
||||
@@ -189,6 +200,7 @@ func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResp
|
||||
ParentID: comment.ParentID,
|
||||
RootID: comment.RootID,
|
||||
Content: comment.Content,
|
||||
Segments: SegmentsOrDefault(comment.Segments, comment.Content),
|
||||
Images: images,
|
||||
LikesCount: comment.LikesCount,
|
||||
RepliesCount: comment.RepliesCount,
|
||||
@@ -200,14 +212,6 @@ func ConvertCommentToResponse(comment *model.Comment, isLiked bool) *CommentResp
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertCommentsToResponse 将Comment列表转换为响应列表
|
||||
func ConvertCommentsToResponse(comments []*model.Comment, isLiked bool) []*CommentResponse {
|
||||
result := make([]*CommentResponse, 0, len(comments))
|
||||
for _, comment := range comments {
|
||||
result = append(result, ConvertCommentToResponse(comment, isLiked))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsLikedChecker 点赞状态检查器接口
|
||||
type IsLikedChecker interface {
|
||||
@@ -265,6 +269,7 @@ func ConvertCommentToResponseWithUser(comment *model.Comment, userID string, che
|
||||
ParentID: comment.ParentID,
|
||||
RootID: comment.RootID,
|
||||
Content: comment.Content,
|
||||
Segments: SegmentsOrDefault(comment.Segments, comment.Content),
|
||||
Images: images,
|
||||
LikesCount: comment.LikesCount,
|
||||
RepliesCount: comment.RepliesCount,
|
||||
|
||||
92
internal/dto/post_dto.go
Normal file
92
internal/dto/post_dto.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package dto
|
||||
|
||||
import "with_you/internal/model"
|
||||
|
||||
// ==================== Post DTOs ====================
|
||||
// 从 dto.go 拆分而来:帖子与投票相关的请求/响应 DTO。
|
||||
// 与 post_converter.go 同属帖子域。
|
||||
|
||||
// PostChannelBrief 帖子所属频道(列表/详情卡片展示)
|
||||
type PostChannelBrief struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// PostImageResponse 帖子图片响应
|
||||
type PostImageResponse struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
PreviewURL string `json:"preview_url"` // 列表/网格预览图
|
||||
PreviewURLLarge string `json:"preview_url_large"` // 详情页预览图
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// PostResponse 帖子响应(列表用)
|
||||
type PostResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ChannelID *string `json:"channel_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments,omitempty"`
|
||||
Images []PostImageResponse `json:"images"`
|
||||
Status string `json:"status,omitempty"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
FavoritesCount int `json:"favorites_count"`
|
||||
SharesCount int `json:"shares_count"`
|
||||
ViewsCount int `json:"views_count"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsLocked bool `json:"is_locked"`
|
||||
IsVote bool `json:"is_vote"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ContentEditedAt string `json:"content_edited_at,omitempty"`
|
||||
Author *UserResponse `json:"author"`
|
||||
IsLiked bool `json:"is_liked"`
|
||||
IsFavorited bool `json:"is_favorited"`
|
||||
Channel *PostChannelBrief `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Vote DTOs ====================
|
||||
|
||||
// CreateVotePostRequest 创建投票帖子请求
|
||||
type CreateVotePostRequest struct {
|
||||
Title string `json:"title" binding:"required,max=200"`
|
||||
Content string `json:"content" binding:"max=2000"`
|
||||
ChannelID *string `json:"channel_id,omitempty"`
|
||||
Images []string `json:"images"`
|
||||
VoteOptions []string `json:"vote_options" binding:"required,min=2,max=10"`
|
||||
// ClientRequestID 客户端为本次发帖生成的幂等键(UUID)。
|
||||
// 同一 userID + ClientRequestID 在 TTL 内重复提交时,后端直接返回首次创建的帖子,避免重复发帖。
|
||||
ClientRequestID string `json:"client_request_id,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePostWithSegmentsRequest 创建帖子请求(含 segments)
|
||||
type CreatePostWithSegmentsRequest struct {
|
||||
Title string `json:"title" binding:"required,max=200"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments"`
|
||||
Images []string `json:"images"`
|
||||
ChannelID *string `json:"channel_id,omitempty"`
|
||||
// ClientRequestID 客户端为本次发帖生成的幂等键(UUID)。
|
||||
// 同一 userID + ClientRequestID 在 TTL 内重复提交时,后端直接返回首次创建的帖子,避免重复发帖。
|
||||
ClientRequestID string `json:"client_request_id,omitempty"`
|
||||
}
|
||||
|
||||
// VoteOptionDTO 投票选项DTO
|
||||
type VoteOptionDTO struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
VotesCount int `json:"votes_count"`
|
||||
}
|
||||
|
||||
// VoteResultDTO 投票结果DTO
|
||||
type VoteResultDTO struct {
|
||||
Options []VoteOptionDTO `json:"options"`
|
||||
TotalVotes int `json:"total_votes"`
|
||||
HasVoted bool `json:"has_voted"`
|
||||
VotedOptionID string `json:"voted_option_id,omitzero"`
|
||||
}
|
||||
171
internal/dto/report.go
Normal file
171
internal/dto/report.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ==================== 举报 DTOs ====================
|
||||
|
||||
// CreateReportRequest 创建举报请求
|
||||
type CreateReportRequest struct {
|
||||
TargetType string `json:"target_type" binding:"required,oneof=post comment message"`
|
||||
TargetID string `json:"target_id" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required,oneof=spam inappropriate harassment misinformation other"`
|
||||
Description string `json:"description" binding:"max=500"`
|
||||
}
|
||||
|
||||
// ReportResponse 举报响应
|
||||
type ReportResponse struct {
|
||||
ID string `json:"id"`
|
||||
ReporterID string `json:"reporter_id"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Reason string `json:"reason"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminReportListResponse 管理端举报列表响应
|
||||
type AdminReportListResponse struct {
|
||||
ID string `json:"id"`
|
||||
ReporterID string `json:"reporter_id"`
|
||||
Reporter *UserResponse `json:"reporter,omitempty"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Reason string `json:"reason"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
HandledBy *string `json:"handled_by,omitempty"`
|
||||
Handler *UserResponse `json:"handler,omitempty"`
|
||||
HandledAt *string `json:"handled_at,omitempty"`
|
||||
Result *string `json:"result,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminReportDetailResponse 管理端举报详情响应
|
||||
type AdminReportDetailResponse struct {
|
||||
ID string `json:"id"`
|
||||
ReporterID string `json:"reporter_id"`
|
||||
Reporter *UserResponse `json:"reporter"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Reason string `json:"reason"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
HandledBy *string `json:"handled_by,omitempty"`
|
||||
Handler *UserResponse `json:"handler,omitempty"`
|
||||
HandledAt *string `json:"handled_at,omitempty"`
|
||||
Result *string `json:"result,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
|
||||
// 被举报内容详情
|
||||
TargetContent *ReportTargetContent `json:"target_content,omitempty"`
|
||||
}
|
||||
|
||||
// ReportTargetContent 被举报内容详情
|
||||
type ReportTargetContent struct {
|
||||
Type string `json:"type"` // post, comment, message
|
||||
ID string `json:"id"`
|
||||
Author *UserResponse `json:"author,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Title string `json:"title,omitempty"` // 帖子标题
|
||||
Images []string `json:"images,omitempty"` // 图片列表
|
||||
Status string `json:"status,omitempty"` // 内容状态
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// HandleReportRequest 处理举报请求
|
||||
type HandleReportRequest struct {
|
||||
Action string `json:"action" binding:"required,oneof=approve reject"` // approve: 确认违规, reject: 驳回
|
||||
Result string `json:"result" binding:"max=500"` // 处理结果说明
|
||||
}
|
||||
|
||||
// BatchHandleReportRequest 批量处理举报请求
|
||||
type BatchHandleReportRequest struct {
|
||||
IDs []string `json:"ids" binding:"required,min=1,max=100"`
|
||||
Action string `json:"action" binding:"required,oneof=approve reject"`
|
||||
Result string `json:"result" binding:"max=500"`
|
||||
}
|
||||
|
||||
// AdminBatchHandleResponse 批量处理响应
|
||||
type AdminBatchHandleResponse struct {
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
FailedIDs []string `json:"failed_ids,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== 转换函数 ====================
|
||||
|
||||
// ConvertReportToResponse 将 Report 模型转换为响应
|
||||
func ConvertReportToResponse(report *model.Report) *ReportResponse {
|
||||
return &ReportResponse{
|
||||
ID: report.ID,
|
||||
ReporterID: report.ReporterID,
|
||||
TargetType: string(report.TargetType),
|
||||
TargetID: report.TargetID,
|
||||
Reason: string(report.Reason),
|
||||
Description: report.Description,
|
||||
Status: string(report.Status),
|
||||
CreatedAt: FormatTime(report.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func fillAdminReportCommon(resp *AdminReportListResponse, report *model.Report, reporter *model.User, handler *model.User) {
|
||||
if reporter != nil {
|
||||
resp.Reporter = ConvertUserToResponse(reporter)
|
||||
}
|
||||
if handler != nil {
|
||||
resp.Handler = ConvertUserToResponse(handler)
|
||||
}
|
||||
if report.HandledAt != nil {
|
||||
handledAt := FormatTime(*report.HandledAt)
|
||||
resp.HandledAt = &handledAt
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertReportToAdminListResponse 将 Report 转换为管理端列表响应
|
||||
func ConvertReportToAdminListResponse(report *model.Report, reporter *model.User, handler *model.User) *AdminReportListResponse {
|
||||
resp := &AdminReportListResponse{
|
||||
ID: report.ID,
|
||||
ReporterID: report.ReporterID,
|
||||
TargetType: string(report.TargetType),
|
||||
TargetID: report.TargetID,
|
||||
Reason: string(report.Reason),
|
||||
Description: report.Description,
|
||||
Status: string(report.Status),
|
||||
HandledBy: report.HandledBy,
|
||||
Result: report.Result,
|
||||
CreatedAt: FormatTime(report.CreatedAt),
|
||||
}
|
||||
fillAdminReportCommon(resp, report, reporter, handler)
|
||||
return resp
|
||||
}
|
||||
|
||||
// ConvertReportToAdminDetailResponse 将 Report 转换为管理端详情响应
|
||||
func ConvertReportToAdminDetailResponse(report *model.Report, reporter *model.User, handler *model.User, targetContent *ReportTargetContent) *AdminReportDetailResponse {
|
||||
resp := &AdminReportDetailResponse{
|
||||
ID: report.ID,
|
||||
ReporterID: report.ReporterID,
|
||||
TargetType: string(report.TargetType),
|
||||
TargetID: report.TargetID,
|
||||
Reason: string(report.Reason),
|
||||
Description: report.Description,
|
||||
Status: string(report.Status),
|
||||
HandledBy: report.HandledBy,
|
||||
Result: report.Result,
|
||||
CreatedAt: FormatTime(report.CreatedAt),
|
||||
TargetContent: targetContent,
|
||||
}
|
||||
if reporter != nil {
|
||||
resp.Reporter = ConvertUserToResponse(reporter)
|
||||
}
|
||||
if handler != nil {
|
||||
resp.Handler = ConvertUserToResponse(handler)
|
||||
}
|
||||
if report.HandledAt != nil {
|
||||
handledAt := FormatTime(*report.HandledAt)
|
||||
resp.HandledAt = &handledAt
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package dto
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
func ConvertScheduleCourseToResponse(course *model.ScheduleCourse, weeks []int) *ScheduleCourseResponse {
|
||||
|
||||
@@ -5,160 +5,38 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
"with_you/internal/model"
|
||||
)
|
||||
|
||||
// ParseSegmentData 解析Segment数据到目标结构体
|
||||
func ParseSegmentData(segment model.MessageSegment, target interface{}) error {
|
||||
dataBytes, err := json.Marshal(segment.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(dataBytes, target)
|
||||
}
|
||||
|
||||
// NewTextSegment 创建文本Segment
|
||||
func NewTextSegment(content string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeText),
|
||||
Data: map[string]interface{}{"text": content},
|
||||
Data: map[string]any{"text": content},
|
||||
}
|
||||
}
|
||||
|
||||
// NewImageSegment 创建图片Segment
|
||||
func NewImageSegment(url string, width, height int, thumbnailURL string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeImage),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"thumbnail_url": thumbnailURL,
|
||||
},
|
||||
// ExtractVoteSegmentData 从消息链中提取投票Segment数据
|
||||
func ExtractVoteSegmentData(segments model.MessageSegments) *VoteSegmentData {
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(SegmentTypeVote) {
|
||||
dataBytes, err := json.Marshal(segment.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var voteData VoteSegmentData
|
||||
if err := json.Unmarshal(dataBytes, &voteData); err != nil {
|
||||
continue
|
||||
}
|
||||
return &voteData
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewImageSegmentWithSize 创建带文件大小的图片Segment
|
||||
func NewImageSegmentWithSize(url string, width, height int, thumbnailURL string, fileSize int64) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeImage),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"thumbnail_url": thumbnailURL,
|
||||
"file_size": fileSize,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewVoiceSegment 创建语音Segment
|
||||
func NewVoiceSegment(url string, duration int, fileSize int64) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeVoice),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"duration": duration,
|
||||
"file_size": fileSize,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewVideoSegment 创建视频Segment
|
||||
func NewVideoSegment(url string, width, height, duration int, thumbnailURL string, fileSize int64) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeVideo),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"duration": duration,
|
||||
"thumbnail_url": thumbnailURL,
|
||||
"file_size": fileSize,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewFileSegment 创建文件Segment
|
||||
func NewFileSegment(url, name string, size int64, mimeType string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeFile),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"name": name,
|
||||
"size": size,
|
||||
"mime_type": mimeType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewAtSegment 创建@Segment,只存储 user_id,昵称由前端根据群成员列表实时解析
|
||||
func NewAtSegment(userID string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeAt),
|
||||
Data: map[string]interface{}{
|
||||
"user_id": userID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewAtAllSegment 创建@所有人Segment
|
||||
func NewAtAllSegment() model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeAt),
|
||||
Data: map[string]interface{}{
|
||||
"user_id": "all",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewReplySegment 创建回复Segment
|
||||
func NewReplySegment(messageID string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeReply),
|
||||
Data: map[string]interface{}{"id": messageID},
|
||||
}
|
||||
}
|
||||
|
||||
// NewFaceSegment 创建表情Segment
|
||||
func NewFaceSegment(id int, name, url string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeFace),
|
||||
Data: map[string]interface{}{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"url": url,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewLinkSegment 创建链接Segment
|
||||
func NewLinkSegment(url, title, description, image string) model.MessageSegment {
|
||||
return model.MessageSegment{
|
||||
Type: string(SegmentTypeLink),
|
||||
Data: map[string]interface{}{
|
||||
"url": url,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"image": image,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractTextContentFromJSON 从JSON格式的segments中提取纯文本内容
|
||||
// 用于从数据库读取的 []byte 格式的 segments
|
||||
// 已废弃:现在数据库直接存储 model.MessageSegments 类型
|
||||
func ExtractTextContentFromJSON(segmentsJSON []byte) string {
|
||||
if len(segmentsJSON) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var segments model.MessageSegments
|
||||
if err := json.Unmarshal(segmentsJSON, &segments); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ExtractTextContentFromModel(segments)
|
||||
// HasVoteSegment 检查消息链中是否包含投票Segment
|
||||
func HasVoteSegment(segments model.MessageSegments) bool {
|
||||
return HasSegmentType(segments, SegmentTypeVote)
|
||||
}
|
||||
|
||||
// ExtractTextContentFromModel 从 model.MessageSegments 中提取纯文本内容
|
||||
@@ -202,6 +80,14 @@ func ExtractTextContentFromModel(segments model.MessageSegments) string {
|
||||
} else {
|
||||
result += "[链接]"
|
||||
}
|
||||
case "vote":
|
||||
result += "[投票]"
|
||||
case "post_ref":
|
||||
if title, ok := segment.Data["title"].(string); ok && title != "" {
|
||||
result += fmt.Sprintf("[帖子:%s]", title)
|
||||
} else {
|
||||
result += "[帖子引用]"
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -231,65 +117,21 @@ func ExtractMentionedUsers(segments model.MessageSegments) []string {
|
||||
return userIDs
|
||||
}
|
||||
|
||||
// IsAtAll 检查消息是否@了所有人
|
||||
func IsAtAll(segments model.MessageSegments) bool {
|
||||
return slices.ContainsFunc(segments, func(segment model.MessageSegment) bool {
|
||||
if segment.Type == string(SegmentTypeAt) {
|
||||
if userID, ok := segment.Data["user_id"].(string); ok && userID == "all" {
|
||||
return true
|
||||
// ExtractReferencedPosts 从消息链中提取所有被引用的帖子ID
|
||||
func ExtractReferencedPosts(segments model.MessageSegments) []string {
|
||||
var postIDs []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, segment := range segments {
|
||||
if segment.Type == string(SegmentTypePostRef) {
|
||||
postID, _ := segment.Data["post_id"].(string)
|
||||
if postID != "" && !seen[postID] {
|
||||
postIDs = append(postIDs, postID)
|
||||
seen[postID] = true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// GetReplyMessageID 从消息链中获取被回复的消息ID
|
||||
// 如果没有回复segment,返回空字符串
|
||||
func GetReplyMessageID(segments model.MessageSegments) string {
|
||||
idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
|
||||
return segment.Type == string(SegmentTypeReply)
|
||||
})
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
if id, ok := segments[idx].Data["id"].(string); ok {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildSegmentsFromContent 从旧版content构建segments
|
||||
// 用于兼容旧版本消息
|
||||
func BuildSegmentsFromContent(contentType, content string, mediaURL *string) model.MessageSegments {
|
||||
var segments model.MessageSegments
|
||||
|
||||
switch contentType {
|
||||
case "text":
|
||||
segments = append(segments, NewTextSegment(content))
|
||||
case "image":
|
||||
if mediaURL != nil {
|
||||
segments = append(segments, NewImageSegment(*mediaURL, 0, 0, ""))
|
||||
}
|
||||
case "voice":
|
||||
if mediaURL != nil {
|
||||
segments = append(segments, NewVoiceSegment(*mediaURL, 0, 0))
|
||||
}
|
||||
case "video":
|
||||
if mediaURL != nil {
|
||||
segments = append(segments, NewVideoSegment(*mediaURL, 0, 0, 0, "", 0))
|
||||
}
|
||||
case "file":
|
||||
if mediaURL != nil {
|
||||
segments = append(segments, NewFileSegment(*mediaURL, content, 0, ""))
|
||||
}
|
||||
default:
|
||||
// 默认当作文本处理
|
||||
if content != "" {
|
||||
segments = append(segments, NewTextSegment(content))
|
||||
}
|
||||
}
|
||||
|
||||
return segments
|
||||
return postIDs
|
||||
}
|
||||
|
||||
// HasSegmentType 检查消息链中是否包含指定类型的segment
|
||||
@@ -299,71 +141,3 @@ func HasSegmentType(segments model.MessageSegments, segmentType SegmentType) boo
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
|
||||
return segment.Type == string(SegmentTypeImage)
|
||||
})
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
if url, ok := segments[idx].Data["url"].(string); ok {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetFirstMediaURL 获取消息链中第一个媒体文件的URL(图片/视频/语音/文件)
|
||||
// 用于兼容旧版本API
|
||||
func GetFirstMediaURL(segments model.MessageSegments) string {
|
||||
idx := slices.IndexFunc(segments, func(segment model.MessageSegment) bool {
|
||||
switch segment.Type {
|
||||
case string(SegmentTypeImage), string(SegmentTypeVideo), string(SegmentTypeVoice), string(SegmentTypeFile):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
if url, ok := segments[idx].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,179 +1,101 @@
|
||||
package dto
|
||||
package dto
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/utils"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/utils"
|
||||
)
|
||||
|
||||
// ==================== User 转换 ====================
|
||||
type UserResponseOption func(*UserResponse)
|
||||
|
||||
func WithFollowing(isFollowing, isFollowingMe bool) UserResponseOption {
|
||||
return func(r *UserResponse) {
|
||||
r.IsFollowing = isFollowing
|
||||
r.IsFollowingMe = isFollowingMe
|
||||
}
|
||||
}
|
||||
|
||||
func WithPostsCount(count int) UserResponseOption {
|
||||
return func(r *UserResponse) {
|
||||
r.PostsCount = count
|
||||
}
|
||||
}
|
||||
|
||||
// getAvatarOrDefault 获取头像URL,如果为空则返回在线头像生成服务的URL
|
||||
func getAvatarOrDefault(user *model.User) string {
|
||||
return utils.GetAvatarOrDefault(user.Username, user.Nickname, user.Avatar)
|
||||
}
|
||||
|
||||
// ConvertUserToResponse 将User转换为UserResponse(公开信息,不包含敏感数据)
|
||||
func ConvertUserToResponse(user *model.User) *UserResponse {
|
||||
// publicVerificationFields 返回认证信息:
|
||||
// - approved 时返回真实身份和状态
|
||||
// - 其他状态统一返回 general + none,不暴露具体审核状态
|
||||
func publicVerificationFields(user *model.User) (model.UserIdentity, model.VerificationStatus) {
|
||||
if user.VerificationStatus == model.VerificationStatusApproved {
|
||||
return user.Identity, user.VerificationStatus
|
||||
}
|
||||
return model.UserIdentityGeneral, model.VerificationStatusNone
|
||||
}
|
||||
|
||||
func baseUserResponse(user *model.User) *UserResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
identity, vs := publicVerificationFields(user)
|
||||
return &UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: user.PostsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: user.PostsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
Identity: identity,
|
||||
VerificationStatus: vs,
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUserToResponse 将User转换为UserResponse(公开信息,不包含敏感数据)
|
||||
func ConvertUserToResponse(user *model.User, opts ...UserResponseOption) *UserResponse {
|
||||
r := baseUserResponse(user)
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ConvertUserToSelfResponse 将User转换为UserSelfResponse(本人信息,包含敏感数据)
|
||||
func ConvertUserToSelfResponse(user *model.User, postsCount int) *UserSelfResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserSelfResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: postsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsVerified: user.IsVerified,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUserToResponseWithFollowing 将User转换为UserResponse(包含关注状态,公开信息)
|
||||
func ConvertUserToResponseWithFollowing(user *model.User, isFollowing bool) *UserResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: user.PostsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsFollowing: isFollowing,
|
||||
IsFollowingMe: false,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUserToResponseWithPostsCount 将User转换为UserResponse(使用实时计算的帖子数量,公开信息)
|
||||
func ConvertUserToResponseWithPostsCount(user *model.User, postsCount int) *UserResponse {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
return &UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
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,
|
||||
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,
|
||||
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),
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: postsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsVerified: user.IsVerified,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
|
||||
Identity: user.Identity,
|
||||
VerificationStatus: user.VerificationStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,50 +105,50 @@ func ConvertUserToDetailResponseWithPostsCount(user *model.User, postsCount int)
|
||||
return nil
|
||||
}
|
||||
return &UserDetailResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Phone: user.Phone,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: postsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsVerified: user.IsVerified,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Phone: user.Phone,
|
||||
Avatar: getAvatarOrDefault(user),
|
||||
CoverURL: user.CoverURL,
|
||||
Bio: user.Bio,
|
||||
Website: user.Website,
|
||||
Location: user.Location,
|
||||
PostsCount: postsCount,
|
||||
FollowersCount: user.FollowersCount,
|
||||
FollowingCount: user.FollowingCount,
|
||||
IsVerified: user.IsVerified,
|
||||
CreatedAt: FormatTime(user.CreatedAt),
|
||||
PrivacySettings: convertPrivacySettingsToResponse(&user.PrivacySettings),
|
||||
Identity: user.Identity,
|
||||
VerificationStatus: user.VerificationStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func convertPrivacySettingsToResponse(ps *model.PrivacySettings) *PrivacySettingsResponse {
|
||||
if ps == nil {
|
||||
return nil
|
||||
}
|
||||
return &PrivacySettingsResponse{
|
||||
FollowersVisibility: string(ps.FollowersVisibility),
|
||||
FollowingVisibility: string(ps.FollowingVisibility),
|
||||
PostsVisibility: string(ps.PostsVisibility),
|
||||
FavoritesVisibility: string(ps.FavoritesVisibility),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertUsersToResponse 将User列表转换为响应列表
|
||||
func ConvertUsersToResponse(users []*model.User) []*UserResponse {
|
||||
func ConvertUsersToResponse(users []*model.User, opts ...UserResponseOption) []*UserResponse {
|
||||
result := make([]*UserResponse, 0, len(users))
|
||||
for _, user := range users {
|
||||
result = append(result, ConvertUserToResponse(user))
|
||||
result = append(result, ConvertUserToResponse(user, opts...))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertUsersToResponseWithMutualFollow 将User列表转换为响应列表(包含双向关注状态)
|
||||
// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe]
|
||||
func ConvertUsersToResponseWithMutualFollow(users []*model.User, followingStatusMap map[string][2]bool) []*UserResponse {
|
||||
result := make([]*UserResponse, 0, len(users))
|
||||
for _, user := range users {
|
||||
status, ok := followingStatusMap[user.ID]
|
||||
if ok {
|
||||
result = append(result, ConvertUserToResponseWithMutualFollow(user, status[0], status[1]))
|
||||
} else {
|
||||
result = append(result, ConvertUserToResponse(user))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表(包含双向关注状态和实时计算的帖子数量)
|
||||
// ConvertUsersToResponseWithMutualFollowAndPostsCount 将User列表转换为响应列表(包含双向关注状态和帖子数量)
|
||||
// followingStatusMap: key是用户ID,value是[isFollowing, isFollowingMe]
|
||||
// postsCountMap: key是用户ID,value是帖子数量
|
||||
func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, followingStatusMap map[string][2]bool, postsCountMap map[string]int64) []*UserResponse {
|
||||
@@ -234,17 +156,15 @@ func ConvertUsersToResponseWithMutualFollowAndPostsCount(users []*model.User, fo
|
||||
for _, user := range users {
|
||||
status, hasStatus := followingStatusMap[user.ID]
|
||||
postsCount, hasPostsCount := postsCountMap[user.ID]
|
||||
|
||||
// 如果没有帖子数量,使用数据库中的值
|
||||
if !hasPostsCount {
|
||||
postsCount = int64(user.PostsCount)
|
||||
}
|
||||
|
||||
var opts []UserResponseOption
|
||||
if hasStatus {
|
||||
result = append(result, ConvertUserToResponseWithMutualFollowAndPostsCount(user, status[0], status[1], int(postsCount)))
|
||||
} else {
|
||||
result = append(result, ConvertUserToResponseWithPostsCount(user, int(postsCount)))
|
||||
opts = append(opts, WithFollowing(status[0], status[1]))
|
||||
}
|
||||
opts = append(opts, WithPostsCount(int(postsCount)))
|
||||
result = append(result, ConvertUserToResponse(user, opts...))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -99,6 +99,59 @@ var (
|
||||
ErrCannotModifyOwnRole = &AppError{Code: "CANNOT_MODIFY_OWN_ROLE", Message: "不能修改自己的角色"}
|
||||
ErrCannotModifySuperAdmin = &AppError{Code: "CANNOT_MODIFY_SUPER_ADMIN", Message: "不能修改超级管理员角色"}
|
||||
ErrCasbinInternal = &AppError{Code: "CASBIN_INTERNAL_ERROR", Message: "权限系统内部错误"}
|
||||
|
||||
// 通话相关错误
|
||||
ErrCallNotFound = &AppError{Code: "CALL_NOT_FOUND", Message: "通话不存在"}
|
||||
ErrCallNotActive = &AppError{Code: "CALL_NOT_ACTIVE", Message: "通话未激活"}
|
||||
ErrCallInProgress = &AppError{Code: "CALL_IN_PROGRESS", Message: "用户之间已有通话"}
|
||||
ErrNotCallParticipant = &AppError{Code: "NOT_CALL_PARTICIPANT", Message: "不是通话参与者"}
|
||||
ErrInvalidCallState = &AppError{Code: "INVALID_CALL_STATE", Message: "通话状态无效"}
|
||||
ErrCalleeOffline = &AppError{Code: "CALLEE_OFFLINE", Message: "对方不在线"}
|
||||
ErrCallAlreadyAnswered = &AppError{Code: "CALL_ALREADY_ANSWERED", Message: "通话已在其他设备应答"}
|
||||
ErrGroupCallNotSupported = &AppError{Code: "GROUP_CALL_NOT_SUPPORTED", Message: "群组通话不支持"}
|
||||
ErrMaxParticipantsReached = &AppError{Code: "MAX_PARTICIPANTS_REACHED", Message: "通话人数已达上限"}
|
||||
|
||||
// 消息相关错误
|
||||
ErrConversationNotFound = &AppError{Code: "CONVERSATION_NOT_FOUND", Message: "会话不存在或无权限"}
|
||||
ErrNotConversationMember = &AppError{Code: "NOT_CONVERSATION_MEMBER", Message: "不是会话参与者"}
|
||||
ErrMessageNotFound = &AppError{Code: "MESSAGE_NOT_FOUND", Message: "消息不存在"}
|
||||
ErrMessageAlreadyRecalled = &AppError{Code: "MESSAGE_ALREADY_RECALLED", Message: "消息已撤回"}
|
||||
ErrMessageRecallTimeout = &AppError{Code: "MESSAGE_RECALL_TIMEOUT", Message: "消息撤回超时(2分钟)"}
|
||||
ErrCannotSendMessage = &AppError{Code: "CANNOT_SEND_MESSAGE", Message: "无法发送消息"}
|
||||
ErrCannotSendImage = &AppError{Code: "CANNOT_SEND_IMAGE", Message: "对方未关注你,暂不支持发送图片"}
|
||||
ErrMessageLimitReached = &AppError{Code: "MESSAGE_LIMIT_REACHED", Message: "对方未关注你前,仅允许发送一条消息"}
|
||||
ErrQRCodeSessionExpired = &AppError{Code: "QRCODE_SESSION_EXPIRED", Message: "二维码已过期"}
|
||||
ErrQRCodeAlreadyScanned = &AppError{Code: "QRCODE_ALREADY_SCANNED", Message: "二维码已被扫描"}
|
||||
ErrQRCodeInvalidStatus = &AppError{Code: "QRCODE_INVALID_STATUS", Message: "二维码状态无效"}
|
||||
ErrPushNotImplemented = &AppError{Code: "PUSH_NOT_IMPLEMENTED", Message: "推送服务未实现"}
|
||||
ErrPushQueueFull = &AppError{Code: "PUSH_QUEUE_FULL", Message: "推送队列已满"}
|
||||
ErrUserOffline = &AppError{Code: "USER_OFFLINE", Message: "用户不在线"}
|
||||
|
||||
// 学习资料相关错误
|
||||
ErrSubjectHasMaterials = &AppError{Code: "SUBJECT_HAS_MATERIALS", Message: "学科下存在资料,无法删除"}
|
||||
|
||||
// 帖子创建幂等性:同一 client_request_id 的请求仍在处理中(占位未回填真实结果)
|
||||
ErrDuplicatePostRequest = &AppError{Code: "DUPLICATE_POST_REQUEST", Message: "正在发布中,请稍候"}
|
||||
|
||||
// 身份认证相关错误
|
||||
ErrVerificationPending = &AppError{Code: "VERIFICATION_PENDING", Message: "已有待审核的认证申请,请等待审核"}
|
||||
ErrVerificationNotFound = &AppError{Code: "VERIFICATION_NOT_FOUND", Message: "认证记录不存在"}
|
||||
ErrInvalidIdentity = &AppError{Code: "INVALID_IDENTITY", Message: "无效的身份类型"}
|
||||
ErrAlreadyVerified = &AppError{Code: "ALREADY_VERIFIED", Message: "已完成身份认证,无需重复申请"}
|
||||
ErrVerificationAlreadyHandled = &AppError{Code: "VERIFICATION_ALREADY_HANDLED", Message: "该认证申请已被处理"}
|
||||
ErrVerificationRequired = &AppError{Code: "VERIFICATION_REQUIRED", Message: "需要完成身份认证"}
|
||||
|
||||
// 认证 / 令牌相关错误(统一由认证管道返回)
|
||||
ErrInvalidToken = &AppError{Code: "INVALID_TOKEN", Message: "令牌无效"}
|
||||
ErrTokenExpired = &AppError{Code: "TOKEN_EXPIRED", Message: "令牌已过期"}
|
||||
ErrSessionRevoked = &AppError{Code: "SESSION_REVOKED", Message: "会话已失效,请重新登录"}
|
||||
ErrAccountInactive = &AppError{Code: "ACCOUNT_INACTIVE", Message: "账号未激活"}
|
||||
ErrWrongTokenType = &AppError{Code: "WRONG_TOKEN_TYPE", Message: "令牌类型错误"}
|
||||
|
||||
// 初始化相关错误
|
||||
ErrSetupAlreadyCompleted = &AppError{Code: "SETUP_ALREADY_COMPLETED", Message: "超级管理员已初始化,无法重复设置"}
|
||||
ErrInvalidSetupSecret = &AppError{Code: "INVALID_SETUP_SECRET", Message: "初始化密钥无效"}
|
||||
ErrSetupSecretNotConfigured = &AppError{Code: "SETUP_SECRET_NOT_CONFIGURED", Message: "未配置初始化密钥,无法使用此接口"}
|
||||
)
|
||||
|
||||
// Wrap 包装错误
|
||||
@@ -110,16 +163,6 @@ func Wrap(err error, appErr *AppError) *AppError {
|
||||
}
|
||||
}
|
||||
|
||||
// New 创建新错误
|
||||
func New(code, message string) *AppError {
|
||||
return &AppError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// Is 判断是否为特定错误
|
||||
func Is(err, target error) bool {
|
||||
return errors.Is(err, target)
|
||||
}
|
||||
|
||||
// As 将错误转换为 AppError
|
||||
func As(err error) (*AppError, bool) {
|
||||
var appErr *AppError
|
||||
|
||||
365
internal/grpc/runner/bus.go
Normal file
365
internal/grpc/runner/bus.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"with_you/proto/runner"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// taskBusMsg Redis 任务频道消息格式
|
||||
type taskBusMsg struct {
|
||||
OriginInstanceID string `json:"origin_instance_id"`
|
||||
TargetRunnerID string `json:"target_runner_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
TaskType string `json:"task_type"`
|
||||
TaskPayload json.RawMessage `json:"task_payload"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
TimeoutSeconds int32 `json:"timeout_seconds"`
|
||||
}
|
||||
|
||||
// resultBusMsg Redis 结果频道消息格式
|
||||
type resultBusMsg struct {
|
||||
OriginInstanceID string `json:"origin_instance_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt int64 `json:"completed_at"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
// TaskBus 组合 RunnerHub + Redis Pub/Sub,实现跨实例任务分发和结果路由
|
||||
type TaskBus struct {
|
||||
hub *RunnerHub
|
||||
registry *RunnerRegistry
|
||||
rdb *redis.Client
|
||||
instanceID string
|
||||
taskChan string
|
||||
resultChan string
|
||||
logger *zap.Logger
|
||||
|
||||
remoteTasks sync.Map // taskID → originInstanceID(从其他实例接收的任务)
|
||||
|
||||
sub *redis.PubSub
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewTaskBus 创建 TaskBus 实例
|
||||
func NewTaskBus(hub *RunnerHub, rdb *redis.Client, registry *RunnerRegistry, instanceID, taskChan, resultChan string, logger *zap.Logger) *TaskBus {
|
||||
return &TaskBus{
|
||||
hub: hub,
|
||||
registry: registry,
|
||||
rdb: rdb,
|
||||
instanceID: instanceID,
|
||||
taskChan: taskChan,
|
||||
resultChan: resultChan,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动 Redis 订阅
|
||||
func (b *TaskBus) Start() error {
|
||||
if b.rdb == nil {
|
||||
b.logger.Info("TaskBus: no Redis client, running in standalone mode")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
b.cancel = cancel
|
||||
|
||||
sub := b.rdb.Subscribe(ctx, b.taskChan, b.resultChan)
|
||||
b.sub = sub
|
||||
|
||||
if _, err := sub.Receive(ctx); err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
go b.subscribeLoop(ctx, sub)
|
||||
|
||||
b.logger.Info("TaskBus started",
|
||||
zap.String("instance_id", b.instanceID),
|
||||
zap.String("task_channel", b.taskChan),
|
||||
zap.String("result_channel", b.resultChan),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop 关闭 TaskBus
|
||||
func (b *TaskBus) Stop() error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.closed {
|
||||
return nil
|
||||
}
|
||||
b.closed = true
|
||||
|
||||
if b.cancel != nil {
|
||||
b.cancel()
|
||||
}
|
||||
if b.sub != nil {
|
||||
b.sub.Close()
|
||||
}
|
||||
if b.registry != nil {
|
||||
b.registry.Stop()
|
||||
}
|
||||
|
||||
b.logger.Info("TaskBus stopped", zap.String("instance_id", b.instanceID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstanceID 返回实例 ID
|
||||
func (b *TaskBus) InstanceID() string {
|
||||
return b.instanceID
|
||||
}
|
||||
|
||||
// DispatchTask 实现 TaskDispatcher 接口
|
||||
// 本地优先 → Redis 注册中心查找 → 远程发布
|
||||
func (b *TaskBus) DispatchTask(task *runner.Task) error {
|
||||
taskTypeStr := task.Type.String()
|
||||
|
||||
// 1. 尝试本地查找
|
||||
conn, err := b.hub.GetAvailableRunner(task.Type)
|
||||
if err == nil && conn != nil {
|
||||
return b.hub.SendTask(conn.ID, task)
|
||||
}
|
||||
|
||||
// 2. 查询 Redis 注册中心
|
||||
if b.registry != nil {
|
||||
runnerID, instanceID, err := b.registry.GetAvailableRunner(taskTypeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no available runner: %w", err)
|
||||
}
|
||||
|
||||
// 如果 runner 在本实例(可能本地 map 还没更新),直接发送
|
||||
if instanceID == b.instanceID {
|
||||
return b.hub.SendTask(runnerID, task)
|
||||
}
|
||||
|
||||
// 发布到远程实例
|
||||
return b.publishTask(task, runnerID)
|
||||
}
|
||||
|
||||
return fmt.Errorf("no available runner for task type: %s", taskTypeStr)
|
||||
}
|
||||
|
||||
// HandleResult 处理任务结果
|
||||
// 如果任务来自远程实例,转发结果;否则交给 TaskManager
|
||||
func (b *TaskBus) HandleResult(result *runner.TaskResult) {
|
||||
originInstance, ok := b.remoteTasks.Load(result.TaskId)
|
||||
if ok {
|
||||
b.remoteTasks.Delete(result.TaskId)
|
||||
|
||||
// 任务来自远程实例,转发结果
|
||||
if originStr, ok := originInstance.(string); ok && originStr != b.instanceID {
|
||||
b.publishResult(result, originStr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 本地任务,交给 TaskManager
|
||||
if b.hub.taskManager != nil {
|
||||
b.hub.taskManager.HandleResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
// publishTask 发布任务到 Redis 频道
|
||||
func (b *TaskBus) publishTask(task *runner.Task, targetRunnerID string) error {
|
||||
msg := taskBusMsg{
|
||||
OriginInstanceID: b.instanceID,
|
||||
TargetRunnerID: targetRunnerID,
|
||||
TaskID: task.TaskId,
|
||||
TaskType: task.Type.String(),
|
||||
TaskPayload: task.Payload,
|
||||
CreatedAt: task.CreatedAt,
|
||||
TimeoutSeconds: task.TimeoutSeconds,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal task message: %w", err)
|
||||
}
|
||||
|
||||
if err := b.rdb.Publish(context.Background(), b.taskChan, data).Err(); err != nil {
|
||||
return fmt.Errorf("failed to publish task to Redis: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishResult 发布结果到 Redis 频道
|
||||
func (b *TaskBus) publishResult(result *runner.TaskResult, originInstanceID string) error {
|
||||
var data json.RawMessage
|
||||
if len(result.Data) > 0 {
|
||||
data = result.Data
|
||||
} else {
|
||||
data = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
msg := resultBusMsg{
|
||||
OriginInstanceID: originInstanceID,
|
||||
TaskID: result.TaskId,
|
||||
Status: result.Status.String(),
|
||||
CompletedAt: result.CompletedAt,
|
||||
Data: data,
|
||||
ErrorCode: result.ErrorCode,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
b.logger.Error("Failed to marshal result message", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := b.rdb.Publish(context.Background(), b.resultChan, raw).Err(); err != nil {
|
||||
b.logger.Error("Failed to publish result to Redis", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// subscribeLoop 处理 Redis 订阅消息
|
||||
func (b *TaskBus) subscribeLoop(ctx context.Context, sub *redis.PubSub) {
|
||||
ch := sub.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch msg.Channel {
|
||||
case b.taskChan:
|
||||
b.handleRemoteTask(msg)
|
||||
case b.resultChan:
|
||||
b.handleRemoteResult(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleRemoteTask 处理从其他实例发来的任务
|
||||
func (b *TaskBus) handleRemoteTask(msg *redis.Message) {
|
||||
var taskMsg taskBusMsg
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &taskMsg); err != nil {
|
||||
b.logger.Error("TaskBus: failed to unmarshal task message", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤自己发送的消息
|
||||
if taskMsg.OriginInstanceID == b.instanceID {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查目标 Runner 是否在本实例
|
||||
conn, err := b.hub.GetRunnerByID(taskMsg.TargetRunnerID)
|
||||
if err != nil {
|
||||
b.logger.Warn("TaskBus: target runner not found locally",
|
||||
zap.String("runner_id", taskMsg.TargetRunnerID),
|
||||
zap.String("task_id", taskMsg.TaskID))
|
||||
return
|
||||
}
|
||||
|
||||
// 记录任务的来源实例,用于结果回传
|
||||
b.remoteTasks.Store(taskMsg.TaskID, taskMsg.OriginInstanceID)
|
||||
|
||||
// 构造 Task 并发送
|
||||
task := &runner.Task{
|
||||
TaskId: taskMsg.TaskID,
|
||||
Type: parseTaskType(taskMsg.TaskType),
|
||||
CreatedAt: taskMsg.CreatedAt,
|
||||
TimeoutSeconds: taskMsg.TimeoutSeconds,
|
||||
Payload: taskMsg.TaskPayload,
|
||||
}
|
||||
|
||||
if err := b.hub.SendTask(conn.ID, task); err != nil {
|
||||
b.logger.Error("TaskBus: failed to send remote task to runner",
|
||||
zap.String("runner_id", conn.ID),
|
||||
zap.String("task_id", taskMsg.TaskID),
|
||||
zap.Error(err))
|
||||
b.remoteTasks.Delete(taskMsg.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRemoteResult 处理从其他实例发来的任务结果
|
||||
func (b *TaskBus) handleRemoteResult(msg *redis.Message) {
|
||||
var resultMsg resultBusMsg
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &resultMsg); err != nil {
|
||||
b.logger.Error("TaskBus: failed to unmarshal result message", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤自己发送的消息
|
||||
if resultMsg.OriginInstanceID == b.instanceID {
|
||||
return
|
||||
}
|
||||
|
||||
// 只有 origin 匹配本实例才需要处理
|
||||
// (originInstanceID 标识的是发起任务的实例)
|
||||
// 收到其他实例转发的结果,且 origin 是我们 → 交给 TaskManager
|
||||
// 但这里收到的是其他实例广播的结果,我们只是中继
|
||||
// 实际上 result 是发到 runner:result channel 的,所有实例都会收到
|
||||
// 我们只关心 origin 是本实例的结果
|
||||
if resultMsg.OriginInstanceID != b.instanceID {
|
||||
return
|
||||
}
|
||||
|
||||
// 交给 TaskManager 处理
|
||||
result := &runner.TaskResult{
|
||||
TaskId: resultMsg.TaskID,
|
||||
Status: parseTaskStatus(resultMsg.Status),
|
||||
CompletedAt: resultMsg.CompletedAt,
|
||||
Data: resultMsg.Data,
|
||||
ErrorCode: resultMsg.ErrorCode,
|
||||
ErrorMessage: resultMsg.ErrorMessage,
|
||||
}
|
||||
|
||||
if b.hub.taskManager != nil {
|
||||
b.hub.taskManager.HandleResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
// parseTaskType 将字符串转换为 runner.TaskType 枚举
|
||||
func parseTaskType(s string) runner.TaskType {
|
||||
switch s {
|
||||
case "TASK_TYPE_LOGIN":
|
||||
return runner.TaskType_TASK_TYPE_LOGIN
|
||||
case "TASK_TYPE_GET_SCHEDULE":
|
||||
return runner.TaskType_TASK_TYPE_GET_SCHEDULE
|
||||
case "TASK_TYPE_GET_GRADES":
|
||||
return runner.TaskType_TASK_TYPE_GET_GRADES
|
||||
case "TASK_TYPE_GET_EXAMS":
|
||||
return runner.TaskType_TASK_TYPE_GET_EXAMS
|
||||
case "TASK_TYPE_GET_USER_INFO":
|
||||
return runner.TaskType_TASK_TYPE_GET_USER_INFO
|
||||
default:
|
||||
return runner.TaskType_TASK_TYPE_UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// parseTaskStatus 将字符串转换为 runner.TaskStatus 枚举
|
||||
func parseTaskStatus(s string) runner.TaskStatus {
|
||||
switch s {
|
||||
case "TASK_STATUS_SUCCESS":
|
||||
return runner.TaskStatus_TASK_STATUS_SUCCESS
|
||||
case "TASK_STATUS_FAILED":
|
||||
return runner.TaskStatus_TASK_STATUS_FAILED
|
||||
case "TASK_STATUS_TIMEOUT":
|
||||
return runner.TaskStatus_TASK_STATUS_TIMEOUT
|
||||
case "TASK_STATUS_CANCELLED":
|
||||
return runner.TaskStatus_TASK_STATUS_CANCELLED
|
||||
default:
|
||||
return runner.TaskStatus_TASK_STATUS_UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package runner
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/proto/runner"
|
||||
"with_you/proto/runner"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -26,22 +26,20 @@ type RunnerConnection struct {
|
||||
type RunnerHub struct {
|
||||
runner.UnimplementedRunnerHubServer // 嵌入以实现 gRPC 接口
|
||||
|
||||
mu sync.RWMutex
|
||||
runners map[string]*RunnerConnection
|
||||
taskManager *TaskManager
|
||||
registerChan chan *RunnerConnection
|
||||
unregisterChan chan string
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
runners map[string]*RunnerConnection
|
||||
taskManager *TaskManager
|
||||
bus *TaskBus
|
||||
registry *RunnerRegistry
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewRunnerHub 创建新的 RunnerHub
|
||||
func NewRunnerHub(taskManager *TaskManager, logger *zap.Logger) *RunnerHub {
|
||||
return &RunnerHub{
|
||||
runners: make(map[string]*RunnerConnection),
|
||||
taskManager: taskManager,
|
||||
registerChan: make(chan *RunnerConnection, 100),
|
||||
unregisterChan: make(chan string, 100),
|
||||
logger: logger,
|
||||
runners: make(map[string]*RunnerConnection),
|
||||
taskManager: taskManager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +135,15 @@ func (h *RunnerHub) handleRegister(req *runner.RegisterRequest, stream runner.Ru
|
||||
h.runners[req.RunnerId] = conn
|
||||
h.mu.Unlock()
|
||||
|
||||
// 同步到 Redis 注册中心
|
||||
if h.registry != nil {
|
||||
if err := h.registry.Register(req.RunnerId, req.Version, req.Capabilities); err != nil {
|
||||
h.logger.Error("Failed to register runner in registry",
|
||||
zap.String("runner_id", req.RunnerId),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 发送注册响应
|
||||
if err := conn.send(&runner.StreamMessage{
|
||||
Message: &runner.StreamMessage_RegisterResponse{
|
||||
@@ -161,6 +168,15 @@ func (h *RunnerHub) handleHeartbeat(conn *RunnerConnection, hb *runner.Heartbeat
|
||||
}
|
||||
conn.LastSeen = time.Now()
|
||||
|
||||
// 刷新 Redis 注册中心的 TTL
|
||||
if h.registry != nil {
|
||||
if err := h.registry.RefreshHeartbeat(conn.ID); err != nil {
|
||||
h.logger.Debug("Failed to refresh runner heartbeat in registry",
|
||||
zap.String("runner_id", conn.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := conn.send(&runner.StreamMessage{
|
||||
Message: &runner.StreamMessage_HeartbeatAck{
|
||||
HeartbeatAck: &runner.HeartbeatAck{
|
||||
@@ -179,6 +195,12 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
|
||||
zap.String("task_id", result.TaskId),
|
||||
zap.String("status", result.Status.String()))
|
||||
|
||||
// 如果有 Bus,委托给 Bus 处理(可能需要转发到远程实例)
|
||||
if h.bus != nil {
|
||||
h.bus.HandleResult(result)
|
||||
return
|
||||
}
|
||||
|
||||
if h.taskManager != nil {
|
||||
h.taskManager.HandleResult(result)
|
||||
}
|
||||
@@ -187,12 +209,19 @@ func (h *RunnerHub) handleTaskResult(result *runner.TaskResult) {
|
||||
// unregisterRunner 注销 Runner
|
||||
func (h *RunnerHub) unregisterRunner(runnerID string) {
|
||||
h.mu.Lock()
|
||||
var capabilities map[string]string
|
||||
if conn, ok := h.runners[runnerID]; ok {
|
||||
capabilities = conn.Capabilities
|
||||
close(conn.sendChan)
|
||||
delete(h.runners, runnerID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
// 从 Redis 注册中心移除
|
||||
if h.registry != nil && capabilities != nil {
|
||||
h.registry.Unregister(runnerID, capabilities)
|
||||
}
|
||||
|
||||
h.logger.Info("Runner unregistered", zap.String("runner_id", runnerID))
|
||||
}
|
||||
|
||||
@@ -236,13 +265,13 @@ func (h *RunnerHub) GetAvailableRunner(taskType runner.TaskType) (*RunnerConnect
|
||||
}
|
||||
|
||||
// GetAllRunners 获取所有已连接的 Runner 信息
|
||||
func (h *RunnerHub) GetAllRunners() []map[string]interface{} {
|
||||
func (h *RunnerHub) GetAllRunners() []map[string]any {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(h.runners))
|
||||
result := make([]map[string]any, 0, len(h.runners))
|
||||
for id, conn := range h.runners {
|
||||
result = append(result, map[string]interface{}{
|
||||
result = append(result, map[string]any{
|
||||
"id": id,
|
||||
"version": conn.Version,
|
||||
"capabilities": conn.Capabilities,
|
||||
@@ -257,6 +286,37 @@ func (h *RunnerHub) SetTaskManager(tm *TaskManager) {
|
||||
h.taskManager = tm
|
||||
}
|
||||
|
||||
// SetBus 设置 TaskBus(集群模式)
|
||||
func (h *RunnerHub) SetBus(bus *TaskBus) {
|
||||
h.bus = bus
|
||||
}
|
||||
|
||||
// SetRegistry 设置 RunnerRegistry(集群模式)
|
||||
func (h *RunnerHub) SetRegistry(registry *RunnerRegistry) {
|
||||
h.registry = registry
|
||||
}
|
||||
|
||||
// DispatchTask 实现 TaskDispatcher 接口(standalone 模式)
|
||||
func (h *RunnerHub) DispatchTask(task *runner.Task) error {
|
||||
conn, err := h.GetAvailableRunner(task.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.SendTask(conn.ID, task)
|
||||
}
|
||||
|
||||
// GetRunnerByID 根据 ID 获取 Runner 连接
|
||||
func (h *RunnerHub) GetRunnerByID(runnerID string) (*RunnerConnection, error) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
conn, ok := h.runners[runnerID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("runner not found: %s", runnerID)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// send 向 RunnerConnection 发送消息
|
||||
func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
|
||||
select {
|
||||
@@ -270,8 +330,11 @@ func (c *RunnerConnection) send(msg *runner.StreamMessage) error {
|
||||
// CheckStaleRunners 检查并清理超时的 Runner 连接
|
||||
func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
var toRemove []struct {
|
||||
id string
|
||||
capabilities map[string]string
|
||||
}
|
||||
now := time.Now()
|
||||
for id, conn := range h.runners {
|
||||
if now.Sub(conn.LastSeen) > timeout {
|
||||
@@ -280,6 +343,18 @@ func (h *RunnerHub) CheckStaleRunners(timeout time.Duration) {
|
||||
zap.Time("last_seen", conn.LastSeen))
|
||||
close(conn.sendChan)
|
||||
delete(h.runners, id)
|
||||
toRemove = append(toRemove, struct {
|
||||
id string
|
||||
capabilities map[string]string
|
||||
}{id: id, capabilities: conn.Capabilities})
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
// 从 Redis 注册中心批量移除
|
||||
if h.registry != nil {
|
||||
for _, r := range toRemove {
|
||||
h.registry.Unregister(r.id, r.capabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
305
internal/grpc/runner/registry.go
Normal file
305
internal/grpc/runner/registry.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RunnerRegistry 分布式 Runner 注册中心
|
||||
// 使用 Redis Hash 存储 Runner 信息,Redis Set 按能力索引
|
||||
type RunnerRegistry struct {
|
||||
rdb *redis.Client
|
||||
instanceID string
|
||||
registryPrefix string
|
||||
capPrefix string
|
||||
onlineTTL time.Duration
|
||||
heartbeatInterval time.Duration
|
||||
localRunners sync.Map // runnerID → map[string]string (capabilities)
|
||||
logger *zap.Logger
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// runnerInfo 存储在 Redis Hash 中的 Runner 信息
|
||||
type runnerInfo struct {
|
||||
InstanceID string `json:"instance_id"`
|
||||
Version string `json:"version"`
|
||||
Capabilities string `json:"capabilities"` // JSON-encoded map
|
||||
}
|
||||
|
||||
// NewRunnerRegistry 创建 Runner 注册中心
|
||||
func NewRunnerRegistry(rdb *redis.Client, instanceID, registryPrefix, capPrefix string, onlineTTL, heartbeatInterval time.Duration, logger *zap.Logger) *RunnerRegistry {
|
||||
return &RunnerRegistry{
|
||||
rdb: rdb,
|
||||
instanceID: instanceID,
|
||||
registryPrefix: registryPrefix,
|
||||
capPrefix: capPrefix,
|
||||
onlineTTL: onlineTTL,
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册 Runner 到分布式注册中心
|
||||
func (r *RunnerRegistry) Register(runnerID, version string, capabilities map[string]string) error {
|
||||
if r.rdb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
capJSON, _ := json.Marshal(capabilities)
|
||||
key := r.registryPrefix + runnerID
|
||||
|
||||
info := runnerInfo{
|
||||
InstanceID: r.instanceID,
|
||||
Version: version,
|
||||
Capabilities: string(capJSON),
|
||||
}
|
||||
infoJSON, _ := json.Marshal(info)
|
||||
|
||||
pipe := r.rdb.Pipeline()
|
||||
pipe.Set(ctx, key, infoJSON, r.onlineTTL)
|
||||
|
||||
// 为每个能力创建索引
|
||||
taskTypeStr := parseTaskTypes(capabilities)
|
||||
for _, tt := range taskTypeStr {
|
||||
capKey := r.capPrefix + tt
|
||||
pipe.SAdd(ctx, capKey, runnerID)
|
||||
pipe.Expire(ctx, capKey, r.onlineTTL*2)
|
||||
}
|
||||
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return fmt.Errorf("failed to register runner in redis: %w", err)
|
||||
}
|
||||
|
||||
r.localRunners.Store(runnerID, capabilities)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unregister 从分布式注册中心移除 Runner
|
||||
func (r *RunnerRegistry) Unregister(runnerID string, capabilities map[string]string) {
|
||||
if r.rdb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := r.registryPrefix + runnerID
|
||||
pipe := r.rdb.Pipeline()
|
||||
pipe.Del(ctx, key)
|
||||
|
||||
taskTypeStr := parseTaskTypes(capabilities)
|
||||
for _, tt := range taskTypeStr {
|
||||
capKey := r.capPrefix + tt
|
||||
pipe.SRem(ctx, capKey, runnerID)
|
||||
}
|
||||
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
r.logger.Error("Failed to unregister runner from redis",
|
||||
zap.String("runner_id", runnerID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
r.localRunners.Delete(runnerID)
|
||||
}
|
||||
|
||||
// RefreshHeartbeat 刷新 Runner 的 TTL
|
||||
func (r *RunnerRegistry) RefreshHeartbeat(runnerID string) error {
|
||||
if r.rdb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := r.registryPrefix + runnerID
|
||||
return r.rdb.Expire(ctx, key, r.onlineTTL).Err()
|
||||
}
|
||||
|
||||
// GetAvailableRunner 查找支持指定任务类型的 Runner
|
||||
// 返回 runnerID 和所在 instanceID
|
||||
func (r *RunnerRegistry) GetAvailableRunner(taskType string) (runnerID, instanceID string, err error) {
|
||||
if r.rdb == nil {
|
||||
return "", "", fmt.Errorf("registry not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
capKey := r.capPrefix + taskType
|
||||
|
||||
members, err := r.rdb.SMembers(ctx, capKey).Result()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to query runner capabilities: %w", err)
|
||||
}
|
||||
|
||||
if len(members) == 0 {
|
||||
return "", "", fmt.Errorf("no runner available for task type: %s", taskType)
|
||||
}
|
||||
|
||||
// 随机选一个(伪随机,map遍历本身随机性)
|
||||
for _, rid := range members {
|
||||
key := r.registryPrefix + rid
|
||||
val, err := r.rdb.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
continue // 已过期,跳过
|
||||
}
|
||||
|
||||
var info runnerInfo
|
||||
if err := json.Unmarshal(val, &info); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return rid, info.InstanceID, nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("no runner available for task type: %s", taskType)
|
||||
}
|
||||
|
||||
// GetRunnerInstance 获取 Runner 所在的实例 ID
|
||||
func (r *RunnerRegistry) GetRunnerInstance(runnerID string) (string, error) {
|
||||
if r.rdb == nil {
|
||||
return "", fmt.Errorf("registry not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := r.registryPrefix + runnerID
|
||||
val, err := r.rdb.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("runner not found: %s", runnerID)
|
||||
}
|
||||
|
||||
var info runnerInfo
|
||||
if err := json.Unmarshal(val, &info); err != nil {
|
||||
return "", fmt.Errorf("failed to parse runner info: %w", err)
|
||||
}
|
||||
|
||||
return info.InstanceID, nil
|
||||
}
|
||||
|
||||
// GetAllRunners 获取所有注册的 Runner 信息
|
||||
func (r *RunnerRegistry) GetAllRunners() ([]map[string]any, error) {
|
||||
if r.rdb == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pattern := r.registryPrefix + "*"
|
||||
keys, err := r.rdb.Keys(ctx, pattern).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
val, err := r.rdb.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var info runnerInfo
|
||||
if err := json.Unmarshal(val, &info); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var caps map[string]string
|
||||
json.Unmarshal([]byte(info.Capabilities), &caps)
|
||||
|
||||
runnerID := key[len(r.registryPrefix):]
|
||||
result = append(result, map[string]any{
|
||||
"id": runnerID,
|
||||
"instance_id": info.InstanceID,
|
||||
"version": info.Version,
|
||||
"capabilities": caps,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// StartHeartbeat 启动心跳续期
|
||||
func (r *RunnerRegistry) StartHeartbeat() {
|
||||
if r.rdb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.cancel = cancel
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(r.heartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.heartbeat()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *RunnerRegistry) heartbeat() {
|
||||
ctx := context.Background()
|
||||
pipe := r.rdb.Pipeline()
|
||||
count := 0
|
||||
|
||||
r.localRunners.Range(func(key, _ any) bool {
|
||||
runnerID := key.(string)
|
||||
regKey := r.registryPrefix + runnerID
|
||||
pipe.Expire(ctx, regKey, r.onlineTTL)
|
||||
count++
|
||||
return true
|
||||
})
|
||||
|
||||
if count > 0 {
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
r.logger.Error("Failed to heartbeat runner registry", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止心跳并清理本地 Runner 的 Redis 注册
|
||||
func (r *RunnerRegistry) Stop() {
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
|
||||
if r.rdb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.localRunners.Range(func(key, value any) bool {
|
||||
runnerID := key.(string)
|
||||
capabilities, _ := value.(map[string]string)
|
||||
r.Unregister(runnerID, capabilities)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// parseTaskTypes 从 capabilities map 中提取支持的任务类型列表
|
||||
func parseTaskTypes(capabilities map[string]string) []string {
|
||||
var types []string
|
||||
for capKey, capValue := range capabilities {
|
||||
// key = "TASK_TYPE_GET_SCHEDULE", value = "true"
|
||||
if capValue == "true" && isTaskType(capKey) {
|
||||
types = append(types, capKey)
|
||||
continue
|
||||
}
|
||||
// key = "schedule", value = "TASK_TYPE_GET_SCHEDULE"
|
||||
if isTaskType(capValue) {
|
||||
types = append(types, capValue)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// isTaskType 检查字符串是否为有效的 TaskType 枚举值
|
||||
func isTaskType(s string) bool {
|
||||
switch s {
|
||||
case "TASK_TYPE_LOGIN", "TASK_TYPE_GET_SCHEDULE", "TASK_TYPE_GET_GRADES",
|
||||
"TASK_TYPE_GET_EXAMS", "TASK_TYPE_GET_USER_INFO":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
package runner
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/config"
|
||||
"carrot_bbs/proto/runner"
|
||||
"with_you/internal/config"
|
||||
"with_you/proto/runner"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Server gRPC 服务器
|
||||
@@ -23,13 +21,8 @@ type Server struct {
|
||||
grpcSrv *grpc.Server
|
||||
}
|
||||
|
||||
// NewServer 创建新的 gRPC 服务器
|
||||
func NewServer(cfg *config.GRPCConfig, logger *zap.Logger) *Server {
|
||||
// 创建 TaskManager 和 RunnerHub,解决循环依赖
|
||||
taskManager := NewTaskManager(nil, 60*time.Second, logger)
|
||||
hub := NewRunnerHub(taskManager, logger)
|
||||
taskManager.SetHub(hub)
|
||||
|
||||
// NewServerWithDeps 使用外部依赖创建 gRPC 服务器(Wire DI 使用)
|
||||
func NewServerWithDeps(cfg *config.GRPCConfig, logger *zap.Logger, hub *RunnerHub) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
@@ -103,43 +96,3 @@ func (s *Server) Stop() {
|
||||
}
|
||||
}
|
||||
|
||||
// GetHub 获取 RunnerHub
|
||||
func (s *Server) GetHub() *RunnerHub {
|
||||
return s.hub
|
||||
}
|
||||
|
||||
// GetTaskManager 获取 TaskManager
|
||||
func (s *Server) GetTaskManager() *TaskManager {
|
||||
return s.hub.taskManager
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if s.grpcSrv == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.grpcSrv.GracefulStop()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
s.logger.Info("gRPC server stopped gracefully")
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
s.logger.Warn("gRPC server shutdown timeout, forcing stop")
|
||||
s.grpcSrv.Stop()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// GetClientCredentials 获取客户端凭证配置
|
||||
func GetClientCredentials(cfg *config.GRPCConfig) (credentials.TransportCredentials, error) {
|
||||
if cfg.TLSEnabled {
|
||||
return credentials.NewClientTLSFromFile(cfg.TLSCertFile, "")
|
||||
}
|
||||
return insecure.NewCredentials(), nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package runner
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/proto/runner"
|
||||
"with_you/proto/runner"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
@@ -16,6 +16,11 @@ import (
|
||||
// TaskCallback 任务完成回调函数类型
|
||||
type TaskCallback func(result *runner.TaskResult)
|
||||
|
||||
// TaskDispatcher 任务分发接口
|
||||
type TaskDispatcher interface {
|
||||
DispatchTask(task *runner.Task) error
|
||||
}
|
||||
|
||||
// PendingTask 表示一个等待处理的任务
|
||||
type PendingTask struct {
|
||||
Task *runner.Task
|
||||
@@ -25,25 +30,25 @@ type PendingTask struct {
|
||||
|
||||
// TaskManager 管理任务的生命周期
|
||||
type TaskManager struct {
|
||||
mu sync.RWMutex
|
||||
pending map[string]*PendingTask
|
||||
hub *RunnerHub
|
||||
timeout time.Duration
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
pending map[string]*PendingTask
|
||||
dispatcher TaskDispatcher
|
||||
timeout time.Duration
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewTaskManager 创建新的任务管理器
|
||||
func NewTaskManager(hub *RunnerHub, timeout time.Duration, logger *zap.Logger) *TaskManager {
|
||||
func NewTaskManager(dispatcher TaskDispatcher, timeout time.Duration, logger *zap.Logger) *TaskManager {
|
||||
return &TaskManager{
|
||||
pending: make(map[string]*PendingTask),
|
||||
hub: hub,
|
||||
timeout: timeout,
|
||||
logger: logger,
|
||||
pending: make(map[string]*PendingTask),
|
||||
dispatcher: dispatcher,
|
||||
timeout: timeout,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SubmitTask 提交任务并等待结果
|
||||
func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType, payload interface{}, callback TaskCallback) (*runner.Task, error) {
|
||||
func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType, payload any, callback TaskCallback) (*runner.Task, error) {
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal payload: %w", err)
|
||||
@@ -69,17 +74,10 @@ func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType,
|
||||
zap.String("task_id", task.TaskId),
|
||||
zap.String("type", taskType.String()))
|
||||
|
||||
// 获取可用 Runner
|
||||
conn, err := tm.hub.GetAvailableRunner(taskType)
|
||||
if err != nil {
|
||||
// 通过 dispatcher 分发任务
|
||||
if err := tm.dispatcher.DispatchTask(task); err != nil {
|
||||
tm.cleanupTask(task.TaskId)
|
||||
return nil, fmt.Errorf("no available runner: %w", err)
|
||||
}
|
||||
|
||||
// 发送任务
|
||||
if err := tm.hub.SendTask(conn.ID, task); err != nil {
|
||||
tm.cleanupTask(task.TaskId)
|
||||
return nil, fmt.Errorf("failed to send task: %w", err)
|
||||
return nil, fmt.Errorf("failed to dispatch task: %w", err)
|
||||
}
|
||||
|
||||
// 启动超时检查
|
||||
@@ -89,7 +87,7 @@ func (tm *TaskManager) SubmitTask(ctx context.Context, taskType runner.TaskType,
|
||||
}
|
||||
|
||||
// SubmitTaskSync 同步提交任务并等待结果
|
||||
func (tm *TaskManager) SubmitTaskSync(ctx context.Context, taskType runner.TaskType, payload interface{}) (*runner.TaskResult, error) {
|
||||
func (tm *TaskManager) SubmitTaskSync(ctx context.Context, taskType runner.TaskType, payload any) (*runner.TaskResult, error) {
|
||||
resultChan := make(chan *runner.TaskResult, 1)
|
||||
|
||||
task, err := tm.SubmitTask(ctx, taskType, payload, func(result *runner.TaskResult) {
|
||||
@@ -203,9 +201,9 @@ func (tm *TaskManager) GetPendingTaskCount() int {
|
||||
return len(tm.pending)
|
||||
}
|
||||
|
||||
// SetHub 设置 RunnerHub(用于解决循环依赖)
|
||||
func (tm *TaskManager) SetHub(hub *RunnerHub) {
|
||||
tm.hub = hub
|
||||
// SetDispatcher 设置任务分发器(用于解决循环依赖)
|
||||
func (tm *TaskManager) SetDispatcher(dispatcher TaskDispatcher) {
|
||||
tm.dispatcher = dispatcher
|
||||
}
|
||||
|
||||
// CleanupStaleTasks 清理超时的任务
|
||||
|
||||
@@ -3,10 +3,10 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -121,6 +121,58 @@ func (h *AdminCommentHandler) BatchDeleteComments(c *gin.Context) {
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *AdminCommentHandler) ModerateComment(c *gin.Context) {
|
||||
commentID := c.Param("id")
|
||||
if commentID == "" {
|
||||
response.BadRequest(c, "comment id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.AdminModerateCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != string(model.CommentStatusPublished) && req.Status != string(model.CommentStatusRejected) {
|
||||
response.BadRequest(c, "invalid status value, must be 'published' or 'rejected'")
|
||||
return
|
||||
}
|
||||
|
||||
reviewedBy := c.GetString("user_id")
|
||||
|
||||
commentDetail, err := h.adminCommentService.ModerateComment(c.Request.Context(), commentID, model.CommentStatus(req.Status), req.Reason, reviewedBy)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to moderate comment")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, commentDetail)
|
||||
}
|
||||
|
||||
func (h *AdminCommentHandler) BatchModerateComments(c *gin.Context) {
|
||||
var req dto.AdminBatchModerateCommentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "invalid request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != string(model.CommentStatusPublished) && req.Status != string(model.CommentStatusRejected) {
|
||||
response.BadRequest(c, "invalid status value, must be 'published' or 'rejected'")
|
||||
return
|
||||
}
|
||||
|
||||
reviewedBy := c.GetString("user_id")
|
||||
|
||||
result, err := h.adminCommentService.BatchModerateComments(c.Request.Context(), req.IDs, model.CommentStatus(req.Status), reviewedBy)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to batch moderate comments")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetPostComments 获取帖子的评论列表
|
||||
// GET /api/v1/admin/posts/:postId/comments
|
||||
func (h *AdminCommentHandler) GetPostComments(c *gin.Context) {
|
||||
|
||||
@@ -3,8 +3,8 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -36,14 +36,9 @@ func (h *AdminDashboardHandler) GetStats(c *gin.Context) {
|
||||
// GetUserActivityTrend 获取用户活跃度趋势
|
||||
// GET /api/v1/admin/dashboard/user-activity
|
||||
func (h *AdminDashboardHandler) GetUserActivityTrend(c *gin.Context) {
|
||||
// 解析天数参数,默认7天
|
||||
// 解析天数参数,默认7天,限制在1-30天
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
|
||||
if days <= 0 {
|
||||
days = 7
|
||||
}
|
||||
if days > 30 {
|
||||
days = 30
|
||||
}
|
||||
days = min(max(days, 1), 30)
|
||||
|
||||
trend, err := h.dashboardService.GetUserActivityTrend(c.Request.Context(), days)
|
||||
if err != nil {
|
||||
@@ -69,14 +64,9 @@ func (h *AdminDashboardHandler) GetContentStats(c *gin.Context) {
|
||||
// GetPendingContent 获取待审核内容
|
||||
// GET /api/v1/admin/dashboard/pending-content
|
||||
func (h *AdminDashboardHandler) GetPendingContent(c *gin.Context) {
|
||||
// 解析limit参数,默认10条
|
||||
// 解析limit参数,默认10条,限制在1-50条
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
limit = min(max(limit, 1), 50)
|
||||
|
||||
content, err := h.dashboardService.GetPendingContent(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,9 +3,9 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/repository"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/query"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -37,7 +37,7 @@ func (h *AdminLogHandler) GetOperationLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
filters := repository.LogFilter{
|
||||
filters := query.LogFilter{
|
||||
UserID: c.Query("user_id"),
|
||||
Operation: c.Query("operation"),
|
||||
TargetType: c.Query("target_type"),
|
||||
@@ -70,7 +70,7 @@ func (h *AdminLogHandler) GetLoginLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
filters := repository.LoginFilter{
|
||||
filters := query.LoginFilter{
|
||||
UserID: c.Query("user_id"),
|
||||
Event: c.Query("event"),
|
||||
Result: c.Query("result"),
|
||||
@@ -113,7 +113,7 @@ func (h *AdminLogHandler) GetDataChangeLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
filters := repository.DataChangeFilter{
|
||||
filters := query.DataChangeFilter{
|
||||
UserID: c.Query("user_id"),
|
||||
OperatorID: c.Query("operator_id"),
|
||||
ChangeType: c.Query("change_type"),
|
||||
@@ -190,7 +190,7 @@ func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
|
||||
"logs": logs,
|
||||
})
|
||||
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 {
|
||||
response.HandleError(c, err, "failed to export login logs")
|
||||
return
|
||||
@@ -200,7 +200,7 @@ func (h *AdminLogHandler) ExportLogs(c *gin.Context) {
|
||||
"log": logs,
|
||||
})
|
||||
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 {
|
||||
response.HandleError(c, err, "failed to export data change logs")
|
||||
return
|
||||
|
||||
@@ -3,10 +3,10 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
129
internal/handler/admin_profile_audit_handler.go
Normal file
129
internal/handler/admin_profile_audit_handler.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AdminProfileAuditHandler struct {
|
||||
profileAuditService service.UserProfileAuditService
|
||||
}
|
||||
|
||||
func NewAdminProfileAuditHandler(profileAuditService service.UserProfileAuditService) *AdminProfileAuditHandler {
|
||||
return &AdminProfileAuditHandler{
|
||||
profileAuditService: profileAuditService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminProfileAuditHandler) GetPendingList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
contentType := c.Query("content_type")
|
||||
var ct model.UserProfileContentType
|
||||
switch contentType {
|
||||
case "avatar":
|
||||
ct = model.UserProfileContentTypeAvatar
|
||||
case "cover":
|
||||
ct = model.UserProfileContentTypeCover
|
||||
case "bio":
|
||||
ct = model.UserProfileContentTypeBio
|
||||
default:
|
||||
ct = ""
|
||||
}
|
||||
|
||||
audits, total, err := h.profileAuditService.GetPendingList(c.Request.Context(), ct, page, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get pending profile audits")
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(audits))
|
||||
for _, audit := range audits {
|
||||
item := map[string]any{
|
||||
"id": audit.ID,
|
||||
"user_id": audit.UserID,
|
||||
"content_type": audit.ContentType,
|
||||
"content": audit.Content,
|
||||
"status": audit.Status,
|
||||
"created_at": audit.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
if audit.User != nil {
|
||||
item["user"] = map[string]any{
|
||||
"id": audit.User.ID,
|
||||
"username": audit.User.Username,
|
||||
"nickname": audit.User.Nickname,
|
||||
"avatar": audit.User.Avatar,
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
response.Paginated(c, items, total, page, pageSize)
|
||||
}
|
||||
|
||||
func (h *AdminProfileAuditHandler) ModerateAudit(c *gin.Context) {
|
||||
auditID := c.Param("id")
|
||||
if auditID == "" {
|
||||
response.BadRequest(c, "audit id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != string(model.UserProfileAuditStatusApproved) && req.Status != string(model.UserProfileAuditStatusRejected) {
|
||||
response.BadRequest(c, "invalid status, must be 'approved' or 'rejected'")
|
||||
return
|
||||
}
|
||||
|
||||
reviewedBy := c.GetString("user_id")
|
||||
if err := h.profileAuditService.ModerateAudit(c.Request.Context(), auditID, model.UserProfileAuditStatus(req.Status), req.Reason, reviewedBy); err != nil {
|
||||
response.HandleError(c, err, "failed to moderate profile audit")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
func (h *AdminProfileAuditHandler) BatchModerate(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
Status string `json:"status" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != string(model.UserProfileAuditStatusApproved) && req.Status != string(model.UserProfileAuditStatusRejected) {
|
||||
response.BadRequest(c, "invalid status, must be 'approved' or 'rejected'")
|
||||
return
|
||||
}
|
||||
|
||||
reviewedBy := c.GetString("user_id")
|
||||
if err := h.profileAuditService.BatchModerateAudits(c.Request.Context(), req.IDs, model.UserProfileAuditStatus(req.Status), req.Reason, reviewedBy); err != nil {
|
||||
response.InternalServerError(c, "failed to batch moderate profile audits")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, map[string]any{"success": true})
|
||||
}
|
||||
139
internal/handler/admin_report_handler.go
Normal file
139
internal/handler/admin_report_handler.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/query"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminReportHandler 管理端举报处理器
|
||||
type AdminReportHandler struct {
|
||||
adminReportService service.AdminReportService
|
||||
}
|
||||
|
||||
// NewAdminReportHandler 创建管理端举报处理器
|
||||
func NewAdminReportHandler(adminReportService service.AdminReportService) *AdminReportHandler {
|
||||
return &AdminReportHandler{
|
||||
adminReportService: adminReportService,
|
||||
}
|
||||
}
|
||||
|
||||
// List 获取举报列表
|
||||
func (h *AdminReportHandler) List(c *gin.Context) {
|
||||
// 解析查询参数
|
||||
var query query.AdminReportListQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.BadRequest(c, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认分页
|
||||
if query.Page == 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.PageSize == 0 {
|
||||
query.PageSize = 20
|
||||
}
|
||||
|
||||
// 查询列表
|
||||
reports, total, err := h.adminReportService.GetReportList(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to get report list", zap.Error(err))
|
||||
response.InternalServerError(c, "获取举报列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.Paginated(c, reports, total, query.Page, query.PageSize)
|
||||
}
|
||||
|
||||
// Detail 获取举报详情
|
||||
func (h *AdminReportHandler) Detail(c *gin.Context) {
|
||||
// 获取举报ID
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, "举报ID不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 查询详情
|
||||
report, err := h.adminReportService.GetReportDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to get report detail", zap.Error(err), zap.String("id", id))
|
||||
response.NotFound(c, "举报不存在")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, report)
|
||||
}
|
||||
|
||||
// Handle 处理举报
|
||||
func (h *AdminReportHandler) Handle(c *gin.Context) {
|
||||
// 获取处理人ID
|
||||
handledBy := c.GetString("user_id")
|
||||
if handledBy == "" {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取举报ID
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, "举报ID不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
var req dto.HandleReportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 处理举报
|
||||
err := h.adminReportService.HandleReport(c.Request.Context(), id, req.Action, req.Result, handledBy)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to handle report",
|
||||
zap.Error(err),
|
||||
zap.String("id", id),
|
||||
zap.String("action", req.Action),
|
||||
)
|
||||
response.Error(c, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "处理成功"})
|
||||
}
|
||||
|
||||
// BatchHandle 批量处理举报
|
||||
func (h *AdminReportHandler) BatchHandle(c *gin.Context) {
|
||||
// 获取处理人ID
|
||||
handledBy := c.GetString("user_id")
|
||||
if handledBy == "" {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
var req dto.BatchHandleReportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 批量处理
|
||||
result, err := h.adminReportService.BatchHandle(c.Request.Context(), req.IDs, req.Action, req.Result, handledBy)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to batch handle reports",
|
||||
zap.Error(err),
|
||||
zap.Int("count", len(req.IDs)),
|
||||
)
|
||||
response.Error(c, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
// AdminUserHandler 管理端用户处理器
|
||||
type AdminUserHandler struct {
|
||||
adminUserService service.AdminUserService
|
||||
pushService service.PushService
|
||||
}
|
||||
|
||||
// NewAdminUserHandler 创建管理端用户处理器
|
||||
func NewAdminUserHandler(adminUserService service.AdminUserService) *AdminUserHandler {
|
||||
func NewAdminUserHandler(adminUserService service.AdminUserService, pushService service.PushService) *AdminUserHandler {
|
||||
return &AdminUserHandler{
|
||||
adminUserService: adminUserService,
|
||||
pushService: pushService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,3 +105,22 @@ func (h *AdminUserHandler) UpdateUserStatus(c *gin.Context) {
|
||||
|
||||
response.Success(c, userDetail)
|
||||
}
|
||||
|
||||
// GetUserDevices 获取用户设备列表(含 registration_id)
|
||||
// GET /api/v1/admin/users/:id/devices
|
||||
// 供后台查询目标用户各设备的 JPush RegistrationID(push_token),用于排查推送问题或手动推送测试。
|
||||
func (h *AdminUserHandler) GetUserDevices(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" {
|
||||
response.BadRequest(c, "user id is required")
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get user devices")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.DeviceTokensToAdminResponse(devices))
|
||||
}
|
||||
|
||||
187
internal/handler/admin_verification_handler.go
Normal file
187
internal/handler/admin_verification_handler.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AdminVerificationHandler struct {
|
||||
adminVerificationService service.AdminVerificationService
|
||||
userService service.UserService
|
||||
}
|
||||
|
||||
func NewAdminVerificationHandler(
|
||||
adminVerificationService service.AdminVerificationService,
|
||||
userService service.UserService,
|
||||
) *AdminVerificationHandler {
|
||||
return &AdminVerificationHandler{
|
||||
adminVerificationService: adminVerificationService,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminVerificationHandler) ListVerifications(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
status := c.Query("status")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
if status != "" && status != string(model.VerificationStatusPending) &&
|
||||
status != string(model.VerificationStatusApproved) &&
|
||||
status != string(model.VerificationStatusRejected) {
|
||||
response.BadRequest(c, "无效的状态值")
|
||||
return
|
||||
}
|
||||
|
||||
records, total, err := h.adminVerificationService.ListVerifications(c.Request.Context(), page, pageSize, status, keyword)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "获取认证列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*dto.AdminVerificationListResponse, len(records))
|
||||
for i, record := range records {
|
||||
responses[i] = h.convertToAdminListResponse(c.Request.Context(), record)
|
||||
}
|
||||
|
||||
response.Paginated(c, responses, total, page, pageSize)
|
||||
}
|
||||
|
||||
func (h *AdminVerificationHandler) GetVerificationDetail(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, "认证ID不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.adminVerificationService.GetVerificationDetail(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "认证记录不存在")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, h.convertToAdminDetailResponse(c.Request.Context(), record))
|
||||
}
|
||||
|
||||
func (h *AdminVerificationHandler) ReviewVerification(c *gin.Context) {
|
||||
reviewerID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, "认证ID不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.AdminReviewVerificationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "请求参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !req.Approve && req.RejectReason == "" {
|
||||
response.BadRequest(c, "拒绝时必须填写拒绝原因")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.adminVerificationService.ReviewVerification(
|
||||
c.Request.Context(),
|
||||
reviewerID.(string),
|
||||
id,
|
||||
req.Approve,
|
||||
req.RejectReason,
|
||||
)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "审核失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, h.convertToAdminDetailResponse(c.Request.Context(), record))
|
||||
}
|
||||
|
||||
func (h *AdminVerificationHandler) convertToAdminListResponse(ctx context.Context, record *model.VerificationRecord) *dto.AdminVerificationListResponse {
|
||||
resp := &dto.AdminVerificationListResponse{
|
||||
ID: record.ID,
|
||||
UserID: record.UserID,
|
||||
Identity: string(record.Identity),
|
||||
Status: string(record.Status),
|
||||
RealName: record.RealName,
|
||||
StudentID: record.StudentID,
|
||||
EmployeeID: record.EmployeeID,
|
||||
Department: record.Department,
|
||||
CreatedAt: dto.FormatTime(record.CreatedAt),
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserByID(ctx, record.UserID)
|
||||
if err == nil && user != nil {
|
||||
resp.Username = user.Username
|
||||
resp.Nickname = user.Nickname
|
||||
resp.Avatar = user.Avatar
|
||||
}
|
||||
|
||||
if record.ReviewedAt != nil {
|
||||
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
|
||||
if record.ReviewedBy != nil {
|
||||
reviewer, err := h.userService.GetUserByID(ctx, *record.ReviewedBy)
|
||||
if err == nil && reviewer != nil {
|
||||
resp.ReviewerName = reviewer.Nickname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *AdminVerificationHandler) convertToAdminDetailResponse(ctx context.Context, record *model.VerificationRecord) *dto.AdminVerificationDetailResponse {
|
||||
resp := &dto.AdminVerificationDetailResponse{
|
||||
ID: record.ID,
|
||||
UserID: record.UserID,
|
||||
Identity: string(record.Identity),
|
||||
Status: string(record.Status),
|
||||
RealName: record.RealName,
|
||||
StudentID: record.StudentID,
|
||||
EmployeeID: record.EmployeeID,
|
||||
Department: record.Department,
|
||||
ProofMaterials: record.ProofMaterials,
|
||||
CreatedAt: dto.FormatTime(record.CreatedAt),
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserByID(ctx, record.UserID)
|
||||
if err == nil && user != nil {
|
||||
resp.Username = user.Username
|
||||
resp.Nickname = user.Nickname
|
||||
resp.Avatar = user.Avatar
|
||||
}
|
||||
|
||||
if record.ReviewedAt != nil {
|
||||
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
|
||||
}
|
||||
if record.ReviewedBy != nil {
|
||||
resp.ReviewedBy = *record.ReviewedBy
|
||||
if reviewer, err := h.userService.GetUserByID(ctx, *record.ReviewedBy); err == nil && reviewer != nil {
|
||||
resp.ReviewerName = reviewer.Nickname
|
||||
}
|
||||
}
|
||||
if record.RejectReason != "" {
|
||||
resp.RejectReason = record.RejectReason
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
51
internal/handler/call_handler.go
Normal file
51
internal/handler/call_handler.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// CallHandler 通话记录处理器
|
||||
type CallHandler struct {
|
||||
callService service.CallService
|
||||
}
|
||||
|
||||
// NewCallHandler 创建通话处理器
|
||||
func NewCallHandler(callService service.CallService) *CallHandler {
|
||||
return &CallHandler{callService: callService}
|
||||
}
|
||||
|
||||
// GetCallHistory 获取通话记录
|
||||
// GET /api/v1/calls/history?page=1&page_size=20
|
||||
func (h *CallHandler) GetCallHistory(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 50 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
calls, total, err := h.callService.GetCallHistory(c.Request.Context(), userID.(string), page, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get call history")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"items": calls,
|
||||
"total": total,
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -120,4 +120,3 @@ func (h *ChannelHandler) AdminDelete(c *gin.Context) {
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,24 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/cursor"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// CommentHandler 评论处理器
|
||||
type CommentHandler struct {
|
||||
commentService *service.CommentService
|
||||
commentService service.CommentService
|
||||
}
|
||||
|
||||
// NewCommentHandler 创建评论处理器
|
||||
func NewCommentHandler(commentService *service.CommentService) *CommentHandler {
|
||||
func NewCommentHandler(commentService service.CommentService) *CommentHandler {
|
||||
return &CommentHandler{
|
||||
commentService: commentService,
|
||||
}
|
||||
@@ -34,10 +34,11 @@ func (h *CommentHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
PostID string `json:"post_id" binding:"required"`
|
||||
Content string `json:"content"` // 内容可选,允许只发图片
|
||||
ParentID *string `json:"parent_id"`
|
||||
Images []string `json:"images"` // 图片URL列表
|
||||
PostID string `json:"post_id" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
var req CreateRequest
|
||||
@@ -46,12 +47,18 @@ func (h *CommentHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证:评论必须有内容或图片
|
||||
if req.Content == "" && len(req.Images) == 0 {
|
||||
// 验证:评论必须有内容或图片或segments
|
||||
if req.Content == "" && len(req.Images) == 0 && len(req.Segments) == 0 {
|
||||
response.BadRequest(c, "评论内容或图片不能同时为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有content但有segments,从segments提取文本
|
||||
content := req.Content
|
||||
if content == "" && len(req.Segments) > 0 {
|
||||
content = dto.ExtractTextContent(req.Segments)
|
||||
}
|
||||
|
||||
// 将图片列表转换为JSON字符串
|
||||
var imagesJSON string
|
||||
if len(req.Images) > 0 {
|
||||
@@ -59,13 +66,8 @@ func (h *CommentHandler) Create(c *gin.Context) {
|
||||
imagesJSON = string(imagesBytes)
|
||||
}
|
||||
|
||||
comment, err := h.commentService.Create(c.Request.Context(), req.PostID, userID, req.Content, req.ParentID, imagesJSON, req.Images)
|
||||
comment, err := h.commentService.Create(c.Request.Context(), req.PostID, userID, content, req.Segments, req.ParentID, imagesJSON, req.Images)
|
||||
if err != nil {
|
||||
var moderationErr *service.CommentModerationRejectedError
|
||||
if errors.As(err, &moderationErr) {
|
||||
response.BadRequest(c, moderationErr.UserMessage())
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to create comment")
|
||||
return
|
||||
}
|
||||
@@ -176,9 +178,9 @@ func (h *CommentHandler) Update(c *gin.Context) {
|
||||
|
||||
comment.Content = req.Content
|
||||
|
||||
err = h.commentService.Update(c.Request.Context(), comment)
|
||||
err = h.commentService.Update(c.Request.Context(), userID, comment)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to update comment")
|
||||
response.HandleError(c, err, "failed to update comment")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -206,9 +208,9 @@ func (h *CommentHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err = h.commentService.Delete(c.Request.Context(), id)
|
||||
err = h.commentService.Delete(c.Request.Context(), userID, id)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to delete comment")
|
||||
response.HandleError(c, err, "failed to delete comment")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
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})
|
||||
}
|
||||
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,16 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/cursor"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// GroupHandler 群组处理器
|
||||
@@ -90,7 +91,7 @@ func (h *GroupHandler) GetGroup(c *gin.Context) {
|
||||
|
||||
group, err := h.groupService.GetGroupByID(groupID)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -129,7 +130,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
updates := make(map[string]any)
|
||||
if req.Name != "" {
|
||||
updates["name"] = req.Name
|
||||
}
|
||||
@@ -145,7 +146,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
||||
response.Forbidden(c, "没有权限修改群组信息")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -178,7 +179,7 @@ func (h *GroupHandler) DissolveGroup(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以解散群组")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -215,7 +216,7 @@ func (h *GroupHandler) TransferOwner(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以转让群主")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -279,7 +280,7 @@ func (h *GroupHandler) InviteMembers(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群成员可以邀请他人")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -314,7 +315,7 @@ func (h *GroupHandler) JoinGroup(c *gin.Context) {
|
||||
response.Forbidden(c, "该群不允许加入")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -353,7 +354,7 @@ func (h *GroupHandler) LeaveGroup(c *gin.Context) {
|
||||
response.BadRequest(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -390,7 +391,7 @@ func (h *GroupHandler) RemoveMember(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以移除成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -429,7 +430,7 @@ func (h *GroupHandler) GetMembers(c *gin.Context) {
|
||||
|
||||
members, total, err := h.groupService.GetMembers(groupID, page, pageSize)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -517,7 +518,7 @@ func (h *GroupHandler) HandleGetMyMemberInfo(c *gin.Context) {
|
||||
// 获取群组信息
|
||||
group, err := h.groupService.GetGroupByID(groupID)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -542,7 +543,7 @@ func (h *GroupHandler) HandleGetMyMemberInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 添加群组禁言状态信息
|
||||
response.Success(c, map[string]interface{}{
|
||||
response.Success(c, map[string]any{
|
||||
"member": memberResp,
|
||||
"mute_all": group.MuteAll,
|
||||
"is_muted": member.Muted || group.MuteAll,
|
||||
@@ -570,7 +571,7 @@ func (h *GroupHandler) HandleDissolveGroup(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以解散群组")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -612,7 +613,7 @@ func (h *GroupHandler) HandleTransferOwner(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以转让群主")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -657,7 +658,7 @@ func (h *GroupHandler) HandleInviteMembers(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以邀请他人")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -696,7 +697,7 @@ func (h *GroupHandler) HandleJoinGroup(c *gin.Context) {
|
||||
response.Forbidden(c, "该群不允许加入")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -741,7 +742,7 @@ func (h *GroupHandler) HandleSetNickname(c *gin.Context) {
|
||||
response.BadRequest(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -778,7 +779,7 @@ func (h *GroupHandler) HandleSetJoinType(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置加群方式")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -824,7 +825,7 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以发布公告")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -837,6 +838,8 @@ func (h *GroupHandler) HandleCreateAnnouncement(c *gin.Context) {
|
||||
|
||||
// HandleGetAnnouncements 获取群公告列表
|
||||
// GET /api/v1/groups/:id/announcements
|
||||
//
|
||||
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||
func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
@@ -850,12 +853,18 @@ func (h *GroupHandler) HandleGetAnnouncements(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 成员校验:公告仅群成员可见。
|
||||
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||
response.Forbidden(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
announcements, total, err := h.groupService.GetAnnouncements(groupID, page, pageSize)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -916,7 +925,7 @@ func (h *GroupHandler) GetMyMemberInfo(c *gin.Context) {
|
||||
// 获取群组信息
|
||||
group, err := h.groupService.GetGroupByID(groupID)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -941,7 +950,7 @@ func (h *GroupHandler) GetMyMemberInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 添加群组禁言状态信息
|
||||
response.Success(c, map[string]interface{}{
|
||||
response.Success(c, map[string]any{
|
||||
"member": memberResp,
|
||||
"mute_all": group.MuteAll,
|
||||
"is_muted": member.Muted || group.MuteAll,
|
||||
@@ -981,7 +990,7 @@ func (h *GroupHandler) SetMemberRole(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置成员角色")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1022,7 +1031,7 @@ func (h *GroupHandler) SetNickname(c *gin.Context) {
|
||||
response.BadRequest(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1065,7 +1074,7 @@ func (h *GroupHandler) MuteMember(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以禁言成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1116,7 +1125,7 @@ func (h *GroupHandler) SetMuteAll(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置全员禁言")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1157,7 +1166,7 @@ func (h *GroupHandler) SetJoinType(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置加群方式")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1205,7 +1214,7 @@ func (h *GroupHandler) CreateAnnouncement(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以发布公告")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1236,7 +1245,7 @@ func (h *GroupHandler) GetAnnouncements(c *gin.Context) {
|
||||
|
||||
announcements, total, err := h.groupService.GetAnnouncements(groupID, page, pageSize)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1363,7 +1372,7 @@ func (h *GroupHandler) GetMembersByCursor(c *gin.Context) {
|
||||
// 调用游标分页服务
|
||||
result, err := h.groupService.GetMembersByCursor(c.Request.Context(), groupID, req)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1386,7 +1395,9 @@ func (h *GroupHandler) GetMembersByCursor(c *gin.Context) {
|
||||
}
|
||||
|
||||
// GetAnnouncementsByCursor 游标分页获取群公告列表
|
||||
// GET /api/groups/:id/announcements/cursor
|
||||
// GET /api/v1/groups/:id/announcements/cursor
|
||||
//
|
||||
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||
func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
@@ -1400,13 +1411,19 @@ func (h *GroupHandler) GetAnnouncementsByCursor(c *gin.Context) {
|
||||
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 err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1472,7 +1489,7 @@ func (h *GroupHandler) HandleSetGroupKick(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以移除成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1543,7 +1560,7 @@ func (h *GroupHandler) HandleSetGroupBan(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主或管理员可以禁言成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1593,7 +1610,7 @@ func (h *GroupHandler) HandleSetGroupWholeBan(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置全员禁言")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1646,7 +1663,7 @@ func (h *GroupHandler) HandleSetGroupAdmin(c *gin.Context) {
|
||||
response.Forbidden(c, "只有群主可以设置管理员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1691,7 +1708,7 @@ func (h *GroupHandler) HandleSetGroupName(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
updates := map[string]any{
|
||||
"name": params.GroupName,
|
||||
}
|
||||
|
||||
@@ -1701,7 +1718,7 @@ func (h *GroupHandler) HandleSetGroupName(c *gin.Context) {
|
||||
response.Forbidden(c, "没有权限修改群组信息")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1740,7 +1757,7 @@ func (h *GroupHandler) HandleSetGroupAvatar(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
updates := map[string]any{
|
||||
"avatar": params.Avatar,
|
||||
}
|
||||
|
||||
@@ -1750,7 +1767,51 @@ func (h *GroupHandler) HandleSetGroupAvatar(c *gin.Context) {
|
||||
response.Forbidden(c, "没有权限修改群组信息")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取更新后的群组信息
|
||||
group, _ := h.groupService.GetGroupByID(groupID)
|
||||
response.Success(c, dto.GroupToResponse(group))
|
||||
}
|
||||
|
||||
// HandleSetGroupDescription 设置群描述
|
||||
// PUT /api/v1/groups/:id/description
|
||||
func (h *GroupHandler) HandleSetGroupDescription(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
groupID := parseGroupID(c)
|
||||
if groupID == "" {
|
||||
response.BadRequest(c, "group_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var params dto.SetGroupDescriptionParams
|
||||
if err := c.ShouldBindJSON(¶ms); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"description": params.Description,
|
||||
}
|
||||
|
||||
err := h.groupService.UpdateGroup(userID, groupID, updates)
|
||||
if err != nil {
|
||||
if err == service.ErrNotGroupAdmin {
|
||||
response.Forbidden(c, "没有权限修改群组信息")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1784,7 +1845,7 @@ func (h *GroupHandler) HandleSetGroupLeave(c *gin.Context) {
|
||||
response.BadRequest(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1892,6 +1953,8 @@ func (h *GroupHandler) HandleRespondInvite(c *gin.Context) {
|
||||
|
||||
// HandleGetGroupInfo 获取群信息
|
||||
// GET /api/v1/groups/:id
|
||||
//
|
||||
// 可见性策略:成员返回完整详情;非成员仅返回公开字段(name/avatar/description/member_count)。
|
||||
func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
@@ -1907,7 +1970,7 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
||||
|
||||
group, err := h.groupService.GetGroupByID(groupID)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
@@ -1922,11 +1985,26 @@ func (h *GroupHandler) HandleGetGroupInfo(c *gin.Context) {
|
||||
resp := dto.GroupToResponse(group)
|
||||
resp.MemberCount = memberCount
|
||||
|
||||
// 非成员仅返回公开字段:与成员分支同构(仍是 GroupResponse),但清空敏感字段。
|
||||
// 与成员分支共用 GroupResponse 结构,依赖完整字段的客户端解析不会因字段缺失出错。
|
||||
if !h.groupService.IsGroupMember(userID, groupID) {
|
||||
resp.OwnerID = ""
|
||||
resp.MaxMembers = 0
|
||||
resp.JoinType = 0
|
||||
resp.MuteAll = false
|
||||
resp.IsMember = false
|
||||
response.Success(c, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp.IsMember = true
|
||||
response.Success(c, resp)
|
||||
}
|
||||
|
||||
// HandleGetGroupMemberList 获取群成员列表
|
||||
// GET /api/v1/groups/:id/members
|
||||
//
|
||||
// 可见性策略:仅群成员可见。非成员返回 403 NOT_GROUP_MEMBER。
|
||||
func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
||||
userID := parseUserID(c)
|
||||
if userID == "" {
|
||||
@@ -1943,9 +2021,14 @@ func (h *GroupHandler) HandleGetGroupMemberList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
|
||||
members, total, err := h.groupService.GetMembers(groupID, page, pageSize)
|
||||
// 成员校验在 service 层完成。
|
||||
members, total, err := h.groupService.GetMembersForUser(userID, groupID, page, pageSize)
|
||||
if err != nil {
|
||||
if err == service.ErrGroupNotFound {
|
||||
if errors.Is(err, service.ErrNotGroupMember) {
|
||||
response.Forbidden(c, "不是群成员")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrGroupNotFound) {
|
||||
response.NotFound(c, "群组不存在")
|
||||
return
|
||||
}
|
||||
|
||||
166
internal/handler/livekit_handler.go
Normal file
166
internal/handler/livekit_handler.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"with_you/internal/config"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// LiveKitHandler LiveKit 令牌和 Webhook 处理器
|
||||
type LiveKitHandler struct {
|
||||
liveKitService service.LiveKitService
|
||||
callService service.CallService
|
||||
config *config.LiveKitConfig
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLiveKitHandler 创建 LiveKit 处理器
|
||||
func NewLiveKitHandler(
|
||||
liveKitService service.LiveKitService,
|
||||
callService service.CallService,
|
||||
cfg *config.Config,
|
||||
logger *zap.Logger,
|
||||
) *LiveKitHandler {
|
||||
return &LiveKitHandler{
|
||||
liveKitService: liveKitService,
|
||||
callService: callService,
|
||||
config: &cfg.LiveKit,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken 生成 LiveKit 访问令牌
|
||||
// GET /api/v1/calls/token?room=<callID>
|
||||
func (h *LiveKitHandler) GetToken(c *gin.Context) {
|
||||
if !h.config.Enabled {
|
||||
response.Forbidden(c, "livekit is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
room := c.Query("room")
|
||||
if room == "" {
|
||||
response.BadRequest(c, "room parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证用户是该通话的参与者
|
||||
activeCall := h.callService.GetActiveCall(c.Request.Context(), room)
|
||||
if activeCall == nil {
|
||||
response.NotFound(c, "call not found or already ended")
|
||||
return
|
||||
}
|
||||
|
||||
isParticipant := false
|
||||
for _, p := range activeCall.Participants {
|
||||
if p.UserID == userID.(string) {
|
||||
isParticipant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isParticipant && activeCall.CallerID != userID.(string) && activeCall.CalleeID != userID.(string) {
|
||||
response.Forbidden(c, "not a participant of this call")
|
||||
return
|
||||
}
|
||||
|
||||
canPublish := true
|
||||
canSubscribe := true
|
||||
if cp := c.Query("can_publish"); cp != "" {
|
||||
canPublish, _ = strconv.ParseBool(cp)
|
||||
}
|
||||
if cs := c.Query("can_subscribe"); cs != "" {
|
||||
canSubscribe, _ = strconv.ParseBool(cs)
|
||||
}
|
||||
|
||||
token, err := h.liveKitService.GenerateToken(room, userID.(string), canPublish, canSubscribe)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to generate livekit token", zap.Error(err))
|
||||
response.InternalServerError(c, "failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"token": token,
|
||||
"url": h.config.URL,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleWebhook 处理 LiveKit Webhook 回调
|
||||
// POST /api/v1/calls/webhook
|
||||
func (h *LiveKitHandler) HandleWebhook(c *gin.Context) {
|
||||
if !h.config.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "livekit is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to read webhook body", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
event, err := h.liveKitService.ProcessWebhook(c.Request.Context(), body, authHeader)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to process webhook", zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid webhook"})
|
||||
return
|
||||
}
|
||||
|
||||
// 根据 webhook 事件类型处理
|
||||
switch event.Event {
|
||||
case "room_finished":
|
||||
h.handleRoomFinished(c.Request.Context(), event)
|
||||
case "participant_left":
|
||||
identity := event.Participant.GetIdentity()
|
||||
roomName := event.Room.GetName()
|
||||
h.logger.Info("participant left room",
|
||||
zap.String("room", roomName),
|
||||
zap.String("identity", identity),
|
||||
)
|
||||
if identity != "" && roomName != "" {
|
||||
if err := h.callService.ParticipantLeave(c.Request.Context(), roomName, identity, "left_livekit"); err != nil {
|
||||
h.logger.Warn("failed to handle participant_left",
|
||||
zap.String("room", roomName),
|
||||
zap.String("identity", identity),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleRoomFinished 处理 room_finished 事件,作为通话历史持久化的安全兜底
|
||||
func (h *LiveKitHandler) handleRoomFinished(ctx context.Context, event *livekit.WebhookEvent) {
|
||||
roomName := event.Room.GetName()
|
||||
if roomName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试结束通话(如果尚未结束)
|
||||
// callService.End 会检查调用是否仍然活跃,如果已经结束则返回 nil
|
||||
_, err := h.callService.End(ctx, roomName, "", "room_finished")
|
||||
if err != nil {
|
||||
h.logger.Debug("room_finished: call end returned error (may already be ended)",
|
||||
zap.String("room", roomName),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
491
internal/handler/material_handler.go
Normal file
491
internal/handler/material_handler.go
Normal file
@@ -0,0 +1,491 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/query"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// MaterialHandler 学习资料处理器
|
||||
type MaterialHandler struct {
|
||||
materialService service.MaterialService
|
||||
}
|
||||
|
||||
// NewMaterialHandler 创建学习资料处理器
|
||||
func NewMaterialHandler(materialService service.MaterialService) *MaterialHandler {
|
||||
return &MaterialHandler{materialService: materialService}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 响应结构体
|
||||
// =====================================================
|
||||
|
||||
type subjectResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
MaterialCount int `json:"material_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type materialResponse struct {
|
||||
ID string `json:"id"`
|
||||
SubjectID string `json:"subject_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
FileType string `json:"file_type"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileURL string `json:"file_url"`
|
||||
FileName string `json:"file_name"`
|
||||
DownloadCount int `json:"download_count"`
|
||||
AuthorID string `json:"author_id,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Subject *subjectResponse `json:"subject,omitempty"`
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 转换函数
|
||||
// =====================================================
|
||||
|
||||
func toSubjectResponse(s *model.MaterialSubject, materialCount int64) *subjectResponse {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return &subjectResponse{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
Icon: s.Icon,
|
||||
Color: s.Color,
|
||||
Description: s.Description,
|
||||
SortOrder: s.SortOrder,
|
||||
IsActive: s.IsActive,
|
||||
MaterialCount: int(materialCount),
|
||||
CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: s.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
|
||||
func toMaterialResponse(f *model.MaterialFile) *materialResponse {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
var subjectResp *subjectResponse
|
||||
if f.Subject != nil {
|
||||
subjectResp = toSubjectResponse(f.Subject, 0)
|
||||
}
|
||||
return &materialResponse{
|
||||
ID: f.ID,
|
||||
SubjectID: f.SubjectID,
|
||||
Title: f.Title,
|
||||
Description: f.Description,
|
||||
FileType: string(f.FileType),
|
||||
FileSize: f.FileSize,
|
||||
FileURL: f.FileURL,
|
||||
FileName: f.FileName,
|
||||
DownloadCount: f.DownloadCount,
|
||||
AuthorID: f.AuthorID,
|
||||
Tags: f.Tags,
|
||||
Status: string(f.Status),
|
||||
CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: f.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
Subject: subjectResp,
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 公开API - 学科相关
|
||||
// =====================================================
|
||||
|
||||
// ListSubjects 公开学科列表(仅启用)
|
||||
func (h *MaterialHandler) ListSubjects(c *gin.Context) {
|
||||
items, err := h.materialService.ListActiveSubjects()
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get subjects")
|
||||
return
|
||||
}
|
||||
result := make([]*subjectResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
_, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID)
|
||||
result = append(result, toSubjectResponse(item, count))
|
||||
}
|
||||
response.Success(c, gin.H{"list": result})
|
||||
}
|
||||
|
||||
// GetSubject 获取学科详情
|
||||
func (h *MaterialHandler) GetSubject(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
item, count, err := h.materialService.GetSubjectByIDWithMaterialCount(id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "subject not found")
|
||||
return
|
||||
}
|
||||
response.Success(c, toSubjectResponse(item, count))
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 公开API - 资料相关
|
||||
// =====================================================
|
||||
|
||||
// ListMaterials 资料列表
|
||||
func (h *MaterialHandler) ListMaterials(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
subjectID := c.Query("subject_id")
|
||||
fileType := c.Query("file_type")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
params := query.MaterialFileQueryParams{
|
||||
SubjectID: subjectID,
|
||||
FileType: fileType,
|
||||
Keyword: keyword,
|
||||
Status: string(model.MaterialStatusActive),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
SortBy: "created_at",
|
||||
SortOrder: "DESC",
|
||||
}
|
||||
|
||||
items, total, err := h.materialService.ListMaterials(params)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get materials")
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]*materialResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, toMaterialResponse(item))
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"list": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"has_more": int64(page*pageSize) < total,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMaterial 获取资料详情
|
||||
func (h *MaterialHandler) GetMaterial(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
item, err := h.materialService.GetMaterialByIDWithSubject(id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "material not found")
|
||||
return
|
||||
}
|
||||
response.Success(c, toMaterialResponse(item))
|
||||
}
|
||||
|
||||
// SearchMaterials 搜索资料
|
||||
func (h *MaterialHandler) SearchMaterials(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
items, total, err := h.materialService.SearchMaterials(keyword, page, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to search materials")
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]*materialResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, toMaterialResponse(item))
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"list": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"has_more": int64(page*pageSize) < total,
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadMaterial 下载资料(增加下载次数)
|
||||
func (h *MaterialHandler) DownloadMaterial(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
item, err := h.materialService.GetMaterialByID(id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "material not found")
|
||||
return
|
||||
}
|
||||
// 增加下载次数
|
||||
_ = h.materialService.IncrementDownloadCount(id)
|
||||
// 返回下载链接
|
||||
response.Success(c, gin.H{
|
||||
"id": item.ID,
|
||||
"file_url": item.FileURL,
|
||||
"file_name": item.FileName,
|
||||
})
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 管理API - 学科相关
|
||||
// =====================================================
|
||||
|
||||
// AdminListSubjects 管理端学科列表(全部)
|
||||
func (h *MaterialHandler) AdminListSubjects(c *gin.Context) {
|
||||
items, err := h.materialService.ListAllSubjects()
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get subjects")
|
||||
return
|
||||
}
|
||||
result := make([]*subjectResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
_, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID)
|
||||
result = append(result, toSubjectResponse(item, count))
|
||||
}
|
||||
response.Success(c, gin.H{"list": result})
|
||||
}
|
||||
|
||||
type adminSubjectUpsertRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Icon string `json:"icon" binding:"max=50"`
|
||||
Color string `json:"color" binding:"max=20"`
|
||||
Description string `json:"description" binding:"max=255"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// AdminCreateSubject 创建学科
|
||||
func (h *MaterialHandler) AdminCreateSubject(c *gin.Context) {
|
||||
var req adminSubjectUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
isActive := true
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
item, err := h.materialService.CreateSubject(req.Name, req.Icon, req.Color, req.Description, req.SortOrder, isActive)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to create subject")
|
||||
return
|
||||
}
|
||||
response.Success(c, toSubjectResponse(item, 0))
|
||||
}
|
||||
|
||||
// AdminUpdateSubject 更新学科
|
||||
func (h *MaterialHandler) AdminUpdateSubject(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req adminSubjectUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
isActive := true
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
item, err := h.materialService.UpdateSubject(id, req.Name, req.Icon, req.Color, req.Description, req.SortOrder, isActive)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to update subject")
|
||||
return
|
||||
}
|
||||
_, count, _ := h.materialService.GetSubjectByIDWithMaterialCount(item.ID)
|
||||
response.Success(c, toSubjectResponse(item, count))
|
||||
}
|
||||
|
||||
// AdminDeleteSubject 删除学科
|
||||
func (h *MaterialHandler) AdminDeleteSubject(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.materialService.DeleteSubject(id); err != nil {
|
||||
if errors.Is(err, service.ErrSubjectHasMaterials) {
|
||||
response.BadRequest(c, "学科下存在资料,无法删除")
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to delete subject")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 管理API - 资料相关
|
||||
// =====================================================
|
||||
|
||||
// AdminListMaterials 管理端资料列表
|
||||
func (h *MaterialHandler) AdminListMaterials(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
subjectID := c.Query("subject_id")
|
||||
fileType := c.Query("file_type")
|
||||
status := c.Query("status")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
params := query.MaterialFileQueryParams{
|
||||
SubjectID: subjectID,
|
||||
FileType: fileType,
|
||||
Status: status,
|
||||
Keyword: keyword,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
SortBy: "created_at",
|
||||
SortOrder: "DESC",
|
||||
}
|
||||
|
||||
items, total, err := h.materialService.ListMaterials(params)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get materials")
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]*materialResponse, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, toMaterialResponse(item))
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"list": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
type adminMaterialCreateRequest struct {
|
||||
SubjectID string `json:"subject_id" binding:"required"`
|
||||
Title string `json:"title" binding:"required,max=200"`
|
||||
Description string `json:"description"`
|
||||
FileType string `json:"file_type" binding:"required,oneof=pdf word ppt"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileURL string `json:"file_url" binding:"required"`
|
||||
FileName string `json:"file_name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type adminMaterialUpdateRequest struct {
|
||||
SubjectID string `json:"subject_id"`
|
||||
Title string `json:"title" binding:"max=200"`
|
||||
Description string `json:"description"`
|
||||
FileType string `json:"file_type" binding:"omitempty,oneof=pdf word ppt"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileURL string `json:"file_url"`
|
||||
FileName string `json:"file_name"`
|
||||
Tags []string `json:"tags"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
}
|
||||
|
||||
// AdminGetMaterial 管理端获取资料详情
|
||||
func (h *MaterialHandler) AdminGetMaterial(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
item, err := h.materialService.GetMaterialByIDWithSubject(id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "material not found")
|
||||
return
|
||||
}
|
||||
response.Success(c, toMaterialResponse(item))
|
||||
}
|
||||
|
||||
// AdminCreateMaterial 创建资料
|
||||
func (h *MaterialHandler) AdminCreateMaterial(c *gin.Context) {
|
||||
var req adminMaterialCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户ID作为作者
|
||||
userID, exists := c.Get("user_id")
|
||||
var authorID string
|
||||
if exists {
|
||||
authorID = userID.(string)
|
||||
}
|
||||
|
||||
file := &model.MaterialFile{
|
||||
SubjectID: req.SubjectID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
FileType: model.MaterialFileType(req.FileType),
|
||||
FileSize: req.FileSize,
|
||||
FileURL: req.FileURL,
|
||||
FileName: req.FileName,
|
||||
Tags: req.Tags,
|
||||
Status: model.MaterialStatusActive,
|
||||
AuthorID: authorID,
|
||||
}
|
||||
|
||||
if err := h.materialService.CreateMaterial(file); err != nil {
|
||||
response.InternalServerError(c, "failed to create material")
|
||||
return
|
||||
}
|
||||
response.Success(c, toMaterialResponse(file))
|
||||
}
|
||||
|
||||
// AdminUpdateMaterial 更新资料
|
||||
func (h *MaterialHandler) AdminUpdateMaterial(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req adminMaterialUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.materialService.GetMaterialByID(id)
|
||||
if err != nil {
|
||||
response.NotFound(c, "material not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.SubjectID != "" {
|
||||
file.SubjectID = req.SubjectID
|
||||
}
|
||||
if req.Title != "" {
|
||||
file.Title = req.Title
|
||||
}
|
||||
if req.Description != "" {
|
||||
file.Description = req.Description
|
||||
}
|
||||
if req.FileType != "" {
|
||||
file.FileType = model.MaterialFileType(req.FileType)
|
||||
}
|
||||
if req.FileSize > 0 {
|
||||
file.FileSize = req.FileSize
|
||||
}
|
||||
if req.FileURL != "" {
|
||||
file.FileURL = req.FileURL
|
||||
}
|
||||
if req.FileName != "" {
|
||||
file.FileName = req.FileName
|
||||
}
|
||||
if req.Tags != nil {
|
||||
file.Tags = req.Tags
|
||||
}
|
||||
if req.Status != "" {
|
||||
file.Status = model.MaterialStatus(req.Status)
|
||||
}
|
||||
|
||||
if err := h.materialService.UpdateMaterial(file); err != nil {
|
||||
response.InternalServerError(c, "failed to update material")
|
||||
return
|
||||
}
|
||||
response.Success(c, toMaterialResponse(file))
|
||||
}
|
||||
|
||||
// AdminDeleteMaterial 删除资料
|
||||
func (h *MaterialHandler) AdminDeleteMaterial(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.materialService.DeleteMaterial(id); err != nil {
|
||||
response.InternalServerError(c, "failed to delete material")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
@@ -2,117 +2,123 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/cursor"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/pkg/ws"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// enrichConversations 批量填充会话列表响应数据(解决 N+1 问题)
|
||||
func (h *MessageHandler) enrichConversations(ctx context.Context, convs []*model.Conversation, userID string) []*dto.ConversationResponse {
|
||||
if len(convs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
convIDs := make([]string, len(convs))
|
||||
for i, c := range convs {
|
||||
convIDs[i] = c.ID
|
||||
}
|
||||
|
||||
// 批量查询:4-5 次查询替代 N*5 次
|
||||
unreadCounts, _ := h.chatService.GetUnreadCountBatch(ctx, userID, convIDs)
|
||||
lastMessages, _ := h.chatService.GetLastMessagesBatch(ctx, convIDs)
|
||||
myParticipants, _ := h.messageService.GetMyParticipantsBatch(ctx, convIDs, userID)
|
||||
allParticipants, _ := h.messageService.GetParticipantsBatch(ctx, convIDs)
|
||||
|
||||
// 收集需要额外查询的 ID
|
||||
var groupIDs []string
|
||||
var userIDs []string
|
||||
for _, conv := range convs {
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
groupIDs = append(groupIDs, *conv.GroupID)
|
||||
}
|
||||
}
|
||||
for _, participants := range allParticipants {
|
||||
for _, p := range participants {
|
||||
if p.UserID != userID {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memberCounts, _ := h.groupService.GetMemberCountBatch(ctx, groupIDs)
|
||||
usersMap, _ := h.userService.GetUsersByIDs(ctx, userIDs)
|
||||
|
||||
// 组装响应
|
||||
result := make([]*dto.ConversationResponse, len(convs))
|
||||
for i, conv := range convs {
|
||||
unreadCount := unreadCounts[conv.ID]
|
||||
lastMessage := lastMessages[conv.ID]
|
||||
|
||||
myP := myParticipants[conv.ID]
|
||||
isPinned := myP != nil && myP.IsPinned
|
||||
notificationMuted := myP != nil && myP.NotificationMuted
|
||||
|
||||
var resp *dto.ConversationResponse
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
if mc, ok := memberCounts[*conv.GroupID]; ok {
|
||||
resp.MemberCount = int(mc)
|
||||
}
|
||||
} else {
|
||||
participants := allParticipants[conv.ID]
|
||||
var users []*model.User
|
||||
for _, p := range participants {
|
||||
if p.UserID == userID {
|
||||
continue
|
||||
}
|
||||
if u, ok := usersMap[p.UserID]; ok {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
resp = dto.ConvertConversationToResponse(conv, users, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
}
|
||||
result[i] = resp
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MessageHandler 消息处理器
|
||||
type MessageHandler struct {
|
||||
chatService service.ChatService
|
||||
messageService *service.MessageService
|
||||
messageService service.MessageService
|
||||
userService service.UserService
|
||||
groupService service.GroupService
|
||||
sseHub *sse.Hub
|
||||
uploadService service.UploadService
|
||||
wsPublisher ws.MessagePublisher
|
||||
}
|
||||
|
||||
// NewMessageHandler 创建消息处理器
|
||||
func NewMessageHandler(chatService service.ChatService, messageService *service.MessageService, userService service.UserService, groupService service.GroupService, sseHub *sse.Hub) *MessageHandler {
|
||||
func NewMessageHandler(chatService service.ChatService, messageService service.MessageService, userService service.UserService, groupService service.GroupService, uploadService service.UploadService, wsPublisher ws.MessagePublisher) *MessageHandler {
|
||||
return &MessageHandler{
|
||||
chatService: chatService,
|
||||
messageService: messageService,
|
||||
userService: userService,
|
||||
groupService: groupService,
|
||||
sseHub: sseHub,
|
||||
uploadService: uploadService,
|
||||
wsPublisher: wsPublisher,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSSE 实时消息订阅(SSE)
|
||||
// GET /api/v1/realtime/sse
|
||||
func (h *MessageHandler) HandleSSE(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
if h.sseHub == nil {
|
||||
response.InternalServerError(c, "sse hub not available")
|
||||
return
|
||||
}
|
||||
|
||||
lastID := sse.ParseEventID(c.GetHeader("Last-Event-ID"))
|
||||
if lastID == 0 {
|
||||
lastID = sse.ParseEventID(c.Query("last_event_id"))
|
||||
}
|
||||
ch, cancel, replay := h.sseHub.Subscribe(userID, lastID)
|
||||
defer cancel()
|
||||
|
||||
w := c.Writer
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
response.InternalServerError(c, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
c.Status(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
writeEvent := func(ev sse.Event) bool {
|
||||
data, err := sse.EncodeData(ev)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Event, data); err != nil {
|
||||
return false
|
||||
}
|
||||
flusher.Flush()
|
||||
return true
|
||||
}
|
||||
|
||||
for _, ev := range replay {
|
||||
if !writeEvent(ev) {
|
||||
return
|
||||
// convertMessagesWithExpiry 将消息列表转换为响应,并对已过期的文件 segment 注入 expired 标记。
|
||||
// 若 uploadService 不可用或查询失败,降级为不带过期标记的普通转换。
|
||||
func (h *MessageHandler) convertMessagesWithExpiry(ctx context.Context, messages []*model.Message) []*dto.MessageResponse {
|
||||
var expiredSet map[string]struct{}
|
||||
if h.uploadService != nil {
|
||||
if es, err := h.uploadService.GetExpiredURLSet(ctx); err == nil {
|
||||
expiredSet = es
|
||||
}
|
||||
}
|
||||
|
||||
// Use a shorter heartbeat interval to survive common 50-60s proxy idle timeout.
|
||||
heartbeat := time.NewTicker(10 * 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:
|
||||
// Comment keepalive line for SSE intermediaries/proxies.
|
||||
if _, err := fmt.Fprint(w, ": keepalive\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
if _, err := fmt.Fprint(w, "event: heartbeat\ndata: {}\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if len(expiredSet) == 0 {
|
||||
return dto.ConvertMessagesToResponse(messages)
|
||||
}
|
||||
return dto.ConvertMessagesToResponseWithExpiry(messages, expiredSet)
|
||||
}
|
||||
|
||||
// HandleTyping 输入状态上报
|
||||
@@ -161,36 +167,8 @@ func (h *MessageHandler) GetConversations(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := make([]*dto.ConversationResponse, len(filteredConvs))
|
||||
for i, conv := range filteredConvs {
|
||||
// 获取未读数
|
||||
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
|
||||
|
||||
// 获取最后一条消息
|
||||
var lastMessage *model.Message
|
||||
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
|
||||
if len(messages) > 0 {
|
||||
lastMessage = messages[0]
|
||||
}
|
||||
|
||||
// 群聊时返回member_count,私聊时返回participants
|
||||
var resp *dto.ConversationResponse
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
// 群聊:实时计算群成员数量
|
||||
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
|
||||
// 创建响应并设置member_count
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned)
|
||||
resp.MemberCount = memberCount
|
||||
} else {
|
||||
// 私聊:获取参与者信息
|
||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned)
|
||||
}
|
||||
result[i] = resp
|
||||
}
|
||||
// 批量填充会话数据(解决 N+1 问题)
|
||||
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
||||
|
||||
// 更新 total 为过滤后的数量
|
||||
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
||||
@@ -234,8 +212,9 @@ func (h *MessageHandler) CreateConversation(c *gin.Context) {
|
||||
participants := []*model.User{targetUser}
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
||||
|
||||
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned))
|
||||
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned, notificationMuted))
|
||||
}
|
||||
|
||||
// GetConversationByID 获取会话详情
|
||||
@@ -269,18 +248,26 @@ func (h *MessageHandler) GetConversationByID(c *gin.Context) {
|
||||
// 获取当前用户的已读位置
|
||||
myLastReadSeq := int64(0)
|
||||
isPinned := false
|
||||
notificationMuted := false
|
||||
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
|
||||
for _, p := range allParticipants {
|
||||
if p.UserID == userID {
|
||||
myLastReadSeq = p.LastReadSeq
|
||||
isPinned = p.IsPinned
|
||||
notificationMuted = p.NotificationMuted
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 获取对方用户的已读位置
|
||||
otherLastReadSeq := int64(0)
|
||||
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned))
|
||||
for _, p := range allParticipants {
|
||||
if p.UserID != userID {
|
||||
otherLastReadSeq = p.LastReadSeq
|
||||
break
|
||||
}
|
||||
}
|
||||
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
|
||||
}
|
||||
|
||||
// GetMessages 获取消息列表
|
||||
@@ -317,8 +304,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -345,8 +332,8 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -365,12 +352,43 @@ func (h *MessageHandler) GetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetReplyMessage 按消息 ID 获取被引用消息(用于引用预览回填/跳转)
|
||||
// GET /api/v1/messages/:id
|
||||
func (h *MessageHandler) GetReplyMessage(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
messageID := c.Param("id")
|
||||
if messageID == "" {
|
||||
response.BadRequest(c, "message id is required")
|
||||
return
|
||||
}
|
||||
|
||||
message, err := h.chatService.GetReplyMessage(c.Request.Context(), messageID, userID)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式(标记已过期文件 / 撤回占位等,保持与列表接口一致)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), []*model.Message{message})
|
||||
if len(result) == 0 {
|
||||
response.InternalServerError(c, "failed to convert message")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result[0])
|
||||
}
|
||||
|
||||
// SendMessage 发送消息
|
||||
// POST /api/conversations/:id/messages
|
||||
func (h *MessageHandler) SendMessage(c *gin.Context) {
|
||||
@@ -393,8 +411,7 @@ func (h *MessageHandler) SendMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 直接使用 segments
|
||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID)
|
||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, req.Segments, req.ReplyToID, req.ClientMsgID)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
@@ -436,7 +453,7 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID)
|
||||
msg, err := h.chatService.SendMessage(c.Request.Context(), userID, conversationID, params.Segments, params.ReplyToID, params.ClientMsgID)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
@@ -444,13 +461,14 @@ func (h *MessageHandler) HandleSendMessage(c *gin.Context) {
|
||||
|
||||
// 构建 WSEventResponse 格式响应
|
||||
wsResponse := dto.WSEventResponse{
|
||||
ID: msg.ID,
|
||||
Time: msg.CreatedAt.UnixMilli(),
|
||||
Type: "message",
|
||||
DetailType: params.DetailType,
|
||||
Seq: strconv.FormatInt(msg.Seq, 10),
|
||||
Segments: params.Segments,
|
||||
SenderID: userID,
|
||||
ID: msg.ID,
|
||||
Time: msg.CreatedAt.UnixMilli(),
|
||||
Type: "message",
|
||||
DetailType: params.DetailType,
|
||||
ConversationID: conversationID,
|
||||
Seq: msg.Seq,
|
||||
Segments: params.Segments,
|
||||
SenderID: userID,
|
||||
}
|
||||
|
||||
response.Success(c, wsResponse)
|
||||
@@ -514,36 +532,8 @@ func (h *MessageHandler) HandleGetConversationList(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := make([]*dto.ConversationResponse, len(filteredConvs))
|
||||
for i, conv := range filteredConvs {
|
||||
// 获取未读数
|
||||
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
|
||||
|
||||
// 获取最后一条消息
|
||||
var lastMessage *model.Message
|
||||
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
|
||||
if len(messages) > 0 {
|
||||
lastMessage = messages[0]
|
||||
}
|
||||
|
||||
// 群聊时返回member_count,私聊时返回participants
|
||||
var resp *dto.ConversationResponse
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
// 群聊:实时计算群成员数量
|
||||
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
|
||||
// 创建响应并设置member_count
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned)
|
||||
resp.MemberCount = memberCount
|
||||
} else {
|
||||
// 私聊:获取参与者信息
|
||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned)
|
||||
}
|
||||
result[i] = resp
|
||||
}
|
||||
// 批量填充会话数据(解决 N+1 问题)
|
||||
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
||||
|
||||
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
||||
}
|
||||
@@ -623,6 +613,57 @@ func (h *MessageHandler) GetUnreadCount(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGetSyncData 获取同步元数据(轻量级 seq + 时间)
|
||||
// GET /api/v1/conversations/sync-data
|
||||
func (h *MessageHandler) HandleGetSyncData(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.chatService.GetSyncData(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get sync data")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, &dto.SyncDataResponse{
|
||||
Conversations: items,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGetSyncByVersion 增量同步:按版本号获取会话变更
|
||||
// GET /api/v1/conversations/sync?version=0&limit=100
|
||||
func (h *MessageHandler) HandleGetSyncByVersion(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
versionStr := c.DefaultQuery("version", "0")
|
||||
sinceVersion, err := strconv.ParseInt(versionStr, 10, 64)
|
||||
if err != nil || sinceVersion < 0 {
|
||||
response.BadRequest(c, "invalid version parameter")
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit < 1 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
result, err := h.chatService.GetSyncByVersion(c.Request.Context(), userID, sinceVersion, limit)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetConversationUnreadCount 获取单个会话的未读数
|
||||
// GET /api/conversations/:id/unread/count
|
||||
func (h *MessageHandler) GetConversationUnreadCount(c *gin.Context) {
|
||||
@@ -691,16 +732,6 @@ func (h *MessageHandler) DeleteMessage(c *gin.Context) {
|
||||
response.SuccessWithMessage(c, "message deleted", nil)
|
||||
}
|
||||
|
||||
// 辅助函数:验证内容类型
|
||||
func isValidContentType(contentType model.ContentType) bool {
|
||||
switch contentType {
|
||||
case model.ContentTypeText, model.ContentTypeImage, model.ContentTypeVideo, model.ContentTypeAudio, model.ContentTypeFile:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:获取会话参与者信息
|
||||
func (h *MessageHandler) getConversationParticipants(ctx context.Context, conversationID string, currentUserID string) ([]*model.User, error) {
|
||||
// 从repository获取参与者列表
|
||||
@@ -784,8 +815,9 @@ func (h *MessageHandler) HandleCreateConversation(c *gin.Context) {
|
||||
participants := []*model.User{targetUser}
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
||||
|
||||
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned))
|
||||
response.Success(c, dto.ConvertConversationToResponse(conv, participants, 0, nil, isPinned, notificationMuted))
|
||||
}
|
||||
|
||||
// HandleGetConversation 获取会话详情
|
||||
@@ -815,21 +847,29 @@ func (h *MessageHandler) HandleGetConversation(c *gin.Context) {
|
||||
// 获取参与者信息
|
||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conversationID, userID)
|
||||
|
||||
// 获取当前用户的已读位置
|
||||
// 获取当前用户的已读位置(优先从 Redis 缓存读取)
|
||||
myLastReadSeq := int64(0)
|
||||
isPinned := false
|
||||
notificationMuted := false
|
||||
allParticipants, _ := h.messageService.GetConversationParticipants(conversationID)
|
||||
for _, p := range allParticipants {
|
||||
if p.UserID == userID {
|
||||
myLastReadSeq = p.LastReadSeq
|
||||
isPinned = p.IsPinned
|
||||
notificationMuted = p.NotificationMuted
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 获取对方用户的已读位置
|
||||
// 获取对方用户的已读位置(优先从 Redis 缓存读取)
|
||||
otherLastReadSeq := int64(0)
|
||||
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned))
|
||||
for _, p := range allParticipants {
|
||||
if p.UserID != userID {
|
||||
otherLastReadSeq = p.LastReadSeq
|
||||
break
|
||||
}
|
||||
}
|
||||
response.Success(c, dto.ConvertConversationToDetailResponse(conv, participants, unreadCount, nil, myLastReadSeq, otherLastReadSeq, isPinned, notificationMuted))
|
||||
}
|
||||
|
||||
// HandleGetMessages 获取会话消息
|
||||
@@ -865,8 +905,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -893,8 +933,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Success(c, &dto.MessageSyncResponse{
|
||||
Messages: result,
|
||||
@@ -913,8 +953,8 @@ func (h *MessageHandler) HandleGetMessages(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := dto.ConvertMessagesToResponse(messages)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
result := h.convertMessagesWithExpiry(c.Request.Context(), messages)
|
||||
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
@@ -949,6 +989,33 @@ func (h *MessageHandler) HandleMarkRead(c *gin.Context) {
|
||||
response.SuccessWithMessage(c, "marked as read", nil)
|
||||
}
|
||||
|
||||
// HandleMarkReadAll 批量标记所有会话已读
|
||||
// POST /api/v1/conversations/read-all
|
||||
func (h *MessageHandler) HandleMarkReadAll(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.BatchMarkReadRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
successCount, err := h.chatService.MarkAsReadBatch(c.Request.Context(), userID, req.Conversations)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to mark all as read")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, &dto.BatchMarkReadResponse{
|
||||
SuccessCount: successCount,
|
||||
TotalCount: len(req.Conversations),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleSetConversationPinned 设置会话置顶
|
||||
// PUT /api/v1/conversations/:id/pinned
|
||||
func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
||||
@@ -983,6 +1050,40 @@ func (h *MessageHandler) HandleSetConversationPinned(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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 游标分页获取会话消息
|
||||
@@ -1007,15 +1108,15 @@ func (h *MessageHandler) GetMessagesByCursor(c *gin.Context) {
|
||||
// 解析游标分页参数
|
||||
pageReq := parseCursorPageRequest(c)
|
||||
|
||||
// 调用服务层
|
||||
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, pageReq)
|
||||
// 调用服务层(内部会校验 currentUserID 是否是会话参与者,否则拒绝)
|
||||
result, err := h.messageService.GetMessagesByCursor(c.Request.Context(), conversationID, userID, pageReq)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get messages")
|
||||
response.HandleError(c, err, "failed to get messages")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
items := dto.ConvertMessagesToResponse(result.Items)
|
||||
// 转换为响应格式(标记已过期文件)
|
||||
items := h.convertMessagesWithExpiry(c.Request.Context(), result.Items)
|
||||
|
||||
response.Success(c, &dto.MessageCursorPageResponse{
|
||||
Items: items,
|
||||
@@ -1056,36 +1157,8 @@ func (h *MessageHandler) GetConversationsByCursor(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
items := make([]*dto.ConversationResponse, len(filteredItems))
|
||||
for i, conv := range filteredItems {
|
||||
// 获取未读数
|
||||
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)
|
||||
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)
|
||||
}
|
||||
items[i] = resp
|
||||
}
|
||||
|
||||
// 批量填充会话数据(解决 N+1 问题)
|
||||
items := h.enrichConversations(c.Request.Context(), filteredItems, userID)
|
||||
response.Success(c, &dto.ConversationCursorPageResponse{
|
||||
Items: items,
|
||||
NextCursor: result.NextCursor,
|
||||
|
||||
@@ -5,19 +5,19 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/pkg/cursor"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// NotificationHandler 通知处理器
|
||||
type NotificationHandler struct {
|
||||
notificationService *service.NotificationService
|
||||
notificationService service.NotificationService
|
||||
}
|
||||
|
||||
// NewNotificationHandler 创建通知处理器
|
||||
func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler {
|
||||
func NewNotificationHandler(notificationService service.NotificationService) *NotificationHandler {
|
||||
return &NotificationHandler{
|
||||
notificationService: notificationService,
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
||||
|
||||
err := h.notificationService.MarkAsReadWithUserID(c.Request.Context(), id, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to mark as read")
|
||||
response.HandleError(c, err, "failed to mark as read")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
|
||||
|
||||
err := h.notificationService.DeleteNotification(c.Request.Context(), id, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to delete notification")
|
||||
response.HandleError(c, err, "failed to delete notification")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,89 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/cursor"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/cursor"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// PostHandler 帖子处理器
|
||||
type PostHandler struct {
|
||||
postService service.PostService
|
||||
userService service.UserService
|
||||
postService service.PostService
|
||||
postRefService service.PostRefService
|
||||
userService service.UserService
|
||||
channelService service.ChannelService
|
||||
}
|
||||
|
||||
// NewPostHandler 创建帖子处理器
|
||||
func NewPostHandler(postService service.PostService, userService service.UserService) *PostHandler {
|
||||
func NewPostHandler(postService service.PostService, postRefService service.PostRefService, userService service.UserService, channelService service.ChannelService) *PostHandler {
|
||||
return &PostHandler{
|
||||
postService: postService,
|
||||
userService: userService,
|
||||
postService: postService,
|
||||
postRefService: postRefService,
|
||||
userService: userService,
|
||||
channelService: channelService,
|
||||
}
|
||||
}
|
||||
|
||||
// collectAuthorIDs 从帖子列表中收集去重的作者ID
|
||||
func collectAuthorIDs(posts []*model.Post) []string {
|
||||
seen := make(map[string]struct{})
|
||||
ids := make([]string, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if _, ok := seen[p.UserID]; !ok {
|
||||
seen[p.UserID] = struct{}{}
|
||||
ids = append(ids, p.UserID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// filterBlockedPosts 过滤掉被当前用户拉黑的作者发布的帖子
|
||||
func (h *PostHandler) filterBlockedPosts(ctx context.Context, posts []*model.Post, currentUserID string) []*model.Post {
|
||||
if currentUserID == "" || len(posts) == 0 {
|
||||
return posts
|
||||
}
|
||||
|
||||
authorIDs := collectAuthorIDs(posts)
|
||||
blockedMap, err := h.userService.IsBlockedBatch(ctx, currentUserID, authorIDs)
|
||||
if err != nil {
|
||||
return posts
|
||||
}
|
||||
|
||||
filtered := make([]*model.Post, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if blockedMap[p.UserID] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// channelMapForPosts 批量解析帖子中的频道 ID,用于填充响应里的 channel 名称
|
||||
func (h *PostHandler) channelMapForPosts(posts []*model.Post) map[string]*model.Channel {
|
||||
ids := dto.CollectPostChannelIDs(posts)
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
m, err := h.channelService.MapByIDs(ids)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Create 创建帖子
|
||||
func (h *PostHandler) Create(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -37,31 +92,42 @@ func (h *PostHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Images []string `json:"images"`
|
||||
ChannelID *string `json:"channel_id,omitempty"`
|
||||
}
|
||||
|
||||
var req CreateRequest
|
||||
var req dto.CreatePostWithSegmentsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, req.Content, req.Images, req.ChannelID)
|
||||
content := req.Content
|
||||
segments := req.Segments
|
||||
|
||||
if len(segments) == 0 && content == "" {
|
||||
response.BadRequest(c, "content or segments is required")
|
||||
return
|
||||
}
|
||||
|
||||
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, content, segments, req.Images, req.ChannelID, req.ClientRequestID)
|
||||
if err != nil {
|
||||
var moderationErr *service.PostModerationRejectedError
|
||||
if errors.As(err, &moderationErr) {
|
||||
response.BadRequest(c, moderationErr.UserMessage())
|
||||
// 幂等命中"进行中"占位:提示客户端稍候,避免重复提交
|
||||
if errors.Is(err, service.ErrDuplicatePostRequest) {
|
||||
response.ErrorWithStatusCode(c, http.StatusTooManyRequests, 429, err.Error())
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to create post")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.ConvertPostToResponse(post, false, false))
|
||||
if h.postRefService != nil && len(segments) > 0 {
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if err := h.postRefService.ProcessPostReferences(bgCtx, post.ID, segments); err != nil {
|
||||
log.Printf("[WARN] process post references failed for post %s: %v", post.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.ConvertPostToResponse(post, chMap, false, false))
|
||||
}
|
||||
|
||||
// GetByID 获取帖子(不增加浏览量)
|
||||
@@ -83,46 +149,60 @@ func (h *PostHandler) GetByID(c *gin.Context) {
|
||||
|
||||
// 注意:不再自动增加浏览量,浏览量通过 RecordView 端点单独记录
|
||||
|
||||
// 增强 post_ref segments
|
||||
enrichedSegments := dto.SegmentsOrDefault(post.Segments, post.Content)
|
||||
if h.postRefService != nil && len(enrichedSegments) > 0 {
|
||||
if enriched, err := h.postRefService.EnrichPostRefSegments(c.Request.Context(), enrichedSegments, currentUserID); err == nil {
|
||||
enrichedSegments = enriched
|
||||
}
|
||||
}
|
||||
|
||||
// 获取交互状态
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, currentUserID)
|
||||
|
||||
// 如果有当前用户,检查与帖子作者的相互关注状态
|
||||
var authorWithFollowStatus *dto.UserResponse
|
||||
if currentUserID != "" && post.User != nil {
|
||||
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
|
||||
if err == nil {
|
||||
authorWithFollowStatus = dto.ConvertUserToResponseWithMutualFollow(post.User, isFollowing, isFollowingMe)
|
||||
if post.User != nil {
|
||||
if currentUserID != "" {
|
||||
_, isFollowing, isFollowingMe, err := h.userService.GetUserByIDWithMutualFollowStatus(c.Request.Context(), post.UserID, currentUserID)
|
||||
if err == nil {
|
||||
authorWithFollowStatus = dto.ConvertUserToResponse(post.User, dto.WithFollowing(isFollowing, isFollowingMe))
|
||||
} else {
|
||||
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
|
||||
}
|
||||
} else {
|
||||
// 如果出错,使用默认的author
|
||||
authorWithFollowStatus = dto.ConvertUserToResponse(post.User)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
responseData := &dto.PostResponse{
|
||||
ID: post.ID,
|
||||
UserID: post.UserID,
|
||||
ChannelID: post.ChannelID,
|
||||
Title: post.Title,
|
||||
Content: post.Content,
|
||||
Images: dto.ConvertPostImagesToResponse(post.Images),
|
||||
Status: string(post.Status),
|
||||
LikesCount: post.LikesCount,
|
||||
CommentsCount: post.CommentsCount,
|
||||
FavoritesCount: post.FavoritesCount,
|
||||
SharesCount: post.SharesCount,
|
||||
ViewsCount: post.ViewsCount,
|
||||
IsPinned: post.IsPinned,
|
||||
IsLocked: post.IsLocked,
|
||||
IsVote: post.IsVote,
|
||||
ID: post.ID,
|
||||
UserID: post.UserID,
|
||||
ChannelID: post.ChannelID,
|
||||
Title: post.Title,
|
||||
Content: post.Content,
|
||||
Segments: enrichedSegments,
|
||||
Images: dto.ConvertPostImagesToResponse(post.Images),
|
||||
Status: string(post.Status),
|
||||
LikesCount: post.LikesCount,
|
||||
CommentsCount: post.CommentsCount,
|
||||
FavoritesCount: post.FavoritesCount,
|
||||
SharesCount: post.SharesCount,
|
||||
ViewsCount: post.ViewsCount,
|
||||
IsPinned: post.IsPinned,
|
||||
IsLocked: post.IsLocked,
|
||||
IsVote: post.IsVote,
|
||||
CreatedAt: dto.FormatTime(post.CreatedAt),
|
||||
UpdatedAt: dto.FormatTime(post.UpdatedAt),
|
||||
ContentEditedAt: dto.FormatTimePointer(post.ContentEditedAt),
|
||||
Author: authorWithFollowStatus,
|
||||
IsLiked: isLiked,
|
||||
IsFavorited: isFavorited,
|
||||
IsLiked: isLiked,
|
||||
IsFavorited: isFavorited,
|
||||
}
|
||||
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
responseData.Channel = dto.PostChannelBriefForPost(post, chMap)
|
||||
|
||||
response.Success(c, responseData)
|
||||
}
|
||||
|
||||
@@ -214,24 +294,20 @@ func (h *PostHandler) List(c *gin.Context) {
|
||||
// 根据 tab 参数选择不同的获取方式
|
||||
switch tab {
|
||||
case "follow":
|
||||
// 获取关注用户的帖子,需要登录
|
||||
if currentUserID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
posts, total, err = h.postService.GetFollowingPosts(c.Request.Context(), currentUserID, page, pageSize)
|
||||
posts, total, err = h.postService.GetFollowingPosts(c.Request.Context(), currentUserID, page, pageSize, channelID)
|
||||
case "hot":
|
||||
// 获取热门帖子
|
||||
posts, total, err = h.postService.GetHotPosts(c.Request.Context(), page, pageSize)
|
||||
posts, total, err = h.postService.GetHotPosts(c.Request.Context(), page, pageSize, channelID)
|
||||
case "latest":
|
||||
// 最新帖子
|
||||
if userID != "" && userID == currentUserID {
|
||||
posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID, channelID)
|
||||
} else {
|
||||
posts, total, err = h.postService.GetLatestPosts(c.Request.Context(), page, pageSize, userID, channelID)
|
||||
}
|
||||
default:
|
||||
// 默认获取最新帖子
|
||||
if userID != "" && userID == currentUserID {
|
||||
posts, total, err = h.postService.GetLatestPostsForOwner(c.Request.Context(), page, pageSize, userID, channelID)
|
||||
} else {
|
||||
@@ -244,6 +320,8 @@ func (h *PostHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
|
||||
|
||||
// 批量获取交互状态
|
||||
postIDs := make([]string, len(posts))
|
||||
for i, post := range posts {
|
||||
@@ -251,8 +329,8 @@ func (h *PostHandler) List(c *gin.Context) {
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(posts)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
@@ -279,15 +357,13 @@ func (h *PostHandler) ListByCursor(c *gin.Context) {
|
||||
// 根据 tab 参数选择不同的获取方式
|
||||
switch tab {
|
||||
case "follow":
|
||||
// 获取关注用户的帖子,需要登录
|
||||
if currentUserID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
result, err = h.postService.GetFollowingPostsByCursor(c.Request.Context(), currentUserID, req)
|
||||
result, err = h.postService.GetFollowingPostsByCursor(c.Request.Context(), currentUserID, channelID, req)
|
||||
case "hot":
|
||||
// 获取热门帖子
|
||||
result, err = h.postService.GetHotPostsByCursor(c.Request.Context(), req)
|
||||
result, err = h.postService.GetHotPostsByCursor(c.Request.Context(), channelID, req)
|
||||
case "latest":
|
||||
// 最新帖子
|
||||
result, err = h.postService.ListByCursor(c.Request.Context(), userID, includePending, channelID, req)
|
||||
@@ -301,6 +377,8 @@ func (h *PostHandler) ListByCursor(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result.Items = h.filterBlockedPosts(c.Request.Context(), result.Items, currentUserID)
|
||||
|
||||
// 批量获取交互状态
|
||||
postIDs := make([]string, len(result.Items))
|
||||
for i, post := range result.Items {
|
||||
@@ -308,8 +386,8 @@ func (h *PostHandler) ListByCursor(c *gin.Context) {
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(result.Items)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.PostCursorPageResponse{
|
||||
@@ -344,9 +422,10 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Images *[]string `json:"images"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Segments model.MessageSegments `json:"segments"`
|
||||
Images *[]string `json:"images"`
|
||||
}
|
||||
|
||||
var req UpdateRequest
|
||||
@@ -361,6 +440,12 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
if req.Content != "" {
|
||||
post.Content = req.Content
|
||||
}
|
||||
if len(req.Segments) > 0 {
|
||||
post.Segments = req.Segments
|
||||
if req.Content == "" {
|
||||
post.Content = dto.ExtractTextContent(req.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
err = h.postService.UpdateWithImages(c.Request.Context(), post, req.Images)
|
||||
if err != nil {
|
||||
@@ -368,6 +453,15 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.postRefService != nil && len(req.Segments) > 0 {
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if err := h.postRefService.ProcessPostReferences(bgCtx, post.ID, post.Segments); err != nil {
|
||||
log.Printf("[WARN] process post references failed for post %s: %v", post.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
post, err = h.postService.GetByID(c.Request.Context(), post.ID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get updated post")
|
||||
@@ -378,7 +472,8 @@ func (h *PostHandler) Update(c *gin.Context) {
|
||||
currentUserID := c.GetString("user_id")
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), post.ID, currentUserID)
|
||||
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// Delete 删除帖子
|
||||
@@ -402,12 +497,21 @@ func (h *PostHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err = h.postService.Delete(c.Request.Context(), id)
|
||||
err = h.postService.Delete(c.Request.Context(), userID, id)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to delete post")
|
||||
response.HandleError(c, err, "failed to delete post")
|
||||
return
|
||||
}
|
||||
|
||||
if h.postRefService != nil {
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
if err := h.postRefService.HandlePostDeleted(bgCtx, id); err != nil {
|
||||
log.Printf("[WARN] mark dead post references failed for post %s: %v", id, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "post deleted", nil)
|
||||
}
|
||||
|
||||
@@ -437,7 +541,8 @@ func (h *PostHandler) Like(c *gin.Context) {
|
||||
// 获取交互状态
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
||||
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// Unlike 取消点赞
|
||||
@@ -466,7 +571,8 @@ func (h *PostHandler) Unlike(c *gin.Context) {
|
||||
// 获取交互状态
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
||||
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// Favorite 收藏帖子
|
||||
@@ -495,7 +601,8 @@ func (h *PostHandler) Favorite(c *gin.Context) {
|
||||
// 获取交互状态
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
||||
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// Unfavorite 取消收藏
|
||||
@@ -524,11 +631,51 @@ func (h *PostHandler) Unfavorite(c *gin.Context) {
|
||||
// 获取交互状态
|
||||
isLiked, isFavorited := h.postService.GetPostInteractionStatusSingle(c.Request.Context(), id, userID)
|
||||
|
||||
response.Success(c, dto.BuildPostResponse(post, isLiked, isFavorited))
|
||||
chMap := h.channelMapForPosts([]*model.Post{post})
|
||||
response.Success(c, dto.BuildPostResponse(post, chMap, isLiked, isFavorited))
|
||||
}
|
||||
|
||||
// GetUserPosts 获取用户帖子列表(支持游标分页和偏移分页)
|
||||
func (h *PostHandler) GetUserPosts(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
canView, err := h.userService.CanViewPosts(c.Request.Context(), currentUserID, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to check privacy settings")
|
||||
return
|
||||
}
|
||||
if !canView {
|
||||
response.Forbidden(c, "该用户的帖子列表不对外公开")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否拉黑了对方
|
||||
if currentUserID != "" && currentUserID != userID {
|
||||
isBlocked, _ := h.userService.IsBlocked(c.Request.Context(), currentUserID, userID)
|
||||
if isBlocked {
|
||||
// 检查是否使用游标分页
|
||||
cursorStr := c.Query("cursor")
|
||||
if cursorStr != "" {
|
||||
response.Success(c, &dto.PostCursorPageResponse{
|
||||
Items: []*dto.PostResponse{},
|
||||
HasMore: false,
|
||||
IsBlocked: true,
|
||||
})
|
||||
} else {
|
||||
response.Success(c, gin.H{
|
||||
"list": []*dto.PostResponse{},
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 0,
|
||||
"is_blocked": true,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否使用游标分页
|
||||
cursorStr := c.Query("cursor")
|
||||
if cursorStr != "" {
|
||||
@@ -537,12 +684,10 @@ func (h *PostHandler) GetUserPosts(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 偏移分页(向后兼容)
|
||||
userID := c.Param("id")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
page, pageSize = normalizePagination(page, pageSize)
|
||||
|
||||
currentUserID := c.GetString("user_id")
|
||||
includePending := currentUserID != "" && currentUserID == userID
|
||||
posts, total, err := h.postService.GetUserPosts(c.Request.Context(), userID, page, pageSize, includePending)
|
||||
if err != nil {
|
||||
@@ -557,8 +702,8 @@ func (h *PostHandler) GetUserPosts(c *gin.Context) {
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(posts)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
@@ -588,8 +733,8 @@ func (h *PostHandler) GetUserPostsByCursor(c *gin.Context) {
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(result.Items)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.PostCursorPageResponse{
|
||||
@@ -605,6 +750,18 @@ func (h *PostHandler) GetUserPostsByCursor(c *gin.Context) {
|
||||
// GetFavorites 获取收藏列表
|
||||
func (h *PostHandler) GetFavorites(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
canView, err := h.userService.CanViewFavorites(c.Request.Context(), currentUserID, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to check privacy settings")
|
||||
return
|
||||
}
|
||||
if !canView {
|
||||
response.Forbidden(c, "该用户的收藏列表不对外公开")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
page, pageSize = normalizePagination(page, pageSize)
|
||||
@@ -615,16 +772,14 @@ func (h *PostHandler) GetFavorites(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 批量获取交互状态
|
||||
currentUserID := c.GetString("user_id")
|
||||
postIDs := make([]string, len(posts))
|
||||
for i, post := range posts {
|
||||
postIDs[i] = post.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(posts)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
@@ -653,14 +808,17 @@ func (h *PostHandler) Search(c *gin.Context) {
|
||||
|
||||
// 批量获取交互状态
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
posts = h.filterBlockedPosts(c.Request.Context(), posts, currentUserID)
|
||||
|
||||
postIDs := make([]string, len(posts))
|
||||
for i, post := range posts {
|
||||
postIDs[i] = post.ID
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(posts)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(posts, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
response.Paginated(c, postResponses, total, page, pageSize)
|
||||
}
|
||||
@@ -680,6 +838,8 @@ func (h *PostHandler) SearchByCursor(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result.Items = h.filterBlockedPosts(c.Request.Context(), result.Items, currentUserID)
|
||||
|
||||
// 批量获取交互状态
|
||||
postIDs := make([]string, len(result.Items))
|
||||
for i, post := range result.Items {
|
||||
@@ -687,8 +847,8 @@ func (h *PostHandler) SearchByCursor(c *gin.Context) {
|
||||
}
|
||||
isLikedMap, isFavoritedMap, _ := h.postService.GetPostInteractionStatus(c.Request.Context(), postIDs, currentUserID)
|
||||
|
||||
// 转换为响应结构
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, isLikedMap, isFavoritedMap)
|
||||
chMap := h.channelMapForPosts(result.Items)
|
||||
postResponses := dto.BuildPostsWithInteractionResponse(result.Items, chMap, isLikedMap, isFavoritedMap)
|
||||
|
||||
// 构建游标分页响应
|
||||
cursorResp := &dto.PostCursorPageResponse{
|
||||
@@ -701,6 +861,114 @@ func (h *PostHandler) SearchByCursor(c *gin.Context) {
|
||||
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")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -67,7 +67,7 @@ func (h *PushHandler) UnregisterDevice(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.pushService.UnregisterDevice(c.Request.Context(), deviceID)
|
||||
err := h.pushService.UnregisterDevice(c.Request.Context(), userID, deviceID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to unregister device")
|
||||
return
|
||||
@@ -85,12 +85,13 @@ func (h *PushHandler) GetMyDevices(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 这里需要从DeviceTokenRepository获取设备列表
|
||||
// 由于PushService接口没有提供获取设备列表的方法,我们暂时返回空列表
|
||||
// TODO: 在PushService接口中添加GetUserDevices方法
|
||||
_ = userID // 避免未使用变量警告
|
||||
devices, err := h.pushService.GetUserDevices(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get devices")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, []*dto.DeviceTokenResponse{})
|
||||
response.Success(c, dto.DeviceTokensToResponse(devices))
|
||||
}
|
||||
|
||||
// GetPushRecords 获取推送记录
|
||||
@@ -140,14 +141,14 @@ func (h *PushHandler) UpdateDeviceToken(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PushToken string `json:"push_token" binding:"required"`
|
||||
PushToken string `json:"push_token"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err := h.pushService.UpdateDeviceToken(c.Request.Context(), deviceID, req.PushToken)
|
||||
err := h.pushService.UpdateDeviceToken(c.Request.Context(), userID, deviceID, req.PushToken)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to update device token")
|
||||
return
|
||||
|
||||
@@ -7,18 +7,18 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/pkg/ws"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// QRCodeHandler 二维码登录处理器
|
||||
type QRCodeHandler struct {
|
||||
qrcodeService *service.QRCodeLoginService
|
||||
qrcodeService service.QRCodeLoginService
|
||||
}
|
||||
|
||||
// NewQRCodeHandler 创建二维码登录处理器
|
||||
func NewQRCodeHandler(qrcodeService *service.QRCodeLoginService) *QRCodeHandler {
|
||||
func NewQRCodeHandler(qrcodeService service.QRCodeLoginService) *QRCodeHandler {
|
||||
return &QRCodeHandler{
|
||||
qrcodeService: qrcodeService,
|
||||
}
|
||||
@@ -44,16 +44,16 @@ func (h *QRCodeHandler) GetQRCode(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// SSEEvents SSE事件流
|
||||
// WSEvents WebSocket事件流
|
||||
// GET /api/v1/auth/qrcode/events?session_id=xxx
|
||||
func (h *QRCodeHandler) SSEEvents(c *gin.Context) {
|
||||
func (h *QRCodeHandler) WSEvents(c *gin.Context) {
|
||||
sessionID := c.Query("session_id")
|
||||
if sessionID == "" {
|
||||
response.BadRequest(c, "session_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
ch, cancel, replay := h.qrcodeService.GetSSEHub().Subscribe(sessionID, 0)
|
||||
ch, cancel := h.qrcodeService.GetWSHub().Subscribe(sessionID, 0)
|
||||
defer cancel()
|
||||
|
||||
w := c.Writer
|
||||
@@ -70,8 +70,8 @@ func (h *QRCodeHandler) SSEEvents(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
writeEvent := func(ev sse.Event) bool {
|
||||
data, err := sse.EncodeData(ev)
|
||||
writeEvent := func(ev ws.Event) bool {
|
||||
data, err := ws.EncodeData(ev)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -82,13 +82,6 @@ func (h *QRCodeHandler) SSEEvents(c *gin.Context) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 发送历史事件
|
||||
for _, ev := range replay {
|
||||
if !writeEvent(ev) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 心跳
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
61
internal/handler/report_handler.go
Normal file
61
internal/handler/report_handler.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ReportHandler 举报处理器
|
||||
type ReportHandler struct {
|
||||
reportService service.ReportService
|
||||
}
|
||||
|
||||
// NewReportHandler 创建举报处理器
|
||||
func NewReportHandler(reportService service.ReportService) *ReportHandler {
|
||||
return &ReportHandler{
|
||||
reportService: reportService,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建举报
|
||||
func (h *ReportHandler) Create(c *gin.Context) {
|
||||
// 获取用户ID
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析请求
|
||||
var req dto.CreateReportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 创建举报
|
||||
report, err := h.reportService.CreateReport(
|
||||
c.Request.Context(),
|
||||
userID,
|
||||
req.TargetType,
|
||||
req.TargetID,
|
||||
req.Reason,
|
||||
req.Description,
|
||||
)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to create report",
|
||||
zap.Error(err),
|
||||
zap.String("user_id", userID),
|
||||
zap.String("target_type", req.TargetType),
|
||||
zap.String("target_id", req.TargetID),
|
||||
)
|
||||
response.Error(c, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.ConvertReportToResponse(report))
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
apperrors "carrot_bbs/internal/errors"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/service"
|
||||
apperrors "with_you/internal/errors"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
type ScheduleHandler struct {
|
||||
|
||||
38
internal/handler/setup_handler.go
Normal file
38
internal/handler/setup_handler.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SetupHandler struct {
|
||||
setupService service.SetupService
|
||||
}
|
||||
|
||||
func NewSetupHandler(setupService service.SetupService) *SetupHandler {
|
||||
return &SetupHandler{
|
||||
setupService: setupService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SetupHandler) SetupSuperAdmin(c *gin.Context) {
|
||||
type SetupSuperAdminRequest struct {
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
SetupSecret string `json:"setup_secret" binding:"required"`
|
||||
}
|
||||
|
||||
var req SetupSuperAdminRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "user_id and setup_secret are required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.setupService.SetupSuperAdmin(c.Request.Context(), req.UserID, req.SetupSecret); err != nil {
|
||||
response.HandleError(c, err, "failed to setup super admin")
|
||||
return
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "超级管理员设置成功", nil)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// StickerHandler 自定义表情处理器
|
||||
@@ -81,11 +82,11 @@ func (h *StickerHandler) AddSticker(c *gin.Context) {
|
||||
|
||||
sticker, err := h.stickerService.AddSticker(userID, req.URL, req.Width, req.Height)
|
||||
if err != nil {
|
||||
if err == service.ErrStickerAlreadyExists {
|
||||
if errors.Is(err, service.ErrStickerAlreadyExists) {
|
||||
response.Error(c, http.StatusConflict, "sticker already exists")
|
||||
return
|
||||
}
|
||||
if err == service.ErrInvalidStickerURL {
|
||||
if errors.Is(err, service.ErrInvalidStickerURL) {
|
||||
response.BadRequest(c, "invalid sticker url, only http/https is allowed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,33 +3,24 @@ package handler
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/repository"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// SystemMessageHandler 系统消息处理器
|
||||
type SystemMessageHandler struct {
|
||||
systemMsgService service.SystemMessageService
|
||||
notifyRepo *repository.SystemNotificationRepository
|
||||
cache cache.Cache
|
||||
}
|
||||
|
||||
// NewSystemMessageHandler 创建系统消息处理器
|
||||
func NewSystemMessageHandler(
|
||||
systemMsgService service.SystemMessageService,
|
||||
notifyRepo *repository.SystemNotificationRepository,
|
||||
cacheBackend cache.Cache,
|
||||
) *SystemMessageHandler {
|
||||
return &SystemMessageHandler{
|
||||
systemMsgService: systemMsgService,
|
||||
notifyRepo: notifyRepo,
|
||||
cache: cacheBackend,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +44,8 @@ func (h *SystemMessageHandler) GetSystemMessages(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
// 获取当前用户的系统通知(从独立表中获取)
|
||||
notifications, total, err := h.notifyRepo.GetByReceiverID(userID, page, pageSize)
|
||||
// 获取当前用户的系统通知
|
||||
notifications, total, err := h.systemMsgService.GetNotifications(userID, page, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get system messages")
|
||||
return
|
||||
@@ -83,7 +74,7 @@ func (h *SystemMessageHandler) GetSystemMessagesByCursor(c *gin.Context) {
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
// 获取当前用户的系统通知(使用兼容旧版接口)
|
||||
notifications, nextCursor, hasMore, err := h.notifyRepo.GetByReceiverIDCursorLegacy(userID, cursorStr, pageSize)
|
||||
notifications, nextCursor, hasMore, err := h.systemMsgService.GetNotificationsByCursor(userID, cursorStr, pageSize)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get system messages")
|
||||
return
|
||||
@@ -117,7 +108,7 @@ func (h *SystemMessageHandler) GetUnreadCount(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 获取当前用户的未读通知数
|
||||
unreadCount, err := h.notifyRepo.GetUnreadCount(userID)
|
||||
unreadCount, err := h.systemMsgService.GetUnreadCount(userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get unread count")
|
||||
return
|
||||
@@ -145,12 +136,11 @@ func (h *SystemMessageHandler) MarkAsRead(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 标记为已读
|
||||
err = h.notifyRepo.MarkAsRead(notificationID, userID)
|
||||
err = h.systemMsgService.MarkAsRead(notificationID, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to mark as read")
|
||||
return
|
||||
}
|
||||
cache.InvalidateUnreadSystem(h.cache, userID)
|
||||
|
||||
response.SuccessWithMessage(c, "marked as read", nil)
|
||||
}
|
||||
@@ -165,12 +155,11 @@ func (h *SystemMessageHandler) MarkAllAsRead(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 标记当前用户所有通知为已读
|
||||
err := h.notifyRepo.MarkAllAsRead(userID)
|
||||
err := h.systemMsgService.MarkAllAsRead(userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to mark all as read")
|
||||
return
|
||||
}
|
||||
cache.InvalidateUnreadSystem(h.cache, userID)
|
||||
|
||||
response.SuccessWithMessage(c, "all messages marked as read", nil)
|
||||
}
|
||||
@@ -192,12 +181,11 @@ func (h *SystemMessageHandler) DeleteSystemMessage(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 删除通知
|
||||
err = h.notifyRepo.Delete(notificationID, userID)
|
||||
err = h.systemMsgService.DeleteNotification(notificationID, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to delete notification")
|
||||
return
|
||||
}
|
||||
cache.InvalidateUnreadSystem(h.cache, userID)
|
||||
|
||||
response.SuccessWithMessage(c, "notification deleted", nil)
|
||||
}
|
||||
|
||||
220
internal/handler/trade_handler.go
Normal file
220
internal/handler/trade_handler.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
type TradeHandler struct {
|
||||
tradeService service.TradeService
|
||||
}
|
||||
|
||||
func NewTradeHandler(tradeService service.TradeService) *TradeHandler {
|
||||
return &TradeHandler{tradeService: tradeService}
|
||||
}
|
||||
|
||||
func (h *TradeHandler) List(c *gin.Context) {
|
||||
currentUserID := c.GetString("user_id")
|
||||
if currentUserID == "" {
|
||||
currentUserID = ""
|
||||
}
|
||||
|
||||
var tradeType *model.TradeType
|
||||
if v := c.Query("trade_type"); v != "" {
|
||||
t := model.TradeType(v)
|
||||
tradeType = &t
|
||||
}
|
||||
|
||||
var category *model.TradeCategory
|
||||
if v := c.Query("category"); v != "" {
|
||||
cat := model.TradeCategory(v)
|
||||
category = &cat
|
||||
}
|
||||
|
||||
cursorStr := c.Query("cursor")
|
||||
pageSize := 20
|
||||
if v := c.Query("page_size"); v != "" {
|
||||
if ps, err := strconv.Atoi(v); err == nil && ps > 0 && ps <= 100 {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
var currentUserIDPtr *string
|
||||
if currentUserID != "" {
|
||||
currentUserIDPtr = ¤tUserID
|
||||
}
|
||||
|
||||
var resp *dto.TradeCursorPageResponse
|
||||
var err error
|
||||
if keyword != "" {
|
||||
resp, err = h.tradeService.Search(c.Request.Context(), keyword, tradeType, category, cursorStr, pageSize, currentUserIDPtr)
|
||||
} else {
|
||||
resp, err = h.tradeService.List(c.Request.Context(), tradeType, category, cursorStr, pageSize, currentUserIDPtr)
|
||||
}
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "获取交易列表失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, resp)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) GetByID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
var currentUserIDPtr *string
|
||||
if currentUserID != "" {
|
||||
currentUserIDPtr = ¤tUserID
|
||||
}
|
||||
|
||||
resp, err := h.tradeService.GetByID(c.Request.Context(), id, currentUserIDPtr)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "获取交易详情失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, resp)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) Create(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.CreateTradeItemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.tradeService.Create(c.Request.Context(), userID, req)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "创建交易失败")
|
||||
return
|
||||
}
|
||||
|
||||
var currentUserIDPtr *string
|
||||
if userID != "" {
|
||||
currentUserIDPtr = &userID
|
||||
}
|
||||
detail, err := h.tradeService.GetByID(c.Request.Context(), item.ID, currentUserIDPtr)
|
||||
if err != nil {
|
||||
response.Success(c, item)
|
||||
return
|
||||
}
|
||||
response.Success(c, detail)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) Update(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
var req dto.UpdateTradeItemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tradeService.Update(c.Request.Context(), userID, id, req); err != nil {
|
||||
response.HandleError(c, err, "更新交易失败")
|
||||
return
|
||||
}
|
||||
|
||||
var currentUserIDPtr *string
|
||||
if userID != "" {
|
||||
currentUserIDPtr = &userID
|
||||
}
|
||||
detail, err := h.tradeService.GetByID(c.Request.Context(), id, currentUserIDPtr)
|
||||
if err != nil {
|
||||
response.Success(c, nil)
|
||||
return
|
||||
}
|
||||
response.Success(c, detail)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) Delete(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if err := h.tradeService.Delete(c.Request.Context(), userID, id); err != nil {
|
||||
response.HandleError(c, err, "删除交易失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) UpdateStatus(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
var req dto.UpdateTradeItemStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tradeService.UpdateStatus(c.Request.Context(), userID, id, model.TradeItemStatus(req.Status)); err != nil {
|
||||
response.HandleError(c, err, "更新交易状态失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) RecordView(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
_ = h.tradeService.RecordView(c.Request.Context(), id)
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *TradeHandler) Favorite(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if err := h.tradeService.Favorite(c.Request.Context(), userID, id); err != nil {
|
||||
response.HandleError(c, err, "收藏失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.TradeFavoriteResponse{Favorited: true})
|
||||
}
|
||||
|
||||
func (h *TradeHandler) Unfavorite(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if err := h.tradeService.Unfavorite(c.Request.Context(), userID, id); err != nil {
|
||||
response.HandleError(c, err, "取消收藏失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.TradeFavoriteResponse{Favorited: false})
|
||||
}
|
||||
@@ -3,17 +3,17 @@ package handler
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// UploadHandler 上传处理器
|
||||
type UploadHandler struct {
|
||||
uploadService *service.UploadService
|
||||
uploadService service.UploadService
|
||||
}
|
||||
|
||||
// NewUploadHandler 创建上传处理器
|
||||
func NewUploadHandler(uploadService *service.UploadService) *UploadHandler {
|
||||
func NewUploadHandler(uploadService service.UploadService) *UploadHandler {
|
||||
return &UploadHandler{
|
||||
uploadService: uploadService,
|
||||
}
|
||||
@@ -92,3 +92,43 @@ func (h *UploadHandler) UploadCover(c *gin.Context) {
|
||||
|
||||
response.Success(c, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// UploadFile 上传聊天/通用文件(非图片类资源)
|
||||
// form 字段:file(必填)、folder(可选,默认 chat)
|
||||
func (h *UploadHandler) UploadFile(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "file is required")
|
||||
return
|
||||
}
|
||||
|
||||
folder := c.DefaultPostForm("folder", "chat")
|
||||
// 前端(尤其原生端 expo-file-system)无法自定义 multipart filename,
|
||||
// 文件常被复制到缓存目录并以 UUID 命名,导致 header Filename 丢失真实名。
|
||||
// 因此前端额外把真实文件名通过 "name" 字段回传,作为权威展示名。
|
||||
displayName := c.PostForm("name")
|
||||
|
||||
result, err := h.uploadService.UploadFile(c.Request.Context(), file, folder, userID, displayName)
|
||||
if err != nil {
|
||||
// 参数类错误(大小/类型)返回 400,其余返回 500
|
||||
if file.Size > 0 && file.Size <= 100<<20 {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.InternalServerError(c, "failed to upload file")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"url": result.URL,
|
||||
"name": result.Name,
|
||||
"size": result.Size,
|
||||
"mime_type": result.MimeType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,13 +6,19 @@ import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"carrot_bbs/internal/dto"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/response"
|
||||
"carrot_bbs/internal/service"
|
||||
"with_you/internal/cache"
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/middleware"
|
||||
"with_you/internal/model"
|
||||
apperrors "with_you/internal/errors"
|
||||
"with_you/internal/pkg/auth"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,22 +38,29 @@ func normalizePagination(page, pageSize int) (int, int) {
|
||||
|
||||
// UserHandler 用户处理器
|
||||
type UserHandler struct {
|
||||
userService service.UserService
|
||||
activityService service.UserActivityService // 用户活跃统计服务
|
||||
jwtService *service.JWTService
|
||||
logService *service.LogService
|
||||
userService service.UserService
|
||||
activityService service.UserActivityService
|
||||
profileAuditService service.UserProfileAuditService
|
||||
jwtService service.JWTService
|
||||
sessionService service.SessionService
|
||||
cache cache.Cache
|
||||
logService *service.LogService
|
||||
}
|
||||
|
||||
// NewUserHandler 创建用户处理器
|
||||
func NewUserHandler(userService service.UserService, activityService service.UserActivityService) *UserHandler {
|
||||
func NewUserHandler(userService service.UserService, activityService service.UserActivityService, logService *service.LogService) *UserHandler {
|
||||
return &UserHandler{
|
||||
userService: userService,
|
||||
activityService: activityService,
|
||||
logService: logService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *UserHandler) SetProfileAuditService(profileAuditService service.UserProfileAuditService) {
|
||||
h.profileAuditService = profileAuditService
|
||||
}
|
||||
|
||||
// SetJWTService 设置JWT服务
|
||||
func (h *UserHandler) SetJWTService(jwtService *service.JWTService) {
|
||||
func (h *UserHandler) SetJWTService(jwtService service.JWTService) {
|
||||
h.jwtService = jwtService
|
||||
}
|
||||
|
||||
@@ -56,9 +69,14 @@ func (h *UserHandler) SetActivityService(activityService service.UserActivitySer
|
||||
h.activityService = activityService
|
||||
}
|
||||
|
||||
// SetLogService 设置日志服务
|
||||
func (h *UserHandler) SetLogService(logService *service.LogService) {
|
||||
h.logService = logService
|
||||
// SetSessionService 设置会话服务(用于登出与令牌轮换)。
|
||||
func (h *UserHandler) SetSessionService(sessionService service.SessionService) {
|
||||
h.sessionService = sessionService
|
||||
}
|
||||
|
||||
// SetCache 注入缓存实例(用于登出时失效 Principal/Session 缓存)。
|
||||
func (h *UserHandler) SetCache(c cache.Cache) {
|
||||
h.cache = c
|
||||
}
|
||||
|
||||
// generateTokenID 生成Token ID
|
||||
@@ -91,12 +109,16 @@ func (h *UserHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成Token
|
||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
||||
|
||||
// 签发带会话的令牌对:sid 写入 JWT,支持登出/封禁后撤销。
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
refreshExpire := time.Duration(7*24) * time.Hour
|
||||
_, accessToken, refreshToken, err := h.sessionService.Issue(c.Request.Context(), user.ID, user.Username, userAgent, ip, refreshExpire)
|
||||
if err != nil {
|
||||
// 会话签发失败视为登录不可用,避免签发无 sid 的旧式令牌。
|
||||
response.InternalServerError(c, "failed to issue session")
|
||||
return
|
||||
}
|
||||
|
||||
// 记录用户活跃
|
||||
if h.activityService != nil {
|
||||
@@ -153,16 +175,22 @@ func (h *UserHandler) Login(c *gin.Context) {
|
||||
|
||||
user, err := h.userService.Login(c.Request.Context(), account, req.Password)
|
||||
if err != nil {
|
||||
middleware.RecordLoginFailure(c.ClientIP())
|
||||
response.HandleError(c, err, "failed to login")
|
||||
return
|
||||
}
|
||||
|
||||
// 生成Token
|
||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
||||
middleware.ResetLoginFailures(c.ClientIP())
|
||||
|
||||
// 签发带会话的令牌对。
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
refreshExpire := time.Duration(7*24) * time.Hour
|
||||
_, accessToken, refreshToken, err := h.sessionService.Issue(c.Request.Context(), user.ID, user.Username, userAgent, ip, refreshExpire)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to issue session")
|
||||
return
|
||||
}
|
||||
|
||||
// 记录用户活跃
|
||||
if h.activityService != nil {
|
||||
@@ -175,8 +203,6 @@ func (h *UserHandler) Login(c *gin.Context) {
|
||||
|
||||
// 更新登录日志(补充IP和UserAgent)
|
||||
if h.logService != nil {
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
go func() {
|
||||
h.logService.LoginLog.RecordLogin(&model.LoginLog{
|
||||
UserID: user.ID,
|
||||
@@ -303,7 +329,7 @@ func (h *UserHandler) GetUserByID(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 转换为响应格式,包含双向关注状态和实时计算的帖子数量
|
||||
userResponse := dto.ConvertUserToResponseWithMutualFollowAndPostsCount(user, isFollowing, isFollowingMe, int(postsCount))
|
||||
userResponse := dto.ConvertUserToResponse(user, dto.WithFollowing(isFollowing, isFollowingMe), dto.WithPostsCount(int(postsCount)))
|
||||
|
||||
response.Success(c, userResponse)
|
||||
}
|
||||
@@ -322,6 +348,7 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
Website string `json:"website"`
|
||||
Location string `json:"location"`
|
||||
Avatar string `json:"avatar"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Phone *string `json:"phone"`
|
||||
Email *string `json:"email"`
|
||||
}
|
||||
@@ -341,18 +368,12 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
if req.Nickname != "" {
|
||||
user.Nickname = req.Nickname
|
||||
}
|
||||
if req.Bio != "" {
|
||||
user.Bio = req.Bio
|
||||
}
|
||||
if req.Website != "" {
|
||||
user.Website = req.Website
|
||||
}
|
||||
if req.Location != "" {
|
||||
user.Location = req.Location
|
||||
}
|
||||
if req.Avatar != "" {
|
||||
user.Avatar = req.Avatar
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = req.Phone
|
||||
}
|
||||
@@ -363,20 +384,53 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
user.Email = req.Email
|
||||
}
|
||||
|
||||
avatarNeedsAudit := req.Avatar != "" && req.Avatar != user.Avatar && h.profileAuditService != nil
|
||||
coverNeedsAudit := req.CoverURL != "" && req.CoverURL != user.CoverURL && h.profileAuditService != nil
|
||||
bioNeedsAudit := req.Bio != "" && req.Bio != user.Bio && h.profileAuditService != nil
|
||||
|
||||
if avatarNeedsAudit {
|
||||
if _, err := h.profileAuditService.SubmitAvatarAudit(c.Request.Context(), userID, req.Avatar); err != nil {
|
||||
avatarNeedsAudit = false
|
||||
user.Avatar = req.Avatar
|
||||
}
|
||||
} else if req.Avatar != "" {
|
||||
user.Avatar = req.Avatar
|
||||
}
|
||||
|
||||
if coverNeedsAudit {
|
||||
if _, err := h.profileAuditService.SubmitCoverAudit(c.Request.Context(), userID, req.CoverURL); err != nil {
|
||||
coverNeedsAudit = false
|
||||
user.CoverURL = req.CoverURL
|
||||
}
|
||||
} else if req.CoverURL != "" {
|
||||
user.CoverURL = req.CoverURL
|
||||
}
|
||||
|
||||
if bioNeedsAudit {
|
||||
if _, err := h.profileAuditService.SubmitBioAudit(c.Request.Context(), userID, req.Bio); err != nil {
|
||||
bioNeedsAudit = false
|
||||
user.Bio = req.Bio
|
||||
}
|
||||
} else if req.Bio != "" {
|
||||
user.Bio = req.Bio
|
||||
}
|
||||
|
||||
err = h.userService.UpdateUser(c.Request.Context(), user)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to update user")
|
||||
return
|
||||
}
|
||||
|
||||
// 实时计算帖子数量
|
||||
postsCount, err := h.userService.GetUserPostCount(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
// 如果获取失败,使用数据库中的值
|
||||
postsCount = int64(user.PostsCount)
|
||||
}
|
||||
|
||||
response.Success(c, dto.ConvertUserToDetailResponseWithPostsCount(user, int(postsCount)))
|
||||
resp := gin.H{"user": dto.ConvertUserToDetailResponseWithPostsCount(user, int(postsCount))}
|
||||
if avatarNeedsAudit || coverNeedsAudit || bioNeedsAudit {
|
||||
resp["audit_pending"] = true
|
||||
}
|
||||
response.Success(c, resp)
|
||||
}
|
||||
|
||||
// SendEmailVerifyCode 发送当前用户邮箱验证码
|
||||
@@ -431,6 +485,11 @@ func (h *UserHandler) VerifyEmail(c *gin.Context) {
|
||||
}
|
||||
|
||||
// RefreshToken 刷新Token
|
||||
//
|
||||
// 安全语义:
|
||||
// - 仅接受 refresh token(jwt.ParseRefreshToken 严格校验 typ=refresh)。
|
||||
// - 通过 SessionService 校验会话未撤销未过期,并轮换会话(旧 refresh token 失效)。
|
||||
// - 不接受 access token,access token 不能在刷新端点换取新令牌。
|
||||
func (h *UserHandler) RefreshToken(c *gin.Context) {
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
@@ -442,26 +501,34 @@ func (h *UserHandler) RefreshToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 refresh token
|
||||
claims, err := h.jwtService.ParseToken(req.RefreshToken)
|
||||
// 获取用户信息用于填充新令牌的 username 声明。
|
||||
// 先解析 refresh 拿 userID,再查 DB;不直接信任 JWT 中的 username。
|
||||
claims, err := h.jwtService.ParseRefreshToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "invalid refresh token")
|
||||
response.HandleError(c, apperrors.ErrInvalidToken, "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "user not found")
|
||||
response.HandleError(c, apperrors.ErrUserNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 生成新 token
|
||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
||||
if user.Status != model.UserStatusActive && user.Status != model.UserStatusPendingDeletion {
|
||||
response.HandleError(c, apperrors.ErrUserBanned, "user banned")
|
||||
return
|
||||
}
|
||||
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
refreshExpire := time.Duration(7*24) * time.Hour
|
||||
|
||||
_, accessToken, refreshToken, err := h.sessionService.Rotate(c.Request.Context(), req.RefreshToken, user.Username, userAgent, ip, refreshExpire)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "failed to refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
// 记录用户活跃
|
||||
if h.activityService != nil {
|
||||
@@ -619,32 +686,40 @@ func (h *UserHandler) GetBlockStatus(c *gin.Context) {
|
||||
func (h *UserHandler) GetFollowingList(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
canView, err := h.userService.CanViewFollowing(c.Request.Context(), currentUserID, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to check privacy settings")
|
||||
return
|
||||
}
|
||||
if !canView {
|
||||
response.Forbidden(c, "该用户的关注列表不对外公开")
|
||||
return
|
||||
}
|
||||
|
||||
page := c.DefaultQuery("page", "1")
|
||||
pageSize := c.DefaultQuery("page_size", "20")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
users, err := h.userService.GetFollowingList(c.Request.Context(), userID, page, pageSize)
|
||||
users, err := h.userService.GetFollowingList(c.Request.Context(), userID, page, pageSize, keyword)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get following list")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已登录,获取双向关注状态和实时计算的帖子数量
|
||||
var userResponses []*dto.UserResponse
|
||||
if currentUserID != "" && len(users) > 0 {
|
||||
userIDs := make([]string, len(users))
|
||||
for i, u := range users {
|
||||
userIDs[i] = u.ID
|
||||
}
|
||||
statusMap, _ := h.userService.GetMutualFollowStatus(c.Request.Context(), currentUserID, userIDs)
|
||||
postsCountMap, _ := h.userService.GetUserPostCountBatch(c.Request.Context(), userIDs)
|
||||
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, statusMap, postsCountMap)
|
||||
} else if len(users) > 0 {
|
||||
if len(users) > 0 {
|
||||
userIDs := make([]string, len(users))
|
||||
for i, u := range users {
|
||||
userIDs[i] = u.ID
|
||||
}
|
||||
postsCountMap, _ := h.userService.GetUserPostCountBatch(c.Request.Context(), userIDs)
|
||||
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, nil, postsCountMap)
|
||||
if currentUserID != "" && userID == currentUserID {
|
||||
statusMap, _ := h.userService.GetMutualFollowStatus(c.Request.Context(), currentUserID, userIDs)
|
||||
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, statusMap, postsCountMap)
|
||||
} else {
|
||||
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, nil, postsCountMap)
|
||||
}
|
||||
} else {
|
||||
userResponses = dto.ConvertUsersToResponse(users)
|
||||
}
|
||||
@@ -658,32 +733,40 @@ func (h *UserHandler) GetFollowingList(c *gin.Context) {
|
||||
func (h *UserHandler) GetFollowersList(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
currentUserID := c.GetString("user_id")
|
||||
|
||||
canView, err := h.userService.CanViewFollowers(c.Request.Context(), currentUserID, userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to check privacy settings")
|
||||
return
|
||||
}
|
||||
if !canView {
|
||||
response.Forbidden(c, "该用户的粉丝列表不对外公开")
|
||||
return
|
||||
}
|
||||
|
||||
page := c.DefaultQuery("page", "1")
|
||||
pageSize := c.DefaultQuery("page_size", "20")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
users, err := h.userService.GetFollowersList(c.Request.Context(), userID, page, pageSize)
|
||||
users, err := h.userService.GetFollowersList(c.Request.Context(), userID, page, pageSize, keyword)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get followers list")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已登录,获取双向关注状态和实时计算的帖子数量
|
||||
var userResponses []*dto.UserResponse
|
||||
if currentUserID != "" && len(users) > 0 {
|
||||
userIDs := make([]string, len(users))
|
||||
for i, u := range users {
|
||||
userIDs[i] = u.ID
|
||||
}
|
||||
statusMap, _ := h.userService.GetMutualFollowStatus(c.Request.Context(), currentUserID, userIDs)
|
||||
postsCountMap, _ := h.userService.GetUserPostCountBatch(c.Request.Context(), userIDs)
|
||||
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, statusMap, postsCountMap)
|
||||
} else if len(users) > 0 {
|
||||
if len(users) > 0 {
|
||||
userIDs := make([]string, len(users))
|
||||
for i, u := range users {
|
||||
userIDs[i] = u.ID
|
||||
}
|
||||
postsCountMap, _ := h.userService.GetUserPostCountBatch(c.Request.Context(), userIDs)
|
||||
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, nil, postsCountMap)
|
||||
if currentUserID != "" && userID == currentUserID {
|
||||
statusMap, _ := h.userService.GetMutualFollowStatus(c.Request.Context(), currentUserID, userIDs)
|
||||
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, statusMap, postsCountMap)
|
||||
} else {
|
||||
userResponses = dto.ConvertUsersToResponseWithMutualFollowAndPostsCount(users, nil, postsCountMap)
|
||||
}
|
||||
} else {
|
||||
userResponses = dto.ConvertUsersToResponse(users)
|
||||
}
|
||||
@@ -753,9 +836,14 @@ func (h *UserHandler) SendChangePasswordCode(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Logout 用户登出
|
||||
//
|
||||
// 安全语义:撤销当前会话,使 refresh token 立即失效。
|
||||
// access token 在剩余 TTL 内仍可用(无状态 JWT 的固有限制),但 refresh token 已撤销,
|
||||
// 攻击者无法用 refresh token 续期,access token 也会在短 TTL 内自然过期。
|
||||
// 如需 access token 即时失效,可后续引入 jti 黑名单。
|
||||
func (h *UserHandler) Logout(c *gin.Context) {
|
||||
currentUserID := c.GetString("user_id")
|
||||
if currentUserID == "" {
|
||||
principal, ok := auth.GetPrincipal(c)
|
||||
if !ok || principal == nil {
|
||||
response.Unauthorized(c, "not logged in")
|
||||
return
|
||||
}
|
||||
@@ -763,8 +851,23 @@ func (h *UserHandler) Logout(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
// 撤销当前会话。
|
||||
if h.sessionService != nil && principal.SessionID != "" {
|
||||
if err := h.sessionService.Revoke(c.Request.Context(), principal.SessionID); err != nil {
|
||||
// 撤销失败不阻塞登出流程,但记日志便于排查。
|
||||
zap.L().Warn("failed to revoke session on logout",
|
||||
zap.String("session_id", principal.SessionID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 失效 Principal/Session 缓存,避免登出后短时间内旧 token 仍命中缓存。
|
||||
auth.InvalidatePrincipalCache(h.cache, principal.UserID)
|
||||
auth.InvalidateSessionCache(h.cache, principal.SessionID)
|
||||
|
||||
// 获取用户信息
|
||||
user, err := h.userService.GetUserByID(c.Request.Context(), currentUserID)
|
||||
user, err := h.userService.GetUserByID(c.Request.Context(), principal.UserID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "user not found")
|
||||
return
|
||||
@@ -814,3 +917,151 @@ func (h *UserHandler) Search(c *gin.Context) {
|
||||
|
||||
response.Paginated(c, userResponses, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetPrivacySettings 获取隐私设置
|
||||
func (h *UserHandler) GetPrivacySettings(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.userService.GetPrivacySettings(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get privacy settings")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, &dto.PrivacySettingsResponse{
|
||||
FollowersVisibility: string(settings.FollowersVisibility),
|
||||
FollowingVisibility: string(settings.FollowingVisibility),
|
||||
PostsVisibility: string(settings.PostsVisibility),
|
||||
FavoritesVisibility: string(settings.FavoritesVisibility),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePrivacySettings 更新隐私设置
|
||||
func (h *UserHandler) UpdatePrivacySettings(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.PrivacySettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
settings := &model.PrivacySettings{}
|
||||
if req.FollowersVisibility != nil {
|
||||
settings.FollowersVisibility = model.VisibilityLevel(*req.FollowersVisibility)
|
||||
} else {
|
||||
settings.FollowersVisibility = model.VisibilityEveryone
|
||||
}
|
||||
if req.FollowingVisibility != nil {
|
||||
settings.FollowingVisibility = model.VisibilityLevel(*req.FollowingVisibility)
|
||||
} else {
|
||||
settings.FollowingVisibility = model.VisibilityEveryone
|
||||
}
|
||||
if req.PostsVisibility != nil {
|
||||
settings.PostsVisibility = model.VisibilityLevel(*req.PostsVisibility)
|
||||
} else {
|
||||
settings.PostsVisibility = model.VisibilityEveryone
|
||||
}
|
||||
if req.FavoritesVisibility != nil {
|
||||
settings.FavoritesVisibility = model.VisibilityLevel(*req.FavoritesVisibility)
|
||||
} else {
|
||||
settings.FavoritesVisibility = model.VisibilityEveryone
|
||||
}
|
||||
|
||||
if err := h.userService.UpdatePrivacySettings(c.Request.Context(), userID, settings); err != nil {
|
||||
response.InternalServerError(c, "failed to update privacy settings")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, &dto.PrivacySettingsResponse{
|
||||
FollowersVisibility: string(settings.FollowersVisibility),
|
||||
FollowingVisibility: string(settings.FollowingVisibility),
|
||||
PostsVisibility: string(settings.PostsVisibility),
|
||||
FavoritesVisibility: string(settings.FavoritesVisibility),
|
||||
})
|
||||
}
|
||||
|
||||
// RequestAccountDeletion 申请注销账号
|
||||
func (h *UserHandler) RequestAccountDeletion(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.RequestDeletionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.userService.RequestAccountDeletion(c.Request.Context(), userID, req.Password); err != nil {
|
||||
response.HandleError(c, err, "failed to request account deletion")
|
||||
return
|
||||
}
|
||||
|
||||
isPending, requestedAt, daysLeft, err := h.userService.GetDeletionStatus(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get deletion status")
|
||||
return
|
||||
}
|
||||
|
||||
resp := &dto.DeletionStatusResponse{
|
||||
IsPendingDeletion: isPending,
|
||||
CoolDownDays: daysLeft,
|
||||
}
|
||||
if requestedAt != nil {
|
||||
resp.DeletionRequestedAt = dto.FormatTime(*requestedAt)
|
||||
}
|
||||
|
||||
response.Success(c, resp)
|
||||
}
|
||||
|
||||
// CancelAccountDeletion 取消注销
|
||||
func (h *UserHandler) CancelAccountDeletion(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.userService.CancelAccountDeletion(c.Request.Context(), userID); err != nil {
|
||||
response.InternalServerError(c, "failed to cancel account deletion")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetDeletionStatus 获取注销状态
|
||||
func (h *UserHandler) GetDeletionStatus(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
response.Unauthorized(c, "")
|
||||
return
|
||||
}
|
||||
|
||||
isPending, requestedAt, daysLeft, err := h.userService.GetDeletionStatus(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to get deletion status")
|
||||
return
|
||||
}
|
||||
|
||||
resp := &dto.DeletionStatusResponse{
|
||||
IsPendingDeletion: isPending,
|
||||
CoolDownDays: daysLeft,
|
||||
}
|
||||
if requestedAt != nil {
|
||||
resp.DeletionRequestedAt = dto.FormatTime(*requestedAt)
|
||||
}
|
||||
|
||||
response.Success(c, resp)
|
||||
}
|
||||
|
||||
138
internal/handler/verification_handler.go
Normal file
138
internal/handler/verification_handler.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"with_you/internal/dto"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/pkg/response"
|
||||
"with_you/internal/pkg/sanitizer"
|
||||
"with_you/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type VerificationHandler struct {
|
||||
verificationService service.VerificationService
|
||||
}
|
||||
|
||||
func NewVerificationHandler(verificationService service.VerificationService) *VerificationHandler {
|
||||
return &VerificationHandler{
|
||||
verificationService: verificationService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VerificationHandler) SubmitVerification(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.SubmitVerificationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "请求参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.verificationService.SubmitVerification(
|
||||
c.Request.Context(),
|
||||
userID.(string),
|
||||
model.UserIdentity(req.Identity),
|
||||
req.RealName,
|
||||
req.StudentID,
|
||||
req.EmployeeID,
|
||||
req.Department,
|
||||
req.ProofMaterials,
|
||||
)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "提交认证申请失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, convertVerificationRecordToResponse(record))
|
||||
}
|
||||
|
||||
func (h *VerificationHandler) GetVerificationStatus(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.verificationService.GetVerificationStatus(c.Request.Context(), userID.(string))
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "获取认证状态失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, &dto.VerificationStatusResponse{
|
||||
Identity: status.Identity,
|
||||
VerificationStatus: status.VerificationStatus,
|
||||
HasPendingRequest: status.HasPendingRequest,
|
||||
LatestRecord: convertVerificationRecordToResponse(status.LatestRecord),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *VerificationHandler) GetVerificationRecords(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
response.Unauthorized(c, "未授权")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 || pageSize > 50 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
records, total, err := h.verificationService.GetVerificationRecords(c.Request.Context(), userID.(string), page, pageSize)
|
||||
if err != nil {
|
||||
response.HandleError(c, err, "获取认证记录失败")
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*dto.VerificationRecordResponse, len(records))
|
||||
for i, record := range records {
|
||||
responses[i] = convertVerificationRecordToResponse(record)
|
||||
}
|
||||
|
||||
response.Paginated(c, responses, total, page, pageSize)
|
||||
}
|
||||
|
||||
func convertVerificationRecordToResponse(record *model.VerificationRecord) *dto.VerificationRecordResponse {
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp := &dto.VerificationRecordResponse{
|
||||
ID: record.ID,
|
||||
UserID: record.UserID,
|
||||
Identity: string(record.Identity),
|
||||
Status: string(record.Status),
|
||||
RealName: sanitizer.MaskValue("real_name", record.RealName),
|
||||
StudentID: record.StudentID,
|
||||
EmployeeID: record.EmployeeID,
|
||||
Department: record.Department,
|
||||
ProofMaterials: record.ProofMaterials,
|
||||
CreatedAt: dto.FormatTime(record.CreatedAt),
|
||||
UpdatedAt: dto.FormatTime(record.UpdatedAt),
|
||||
}
|
||||
|
||||
if record.ReviewedAt != nil {
|
||||
resp.ReviewedAt = dto.FormatTime(*record.ReviewedAt)
|
||||
}
|
||||
if record.ReviewedBy != nil {
|
||||
resp.ReviewedBy = *record.ReviewedBy
|
||||
}
|
||||
if record.RejectReason != "" {
|
||||
resp.RejectReason = record.RejectReason
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user